mirror of
https://github.com/esphome/esphome.git
synced 2026-10-27 08:22:25 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f9dfbf161 | ||
|
|
dc063c5d98 | ||
|
|
be585d8885 | ||
|
|
d76c8b7de0 | ||
|
|
38295397a4 | ||
|
|
f503b7d431 | ||
|
|
49e5e050df | ||
|
|
4f717006fd | ||
|
|
7d3c5eea0e | ||
|
|
b30ac789d1 | ||
|
|
903c343bb8 |
No files matched your search
@@ -17,7 +17,7 @@ runs:
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
@@ -32,7 +32,7 @@ runs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
|
||||
# downstream jobs rely on is preserved.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
|
||||
@@ -23,13 +23,13 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up uv
|
||||
# ``--system`` (below) installs into the setup-python interpreter;
|
||||
# no venv is created or restored by this workflow.
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull-request-only workflow: a save could never be shared and
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
if: steps.tag.outputs.push == 'true'
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -154,14 +154,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
+10
-135
@@ -34,7 +34,7 @@ jobs:
|
||||
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
|
||||
# that ``. venv/bin/activate`` see an identical layout.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -406,11 +406,6 @@ jobs:
|
||||
# Registry libraries (noise-c, libsodium, ArduinoJson, lvgl) the host builds
|
||||
# download, shared per xdist worker by tests/integration/conftest.py
|
||||
INTEGRATION_LIBRARY_CACHE_PATH: ~/.esphome-integration-tests/pio_components
|
||||
# The host build's ccache (get_build_env() in esphome/host/toolchain.py)
|
||||
INTEGRATION_CCACHE_PATH: ~/.cache/esphome/host/ccache
|
||||
# Only a full matrix run saves the ccache seed: a partial run would
|
||||
# trim it to the few tests it ran
|
||||
INTEGRATION_CCACHE_SAVE: ${{ needs.determine-jobs.outputs.integration-run-all == 'true' && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -427,7 +422,7 @@ jobs:
|
||||
version: 1.2
|
||||
- name: Set up Python 3.13
|
||||
id: python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Restore integration library cache
|
||||
@@ -437,54 +432,6 @@ jobs:
|
||||
with:
|
||||
path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }}
|
||||
key: integration-libraries-v1-${{ runner.os }}-${{ hashFiles('esphome/components/json/__init__.py', 'esphome/components/noise/__init__.py', 'esphome/components/lvgl/__init__.py', 'esphome/components/improv_base/__init__.py') }}
|
||||
# A full run saves one ccache slice per bucket and restores only its own; a
|
||||
# partial run groups tests differently, so it restores all of them. One step
|
||||
# per slice of INTEGRATION_TESTS_SPLIT_BUCKETS in script/determine-jobs.py.
|
||||
- name: Restore integration ccache (slice 1/5)
|
||||
if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 0
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.INTEGRATION_CCACHE_PATH }}
|
||||
key: integration-ccache-v1-0-${{ github.run_id }}
|
||||
restore-keys: integration-ccache-v1-0-
|
||||
- name: Restore integration ccache (slice 2/5)
|
||||
if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 1
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.INTEGRATION_CCACHE_PATH }}
|
||||
key: integration-ccache-v1-1-${{ github.run_id }}
|
||||
restore-keys: integration-ccache-v1-1-
|
||||
- name: Restore integration ccache (slice 3/5)
|
||||
if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 2
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.INTEGRATION_CCACHE_PATH }}
|
||||
key: integration-ccache-v1-2-${{ github.run_id }}
|
||||
restore-keys: integration-ccache-v1-2-
|
||||
- name: Restore integration ccache (slice 4/5)
|
||||
if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 3
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.INTEGRATION_CCACHE_PATH }}
|
||||
key: integration-ccache-v1-3-${{ github.run_id }}
|
||||
restore-keys: integration-ccache-v1-3-
|
||||
- name: Restore integration ccache (slice 5/5)
|
||||
if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 4
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.INTEGRATION_CCACHE_PATH }}
|
||||
key: integration-ccache-v1-4-${{ github.run_id }}
|
||||
restore-keys: integration-ccache-v1-4-
|
||||
- name: Mark the restored ccache as unused
|
||||
# A hit refreshes an entry's mtime, so the trim before the save keeps
|
||||
# exactly what this run used; the zeroed statistics cover this run only
|
||||
run: |
|
||||
dir="${INTEGRATION_CCACHE_PATH/#\~/$HOME}"
|
||||
if [ -d "$dir" ]; then
|
||||
find "$dir" -type f -exec touch -m -d 2000-01-01 {} +
|
||||
fi
|
||||
echo "CCACHE_DIR=$dir" >> "$GITHUB_ENV"
|
||||
CCACHE_DIR="$dir" ccache -z
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
@@ -494,7 +441,7 @@ jobs:
|
||||
- name: Set up uv
|
||||
# Only needed on cache miss to populate the venv.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -515,7 +462,6 @@ jobs:
|
||||
- name: Register matcher
|
||||
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
|
||||
- name: Run integration tests
|
||||
id: integration
|
||||
env:
|
||||
# JSON array of test paths; parsed into a bash array below to avoid
|
||||
# shell word-splitting / glob hazards.
|
||||
@@ -531,32 +477,6 @@ jobs:
|
||||
# A hung test fails after 30 min: the 15 min shared build lock wait plus a cold compile
|
||||
pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal --timeout=1800 \
|
||||
--junitxml=junit-integration.xml "${test_files[@]}"
|
||||
- name: Re-run the failed tests with debug symbols
|
||||
# The first run builds without -g. A crash in the re-run prints a gdb
|
||||
# backtrace of its core; exit 1 keeps the job red when the re-run passes.
|
||||
if: failure() && steps.integration.outcome == 'failure'
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
ESPHOME_INTEGRATION_DEBUG_SYMBOLS: "1"
|
||||
ESPHOME_INTEGRATION_CORE_DIR: /tmp/esphome-cores
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
# The image's package lists usually suffice; refresh them only if not
|
||||
if ! command -v gdb >/dev/null &&
|
||||
! sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 apt-get install -y -qq gdb; then
|
||||
sudo timeout -k 15 300 apt-get update -qq &&
|
||||
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 apt-get install -y -qq gdb ||
|
||||
echo "::warning::gdb install failed; no backtraces"
|
||||
fi
|
||||
{
|
||||
mkdir -p "$ESPHOME_INTEGRATION_CORE_DIR" &&
|
||||
echo "$ESPHOME_INTEGRATION_CORE_DIR/core.%p" | sudo tee /proc/sys/kernel/core_pattern &&
|
||||
ulimit -c unlimited
|
||||
} || echo "::warning::core dumps not enabled; no backtraces"
|
||||
# Under the step limit, so a hung test still reports before Actions kills the step
|
||||
pytest -vv --no-cov --tb=native -n auto --dist worksteal --timeout=600 \
|
||||
--lf --lfnf=none tests/integration || true
|
||||
exit 1
|
||||
- name: Upload test artifacts
|
||||
# Tests that compare rendered output write the image they actually got here, so a
|
||||
# failure can be looked at without reproducing the whole build locally.
|
||||
@@ -579,19 +499,10 @@ jobs:
|
||||
if-no-files-found: error
|
||||
# A full cron period of margin for the weekly refresh
|
||||
retention-days: 14
|
||||
- name: Trim the integration ccache to this run
|
||||
# Restored entries this run did not hit are stale; dropping them keeps
|
||||
# the seed at one full run's working set instead of growing per save
|
||||
if: success() && env.INTEGRATION_CCACHE_SAVE == 'true'
|
||||
run: ccache --evict-older-than 1d
|
||||
- name: Print ccache statistics
|
||||
run: ccache -s
|
||||
- name: Save integration ccache slice
|
||||
if: success() && env.INTEGRATION_CCACHE_SAVE == 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.INTEGRATION_CCACHE_PATH }}
|
||||
key: integration-ccache-v1-${{ strategy.job-index }}-${{ github.run_id }}
|
||||
# esphome stores the host build's ccache under the machine-global
|
||||
# cache dir (see get_build_env() in esphome/host/toolchain.py).
|
||||
run: CCACHE_DIR="$HOME/.cache/esphome/host/ccache" ccache -s
|
||||
- name: Save integration library cache
|
||||
# Bucket 0 only; the others would race the same immutable key.
|
||||
if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.library-cache.outputs.cache-hit != 'true'
|
||||
@@ -600,41 +511,6 @@ jobs:
|
||||
path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }}
|
||||
key: ${{ steps.library-cache.outputs.cache-primary-key }}
|
||||
|
||||
integration-ccache-prune:
|
||||
name: Prune replaced integration ccache slices
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- determine-jobs
|
||||
- integration-tests
|
||||
# Dev only: a pull request's saves stay in its own scope and expire unused.
|
||||
# A separate job so no test code runs with the cache write scope.
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
github.ref == 'refs/heads/dev' &&
|
||||
needs.determine-jobs.outputs.integration-run-all == 'true'
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Delete the slices this run replaced
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
entries=$(gh cache list --ref refs/heads/dev --key integration-ccache-v1- --limit 100 --json id,key)
|
||||
# A slice whose bucket failed did not save; its older entry stays
|
||||
for key in $(jq -r --arg run "-$GITHUB_RUN_ID" '.[].key | select(endswith($run))' <<<"$entries"); do
|
||||
prefix="${key%-"$GITHUB_RUN_ID"}-"
|
||||
jq -r --arg p "$prefix" --argjson run "$GITHUB_RUN_ID" \
|
||||
'.[] | select((.key | startswith($p)) and (.key | ltrimstr($p) | tonumber) < $run) | .id' <<<"$entries" |
|
||||
while read -r id; do
|
||||
if ! out=$(gh api -X DELETE "repos/$GH_REPO/actions/caches/$id" 2>&1); then
|
||||
# An overlapping dev run may have deleted it already; any other error fails the job
|
||||
grep -q 'HTTP 404' <<<"$out" || { echo "$out" >&2; exit 1; }
|
||||
echo "::notice::cache $id already gone"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
import-time:
|
||||
name: Check import esphome.__main__ time
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -754,7 +630,7 @@ jobs:
|
||||
apt-get install -y libc6-dbg
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@78102a468f96d8e0ccb92d0ec49425753fc42062 # v5.6.0
|
||||
uses: CodSpeedHQ/action@821a40cd6c80e3cfe387caf55668287addd808da # v5.5.0
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -1581,7 +1457,7 @@ jobs:
|
||||
ref: main
|
||||
path: device-builder
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Set up uv
|
||||
@@ -1590,7 +1466,7 @@ jobs:
|
||||
# install step (order-of-magnitude faster on cold boots,
|
||||
# with its own wheel cache). actions/setup-python still
|
||||
# provides the interpreter.
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -1923,7 +1799,6 @@ jobs:
|
||||
- pytest
|
||||
- codecov-empty-upload
|
||||
- integration-tests
|
||||
- integration-ccache-prune
|
||||
- import-time
|
||||
- cpp-unit-tests
|
||||
- clang-tidy-single
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Build
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -126,12 +126,12 @@ jobs:
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -206,13 +206,13 @@ jobs:
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
if: matrix.registry == 'ghcr'
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
path: lib/home-assistant
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
# setup-python interpreter so subsequent ``prek`` /
|
||||
# ``script/run-in-env.py`` steps find the deps without a
|
||||
# ``uv run`` prefix.
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.17.2
|
||||
rev: v0.17.1
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -25,7 +25,6 @@ from esphome.framework_helpers import (
|
||||
get_project_link_flags,
|
||||
)
|
||||
from esphome.helpers import get_bool_env, mkdir_p, write_file_if_changed
|
||||
from esphome.platformio.library import lex_build_flags
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -155,47 +154,6 @@ def has_discovered_components() -> bool:
|
||||
return bool(get_available_components())
|
||||
|
||||
|
||||
_KEY_WARNED_BUILD_FLAGS = "warned_build_flags"
|
||||
|
||||
|
||||
def _lexed_build_flags() -> list[str]:
|
||||
"""Shell-lex the build flag entries as the other generators do, so an
|
||||
entry holding several flags is classified flag by flag. Warns once per
|
||||
entry about a flag that native ESP-IDF builds do not pass on."""
|
||||
warned: set[tuple[str, str]] = CORE.data.setdefault(KEY_ESP32, {}).setdefault(
|
||||
_KEY_WARNED_BUILD_FLAGS, set()
|
||||
)
|
||||
flags: list[str] = []
|
||||
for entry in sorted(CORE.build_flags):
|
||||
tokens = lex_build_flags(entry, "esphome build_flags")
|
||||
for token in tokens:
|
||||
if (
|
||||
(token, entry) not in warned
|
||||
and not get_project_compile_flags([token])
|
||||
and not get_project_link_flags([token])
|
||||
):
|
||||
warned.add((token, entry))
|
||||
_LOGGER.warning(
|
||||
"Build flag '%s'%s is ignored: native ESP-IDF builds only "
|
||||
"pass -D, -W and -Wl, flags",
|
||||
token,
|
||||
"" if token == entry else f" in '{entry}'",
|
||||
)
|
||||
flags += tokens
|
||||
return flags
|
||||
|
||||
|
||||
def _cmake_args(flags: list[str]) -> list[str]:
|
||||
"""Quote each flag as a CMake argument so quotes, backslashes and $
|
||||
survive."""
|
||||
return [
|
||||
'"{}"'.format(
|
||||
flag.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$")
|
||||
)
|
||||
for flag in flags
|
||||
]
|
||||
|
||||
|
||||
def _cmake_quote(value: str) -> str:
|
||||
"""Quote a cmake arg value for a set() line. add_cmake_arg rejects
|
||||
whitespace, quotes, and '$', so only backslashes need escaping."""
|
||||
@@ -336,24 +294,18 @@ def get_project_cmakelists(
|
||||
# esphome__micro-mp3) rather than just src/. Required so suppressions
|
||||
# like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
|
||||
# third-party components we don't author.
|
||||
# CMake keeps only the first copy of a repeated option; drop all but the
|
||||
# last copy instead so the last definition wins, as on the command line.
|
||||
project_compile_opts = list(
|
||||
reversed(
|
||||
dict.fromkeys(reversed(get_project_compile_flags(_lexed_build_flags())))
|
||||
)
|
||||
)
|
||||
project_compile_opts = get_project_compile_flags()
|
||||
extra_compile_options = "\n".join(
|
||||
f"idf_build_set_property(COMPILE_OPTIONS {flag} APPEND)"
|
||||
for flag in _cmake_args(project_compile_opts)
|
||||
f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
|
||||
for flag in project_compile_opts
|
||||
)
|
||||
|
||||
# Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS
|
||||
# (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as
|
||||
# -Wno-volatile is passed on a C compile.
|
||||
cxx_compile_options = "\n".join(
|
||||
f"idf_build_set_property(CXX_COMPILE_OPTIONS {flag} APPEND)"
|
||||
for flag in _cmake_args(get_project_cxx_compile_flags())
|
||||
f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)'
|
||||
for flag in get_project_cxx_compile_flags()
|
||||
)
|
||||
|
||||
cpp_standard_options = (
|
||||
@@ -509,7 +461,7 @@ def get_component_cmakelists() -> str:
|
||||
# Extract linker options (-Wl, flags). Compile flags (-D, -W) are
|
||||
# emitted project-wide via idf_build_set_property in
|
||||
# get_project_cmakelists so they reach every component, not just src/.
|
||||
link_opts = _cmake_args(get_project_link_flags(_lexed_build_flags()))
|
||||
link_opts = get_project_link_flags()
|
||||
link_opts_str = "\n ".join(link_opts) if link_opts else ""
|
||||
src_cmake = "\n".join(CORE.data[KEY_ESP32].get(KEY_SRC_CMAKE, []))
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ from esphome.cpp_helpers import ( # noqa: F401
|
||||
add_variable,
|
||||
build_registry_entry,
|
||||
build_registry_list,
|
||||
build_registry_list_merged,
|
||||
extract_registry_entry_config,
|
||||
get_slot_count,
|
||||
gpio_pin_expression,
|
||||
|
||||
@@ -253,9 +253,8 @@ template<typename... Ts> class APIRespondAction final : public Action<Ts...> {
|
||||
#endif
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
// Extract call_id from first argument - it's always first for optional/only/status modes.
|
||||
// By reference: array args are non-copyable FixedVector views.
|
||||
auto args = std::forward_as_tuple(x...);
|
||||
// Extract call_id from first argument - it's always first for optional/only/status modes
|
||||
auto args = std::make_tuple(x...);
|
||||
uint32_t call_id = std::get<0>(args);
|
||||
|
||||
bool success = this->success_.value(x...);
|
||||
@@ -309,7 +308,7 @@ template<typename... Ts> class APIUnregisterServiceCallAction final : public Act
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
// Extract call_id from first argument - same convention as APIRespondAction
|
||||
auto args = std::forward_as_tuple(x...);
|
||||
auto args = std::make_tuple(x...);
|
||||
uint32_t call_id = std::get<0>(args);
|
||||
if (call_id != 0) {
|
||||
this->parent_->unregister_active_action_call(call_id);
|
||||
|
||||
@@ -45,8 +45,9 @@ void CopyCover::control(const cover::CoverCall &call) {
|
||||
auto position = call.get_position();
|
||||
if (position.has_value())
|
||||
call2.set_position(*position);
|
||||
if (call.get_toggle().has_value())
|
||||
call2.set_command_toggle();
|
||||
auto tilt2 = call.get_tilt();
|
||||
if (tilt2.has_value())
|
||||
call2.set_tilt(*tilt2);
|
||||
call2.perform();
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,4 @@ void CopyLock::control(const lock::LockCall &call) {
|
||||
call2.perform();
|
||||
}
|
||||
|
||||
void CopyLock::open_latch() { this->source_->open(); }
|
||||
|
||||
} // namespace esphome::copy
|
||||
@@ -13,7 +13,6 @@ class CopyLock final : public lock::Lock, public Component {
|
||||
|
||||
protected:
|
||||
void control(const lock::LockCall &call) override;
|
||||
void open_latch() override;
|
||||
|
||||
lock::Lock *source_;
|
||||
};
|
||||
|
||||
@@ -57,7 +57,6 @@ class DemoCover final : public cover::Cover, public Component {
|
||||
}
|
||||
if (call.get_stop()) {
|
||||
this->cancel_timeout(MOVE_TIMEOUT_ID);
|
||||
this->current_operation = cover::COVER_OPERATION_IDLE;
|
||||
}
|
||||
|
||||
this->publish_state();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Protocol
|
||||
|
||||
from esphome import automation, core, pins
|
||||
@@ -35,8 +34,6 @@ from esphome.types import ConfigType
|
||||
DOMAIN = "display"
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
display_ns = cg.esphome_ns.namespace("display")
|
||||
Display = display_ns.class_("Display", cg.PollingComponent)
|
||||
DisplayBuffer = display_ns.class_("DisplayBuffer", Display)
|
||||
@@ -105,21 +102,13 @@ BASIC_DISPLAY_SCHEMA = cv.Schema(
|
||||
).extend(cv.polling_component_schema("1s"))
|
||||
|
||||
|
||||
def _validate_test_card(config: ConfigType) -> ConfigType:
|
||||
def _validate_test_card(config):
|
||||
if (
|
||||
config.get(CONF_SHOW_TEST_CARD, False)
|
||||
and (update_interval := config.get(CONF_UPDATE_INTERVAL)) is not None
|
||||
and update_interval.total_milliseconds == SCHEDULER_DONT_RUN
|
||||
and config.get(CONF_UPDATE_INTERVAL, False) == SCHEDULER_DONT_RUN
|
||||
):
|
||||
# Only a warning: something else can still update the display, for example
|
||||
# component.update, and the test card is drawn then.
|
||||
display_id = config.get(CONF_ID)
|
||||
_LOGGER.warning(
|
||||
"%s`%s: True` is used with `%s: never`, so the test card is only drawn "
|
||||
"when something updates the display (for example component.update)",
|
||||
f"{display_id.id}: " if display_id is not None and display_id.id else "",
|
||||
CONF_SHOW_TEST_CARD,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
raise cv.Invalid(
|
||||
f"`{CONF_SHOW_TEST_CARD}: True` cannot be used with `{CONF_UPDATE_INTERVAL}: never` because this combination will not show a test_card."
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -17,13 +17,8 @@
|
||||
#include "hal/rtc_io_hal.h"
|
||||
#endif
|
||||
|
||||
// some variants don't map rtcio to gpio 1:1
|
||||
#if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32H21)
|
||||
#define RTCIO_NEED_MAPPING 1 // NOLINT
|
||||
#endif
|
||||
|
||||
#if defined(USE_ESP32_VARIANT_ESP32) || (SOC_RTCIO_PIN_COUNT > 0 && defined(USE_GPIO_HOLD))
|
||||
#define ISR_PIN_USE_RTC 1 // NOLINT
|
||||
#ifndef SOC_GPIO_SUPPORT_RTC_INDEPENDENT
|
||||
#define SOC_GPIO_SUPPORT_RTC_INDEPENDENT 0 // NOLINT
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
@@ -58,12 +53,10 @@ struct ISRPinArg {
|
||||
#ifdef USE_GPIO_HOLD
|
||||
bool hold;
|
||||
#endif
|
||||
#ifdef ISR_PIN_USE_RTC
|
||||
#if defined(USE_ESP32_VARIANT_ESP32)
|
||||
bool use_rtc;
|
||||
#ifdef RTCIO_NEED_MAPPING
|
||||
int rtc_pin;
|
||||
#endif
|
||||
#endif
|
||||
};
|
||||
|
||||
#ifdef USE_GPIO_HOLD
|
||||
@@ -76,15 +69,17 @@ static inline void refresh_hold(const ESP32InternalGPIOPin &pin) {
|
||||
static inline void IRAM_ATTR isr_refresh_hold(const ISRPinArg *arg) {
|
||||
if (!arg->hold)
|
||||
return;
|
||||
#if defined(ISR_PIN_USE_RTC) && defined(SOC_RTCIO_HOLD_SUPPORTED)
|
||||
#if defined(USE_ESP32_VARIANT_ESP32)
|
||||
if (arg->use_rtc) {
|
||||
#ifdef RTCIO_NEED_MAPPING
|
||||
rtcio_hal_hold_disable(arg->rtc_pin);
|
||||
rtcio_hal_hold_enable(arg->rtc_pin);
|
||||
#else
|
||||
return;
|
||||
}
|
||||
#elif defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3)
|
||||
// GPIO0-21 are held from the RTC domain; the digital hold mask is zero for them
|
||||
if (arg->pin < SOC_RTCIO_PIN_COUNT) {
|
||||
rtcio_hal_hold_disable(arg->pin);
|
||||
rtcio_hal_hold_enable(arg->pin);
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
@@ -101,12 +96,10 @@ ISRInternalGPIOPin ESP32InternalGPIOPin::to_isr() const {
|
||||
#ifdef USE_GPIO_HOLD
|
||||
arg->hold = this->get_hold();
|
||||
#endif
|
||||
#ifdef ISR_PIN_USE_RTC
|
||||
#if defined(USE_ESP32_VARIANT_ESP32)
|
||||
arg->use_rtc = rtc_gpio_is_valid_gpio(this->get_pin_num());
|
||||
#ifdef RTCIO_NEED_MAPPING
|
||||
if (arg->use_rtc)
|
||||
arg->rtc_pin = rtc_io_number_get(this->get_pin_num());
|
||||
#endif
|
||||
#endif
|
||||
return ISRInternalGPIOPin((void *) arg);
|
||||
}
|
||||
@@ -258,8 +251,7 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) {
|
||||
if (diff & gpio::FLAG_INPUT) {
|
||||
if (flags & gpio::FLAG_INPUT) {
|
||||
gpio_hal_input_enable(&GPIO_HAL, arg->pin);
|
||||
#ifdef USE_ESP32_VARIANT_ESP32
|
||||
// Only ESP32 uses the RTC registers for pullup/pulldown
|
||||
#if defined(USE_ESP32_VARIANT_ESP32)
|
||||
if (arg->use_rtc) {
|
||||
if (flags & gpio::FLAG_PULLUP) {
|
||||
rtcio_hal_pullup_enable(arg->rtc_pin);
|
||||
|
||||
@@ -138,11 +138,6 @@ static uint8_t calc_checksum(const void *data, size_t size) {
|
||||
return xor_bytes(static_cast<const uint8_t *>(data), size);
|
||||
}
|
||||
|
||||
// Simple mode needs simple reporting from the module; Normal and Calibrate read energy frames
|
||||
static uint16_t system_mode_for(uint8_t mode) {
|
||||
return mode == OP_SIMPLE_MODE ? CMD_SYSTEM_MODE_SIMPLE : CMD_SYSTEM_MODE_ENERGY;
|
||||
}
|
||||
|
||||
static int32_t get_firmware_int(const char *version_string) {
|
||||
// Convert "v1.5.4" -> 154 by skipping 'v' and '.', accumulating digits
|
||||
const char *p = (*version_string == 'v') ? version_string + 1 : version_string;
|
||||
@@ -212,10 +207,13 @@ void LD2420Component::setup() {
|
||||
|
||||
memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
|
||||
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
|
||||
this->set_operating_mode(OP_SIMPLE_MODE);
|
||||
this->set_mode_(CMD_SYSTEM_MODE_SIMPLE);
|
||||
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
|
||||
} else {
|
||||
this->set_operating_mode(OP_NORMAL_MODE);
|
||||
this->set_mode_(CMD_SYSTEM_MODE_ENERGY);
|
||||
}
|
||||
this->set_operating_mode(OP_NORMAL_MODE); // older firmware is held in Simple
|
||||
this->set_mode_(system_mode_for(this->current_operating_mode));
|
||||
#ifdef USE_NUMBER
|
||||
this->init_gate_config_numbers();
|
||||
#endif
|
||||
@@ -246,9 +244,7 @@ void LD2420Component::apply_config_action() {
|
||||
#endif
|
||||
this->set_system_mode(this->system_mode_);
|
||||
this->set_config_mode(false); // Disable config mode to save new values in LD2420 nvm
|
||||
// Applying the calibrated thresholds ends calibration; Normal and Simple stay as they are
|
||||
if (this->current_operating_mode == OP_CALIBRATE_MODE)
|
||||
this->set_operating_mode(OP_NORMAL_MODE);
|
||||
this->set_operating_mode(OP_NORMAL_MODE);
|
||||
}
|
||||
|
||||
void LD2420Component::factory_reset_action() {
|
||||
@@ -360,22 +356,6 @@ void LD2420Component::set_operating_mode(const char *state) {
|
||||
}
|
||||
}
|
||||
|
||||
void LD2420Component::change_operating_mode(OpMode mode) {
|
||||
this->set_operating_mode(mode);
|
||||
const uint16_t system_mode = system_mode_for(this->current_operating_mode);
|
||||
if (system_mode == this->get_mode_())
|
||||
return;
|
||||
if (this->set_config_mode(true) != LD2420_ERROR_TIMEOUT) {
|
||||
this->set_system_mode(system_mode);
|
||||
this->set_config_mode(false);
|
||||
}
|
||||
if (this->get_mode_() != system_mode) {
|
||||
// The module kept its reporting mode; show the mode that matches it
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
this->set_operating_mode(this->get_mode_() == CMD_SYSTEM_MODE_SIMPLE ? OP_SIMPLE_MODE : OP_NORMAL_MODE);
|
||||
}
|
||||
}
|
||||
|
||||
void LD2420Component::set_operating_mode(OpMode mode) {
|
||||
// Older firmware only supports simple mode
|
||||
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
|
||||
|
||||
@@ -119,7 +119,6 @@ class LD2420Component final : public Component, public uart::UARTDevice {
|
||||
void handle_cmd_error(uint16_t error);
|
||||
void set_operating_mode(const char *state);
|
||||
void set_operating_mode(OpMode mode);
|
||||
void change_operating_mode(OpMode mode);
|
||||
void auto_calibrate_sensitivity();
|
||||
void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number);
|
||||
uint8_t set_config_mode(bool enable);
|
||||
|
||||
@@ -30,5 +30,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
CONF_OPERATING_MODE,
|
||||
hub.set_operating_mode_select,
|
||||
options=CONF_SELECTS,
|
||||
apply="change_operating_mode(static_cast<ld2420::OpMode>(index + 1))",
|
||||
apply="set_operating_mode(static_cast<ld2420::OpMode>(index + 1))",
|
||||
publish="before",
|
||||
)
|
||||
@@ -339,8 +339,8 @@ def final_validation(config_list):
|
||||
|
||||
def lv_conf_path_flag(lv_conf_h_file: Path) -> str:
|
||||
"""The ``-DLV_CONF_PATH`` build flag, decoded identically by the shell
|
||||
lexers (PlatformIO, host, ESP8266, and ESP-IDF before it quotes each
|
||||
token for CMake) and the raw quoted CMake argument (Zephyr)."""
|
||||
lexers (PlatformIO, host, ESP8266) and the quoted CMake arguments
|
||||
(ESP-IDF, Zephyr) the build flags pass through."""
|
||||
# forward slashes for Windows, then escape what shlex or a quoted CMake
|
||||
# argument would not pass through literally
|
||||
path = re.sub(r"[^\w/.:+=,-]", r"\\\g<0>", Path(lv_conf_h_file).as_posix())
|
||||
@@ -414,8 +414,7 @@ async def to_code(configs):
|
||||
"LV_COLOR_CHROMA_KEY",
|
||||
await lvalid.lv_color.process(config_0[df.CONF_TRANSPARENCY_KEY]),
|
||||
)
|
||||
if not CORE.using_toolchain_esp_idf:
|
||||
cg.add_build_flag("-Isrc")
|
||||
cg.add_build_flag("-Isrc")
|
||||
|
||||
cg.add_global(lvgl_ns.using)
|
||||
for font in df.get_esphome_fonts_used():
|
||||
|
||||
@@ -452,16 +452,6 @@ bool Nextion::remove_from_q_(bool report_empty) {
|
||||
}
|
||||
NextionComponentBase *component = nb->component;
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
if (!nb->pending_command.empty()) {
|
||||
// Not sent yet, so this response cannot belong to it. Dropping the
|
||||
// response leaves the command queued instead of losing it.
|
||||
ESP_LOGW(TAG, "Response for unsent cmd '%s' (queue %zu), ignoring", component->get_variable_name().c_str(),
|
||||
this->nextion_queue_.size());
|
||||
return false;
|
||||
}
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
|
||||
ESP_LOGN(TAG, "Removed: %s", component->get_variable_name().c_str());
|
||||
|
||||
if (is_sleep_wake_command(component)) {
|
||||
@@ -1183,8 +1173,8 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) {
|
||||
* (e.g. add_no_result_to_queue_with_ignore_sleep_printf_()) are explicitly
|
||||
* sleep-safe and must bypass it.
|
||||
*
|
||||
* If USE_NEXTION_COMMAND_SPACING is enabled and the command cannot go out right
|
||||
* away, it is saved in the queue entry for retry rather than dropped.
|
||||
* If USE_NEXTION_COMMAND_SPACING is enabled and the pacer is not ready,
|
||||
* the command is saved in the queue entry for retry rather than dropped.
|
||||
*
|
||||
* @param variable_name Name of the variable or component associated with the command.
|
||||
* @param command The raw command string to send.
|
||||
@@ -1195,15 +1185,6 @@ void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_n
|
||||
|
||||
ESP_LOGV(TAG, "Queue NORESULT '%s': %s", variable_name.c_str(), command.c_str());
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
// Sending now would overtake commands that are still waiting, so only send when
|
||||
// nothing is waiting. process_pending_in_queue_() transmits the rest in order.
|
||||
if (this->has_unsent_commands_()) {
|
||||
this->add_no_result_to_queue_with_pending_command_(variable_name, command);
|
||||
return;
|
||||
}
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
|
||||
if (this->send_command_(command)) {
|
||||
this->add_no_result_to_queue_(variable_name);
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
@@ -1365,13 +1346,14 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) {
|
||||
std::string command = "get " + component->get_variable_name_to_send();
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
// Only send right away when nothing is waiting, otherwise this command would
|
||||
// overtake the ones still queued. Anything not sent here keeps its command for
|
||||
// process_pending_in_queue_() to transmit in order.
|
||||
if (this->has_unsent_commands_() || !this->send_command_(command)) {
|
||||
nextion_queue->pending_command = std::move(command);
|
||||
}
|
||||
// Always enqueue first so the response handler is present when the command
|
||||
// is eventually sent. Store the command for retry if spacing blocked it;
|
||||
// process_pending_in_queue_() will transmit it when the pacer allows.
|
||||
nextion_queue->pending_command = command;
|
||||
this->nextion_queue_.push_back(nextion_queue);
|
||||
if (this->send_command_(command)) {
|
||||
nextion_queue->pending_command.clear();
|
||||
}
|
||||
#else // USE_NEXTION_COMMAND_SPACING
|
||||
if (this->send_command_(command)) {
|
||||
this->nextion_queue_.push_back(nextion_queue);
|
||||
|
||||
@@ -1470,16 +1470,6 @@ class Nextion final : public NextionBase, public PollingComponent, public uart::
|
||||
* Called from loop() to retry sending commands that were delayed by spacing.
|
||||
*/
|
||||
void process_pending_in_queue_();
|
||||
|
||||
/**
|
||||
* @brief Whether the queue still holds a command that has not reached the wire.
|
||||
*
|
||||
* The queue is kept in transmit order, so anything still waiting sits at the back.
|
||||
*/
|
||||
bool has_unsent_commands_() const {
|
||||
return !this->nextion_queue_.empty() && this->nextion_queue_.back() != nullptr &&
|
||||
!this->nextion_queue_.back()->pending_command.empty();
|
||||
}
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
|
||||
std::list<NextionQueue *> nextion_queue_;
|
||||
|
||||
@@ -45,7 +45,6 @@ from esphome.const import (
|
||||
CONF_TIMEOUT,
|
||||
CONF_TO,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_TYPE_ID,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_USE_FAHRENHEIT,
|
||||
CONF_VALUE,
|
||||
@@ -1009,26 +1008,17 @@ def _timeout_throttle_period(
|
||||
return timeout[CONF_TIMEOUT]
|
||||
|
||||
|
||||
def _timeout_throttle_merge(first: ConfigType, second: ConfigType) -> tuple | None:
|
||||
if (period := _timeout_throttle_period(first, second)) is None:
|
||||
return None
|
||||
return TimeoutThrottleFilter, [period]
|
||||
|
||||
|
||||
async def _build_chain_filters(config: list[ConfigType]) -> list:
|
||||
"""Like build_filters, but merges the radar `timeout` + `throttle_with_priority` pair into one filter."""
|
||||
filters = []
|
||||
i = 0
|
||||
while i < len(config):
|
||||
if (
|
||||
i + 1 < len(config)
|
||||
and (period := _timeout_throttle_period(config[i], config[i + 1]))
|
||||
is not None
|
||||
):
|
||||
filter_id = config[i][CONF_TYPE_ID].copy()
|
||||
filter_id.type = TimeoutThrottleFilter
|
||||
var = cg.new_Pvariable(filter_id, period)
|
||||
await cg.register_component(var, {})
|
||||
filters.append(var)
|
||||
i += 2
|
||||
continue
|
||||
filters.append(await cg.build_registry_entry(FILTER_REGISTRY, config[i]))
|
||||
i += 1
|
||||
return filters
|
||||
return await cg.build_registry_list_merged(
|
||||
FILTER_REGISTRY, config, _timeout_throttle_merge
|
||||
)
|
||||
|
||||
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
|
||||
@@ -124,7 +124,6 @@ CoverTraits TemplateCover::get_traits() {
|
||||
Trigger<float> *TemplateCover::get_position_trigger() { return &this->position_trigger_; }
|
||||
Trigger<float> *TemplateCover::get_tilt_trigger() { return &this->tilt_trigger_; }
|
||||
void TemplateCover::stop_prev_trigger_() {
|
||||
this->position_trigger_.stop_action();
|
||||
if (this->prev_command_trigger_ != nullptr) {
|
||||
this->prev_command_trigger_->stop_action();
|
||||
this->prev_command_trigger_ = nullptr;
|
||||
|
||||
@@ -13,7 +13,9 @@ from esphome.const import (
|
||||
CONF_ON_RAW_VALUE,
|
||||
CONF_ON_VALUE,
|
||||
CONF_STATE,
|
||||
CONF_TIMEOUT,
|
||||
CONF_TO,
|
||||
CONF_VALUE,
|
||||
DEVICE_CLASS_DATE,
|
||||
DEVICE_CLASS_EMPTY,
|
||||
DEVICE_CLASS_TIMESTAMP,
|
||||
@@ -59,6 +61,18 @@ AppendFilter = text_sensor_ns.class_("AppendFilter", Filter)
|
||||
PrependFilter = text_sensor_ns.class_("PrependFilter", Filter)
|
||||
SubstituteFilter = text_sensor_ns.class_("SubstituteFilter", Filter)
|
||||
MapFilter = text_sensor_ns.class_("MapFilter", Filter)
|
||||
ThrottleFilter = text_sensor_ns.class_("ThrottleFilter", Filter)
|
||||
ThrottleWithPriorityFilter = text_sensor_ns.class_(
|
||||
"ThrottleWithPriorityFilter", ThrottleFilter
|
||||
)
|
||||
TimeoutFilterBase = text_sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component)
|
||||
TimeoutFilterLast = text_sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase)
|
||||
TimeoutFilterConfigured = text_sensor_ns.class_(
|
||||
"TimeoutFilterConfigured", TimeoutFilterBase
|
||||
)
|
||||
TimeoutThrottleFilter = text_sensor_ns.class_(
|
||||
"TimeoutThrottleFilter", TimeoutFilterLast
|
||||
)
|
||||
Substitution = text_sensor_ns.struct("Substitution")
|
||||
|
||||
|
||||
@@ -128,6 +142,80 @@ async def substitution_filter_to_code(
|
||||
return cg.new_Pvariable(filter_id, _substitution_table(config))
|
||||
|
||||
|
||||
@FILTER_REGISTRY.register(
|
||||
"throttle", ThrottleFilter, cv.positive_time_period_milliseconds
|
||||
)
|
||||
async def throttle_filter_to_code(config: ConfigType, filter_id: ID) -> MockObj:
|
||||
return cg.new_Pvariable(filter_id, config)
|
||||
|
||||
|
||||
THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value(
|
||||
{
|
||||
cv.Required(CONF_TIMEOUT): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_VALUE, default=[]): cv.ensure_list(cv.string),
|
||||
},
|
||||
key=CONF_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@FILTER_REGISTRY.register(
|
||||
"throttle_with_priority", ThrottleWithPriorityFilter, THROTTLE_WITH_PRIORITY_SCHEMA
|
||||
)
|
||||
async def throttle_with_priority_filter_to_code(
|
||||
config: ConfigType, filter_id: ID
|
||||
) -> MockObj:
|
||||
if not (values := config[CONF_VALUE]):
|
||||
filter_id = filter_id.copy()
|
||||
filter_id.type = ThrottleFilter
|
||||
return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT])
|
||||
return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], _priority_table(values))
|
||||
|
||||
|
||||
def _priority_table(values: list[str]) -> MockObj:
|
||||
"""Shared PROGMEM list of priority texts, ended by nullptr; nullptr without any."""
|
||||
if not values:
|
||||
return cg.nullptr
|
||||
return cg.shared_progmem_array(
|
||||
"text_sensor_priorities",
|
||||
cg.const_char_ptr,
|
||||
cg.ArrayInitializer(*values, cg.nullptr),
|
||||
)
|
||||
|
||||
|
||||
def timeout_throttle_filters(priority: str, period_ms: int = 1000) -> list[ConfigType]:
|
||||
"""Default filters for a text entity: resend the last value after a pause, at most one value a period,
|
||||
`priority` at once. Built as one filter, like sensor.TIMEOUT_THROTTLE_FILTERS."""
|
||||
period = cv.TimePeriod(milliseconds=period_ms)
|
||||
return [
|
||||
{"timeout": {CONF_TIMEOUT: period, CONF_VALUE: "last"}},
|
||||
{"throttle_with_priority": {CONF_TIMEOUT: period, CONF_VALUE: [priority]}},
|
||||
]
|
||||
|
||||
|
||||
TIMEOUT_SCHEMA = cv.maybe_simple_value(
|
||||
{
|
||||
cv.Required(CONF_TIMEOUT): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_VALUE, default="last"): cv.string,
|
||||
},
|
||||
key=CONF_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@FILTER_REGISTRY.register("timeout", TimeoutFilterBase, TIMEOUT_SCHEMA)
|
||||
async def timeout_filter_to_code(config: ConfigType, filter_id: ID) -> MockObj:
|
||||
filter_id = filter_id.copy()
|
||||
args = [config[CONF_TIMEOUT]]
|
||||
# "last" is a keyword, so a timeout cannot send that literal text
|
||||
if (value := config[CONF_VALUE]) == "last":
|
||||
filter_id.type = TimeoutFilterLast
|
||||
else:
|
||||
filter_id.type = TimeoutFilterConfigured
|
||||
args.append(value)
|
||||
var = cg.new_Pvariable(filter_id, *args)
|
||||
await cg.register_component(var, {})
|
||||
return var
|
||||
|
||||
|
||||
validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_")
|
||||
|
||||
_TEXT_SENSOR_SCHEMA = (
|
||||
@@ -184,6 +272,32 @@ async def build_filters(config):
|
||||
return await cg.build_registry_list(FILTER_REGISTRY, config)
|
||||
|
||||
|
||||
def _timeout_throttle_pair(first: ConfigType, second: ConfigType) -> tuple | None:
|
||||
"""Period and priority texts of a `timeout` (value `last`) directly followed by a
|
||||
`throttle_with_priority` with the same period, or None."""
|
||||
if (timeout := first.get(CONF_TIMEOUT)) is None or (
|
||||
throttle := second.get("throttle_with_priority")
|
||||
) is None:
|
||||
return None
|
||||
if timeout[CONF_VALUE] != "last" or timeout[CONF_TIMEOUT] != throttle[CONF_TIMEOUT]:
|
||||
return None
|
||||
return timeout[CONF_TIMEOUT], throttle[CONF_VALUE]
|
||||
|
||||
|
||||
def _timeout_throttle_merge(first: ConfigType, second: ConfigType) -> tuple | None:
|
||||
if (pair := _timeout_throttle_pair(first, second)) is None:
|
||||
return None
|
||||
period, values = pair
|
||||
return TimeoutThrottleFilter, [period, _priority_table(values)]
|
||||
|
||||
|
||||
async def _build_chain_filters(config: list[ConfigType]) -> list:
|
||||
"""Like build_filters, but merges a `timeout` + `throttle_with_priority` pair into one filter."""
|
||||
return await cg.build_registry_list_merged(
|
||||
FILTER_REGISTRY, config, _timeout_throttle_merge
|
||||
)
|
||||
|
||||
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_VALUE, "add_on_state_callback", [(cg.std_string, "x")]
|
||||
@@ -205,7 +319,7 @@ async def setup_text_sensor_core_(var, config):
|
||||
|
||||
if config.get(CONF_FILTERS): # must exist and not be empty
|
||||
cg.add_define("USE_TEXT_SENSOR_FILTER")
|
||||
filters = await build_filters(config[CONF_FILTERS])
|
||||
filters = await _build_chain_filters(config[CONF_FILTERS])
|
||||
for filter_ in filters:
|
||||
cg.add(var.add_filter(filter_))
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include "filter.h"
|
||||
#include "text_sensor.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
@@ -98,6 +99,65 @@ bool MapFilter::new_value(std::string &value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Throttle
|
||||
// True when a value may pass: the period is over, or the value is in the nullptr-ended `prioritized` list
|
||||
static bool throttle_passes(uint32_t &last_input, uint32_t period, const std::string &value,
|
||||
const char *const *prioritized) {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
bool pass = last_input == 0 || now - last_input >= period;
|
||||
for (const char *const *p = prioritized; !pass && p != nullptr && *p != nullptr; p++)
|
||||
pass = value == *p;
|
||||
if (pass)
|
||||
last_input = now;
|
||||
return pass;
|
||||
}
|
||||
|
||||
bool ThrottleFilter::new_value(std::string &value) {
|
||||
return throttle_passes(this->last_input_, this->min_time_between_inputs_, value, nullptr);
|
||||
}
|
||||
|
||||
bool ThrottleWithPriorityFilter::new_value(std::string &value) {
|
||||
return throttle_passes(this->last_input_, this->min_time_between_inputs_, value, this->prioritized_);
|
||||
}
|
||||
|
||||
// Timeout
|
||||
bool TimeoutFilterBase::new_value(std::string &value) {
|
||||
this->timeout_start_time_ = App.get_loop_component_start_time();
|
||||
this->enable_loop();
|
||||
return true;
|
||||
}
|
||||
|
||||
void TimeoutFilterBase::loop() {
|
||||
if (App.get_loop_component_start_time() - this->timeout_start_time_ < this->time_period_)
|
||||
return;
|
||||
// Before sending, so a callback that publishes to this sensor again re-arms the timeout
|
||||
this->disable_loop();
|
||||
this->send_timeout_value();
|
||||
}
|
||||
|
||||
void TimeoutFilterBase::send_(const char *text, size_t len) {
|
||||
if (this->next_ == nullptr) {
|
||||
this->parent_->internal_send_state_to_frontend(text, len);
|
||||
} else {
|
||||
this->next_->input(std::string(text, len));
|
||||
}
|
||||
}
|
||||
|
||||
bool TimeoutFilterLast::new_value(std::string &value) {
|
||||
this->last_ = value; // reuses last_'s buffer
|
||||
return TimeoutFilterBase::new_value(value);
|
||||
}
|
||||
|
||||
bool TimeoutThrottleFilter::new_value(std::string &value) {
|
||||
TimeoutFilterLast::new_value(value);
|
||||
return throttle_passes(this->last_input_, this->time_period_, value, this->prioritized_);
|
||||
}
|
||||
|
||||
void TimeoutThrottleFilter::send_timeout_value() {
|
||||
if (throttle_passes(this->last_input_, this->time_period_, this->last_, this->prioritized_))
|
||||
this->send_(this->last_.data(), this->last_.size());
|
||||
}
|
||||
|
||||
} // namespace esphome::text_sensor
|
||||
|
||||
#endif // USE_TEXT_SENSOR_FILTER
|
||||
@@ -169,6 +169,82 @@ class MapFilter : public Filter {
|
||||
const Substitution *mappings_;
|
||||
};
|
||||
|
||||
/// Drops a value that comes sooner than `min_time_between_inputs` after the last one let through.
|
||||
class ThrottleFilter : public Filter {
|
||||
public:
|
||||
explicit ThrottleFilter(uint32_t min_time_between_inputs) : min_time_between_inputs_(min_time_between_inputs) {}
|
||||
bool new_value(std::string &value) override;
|
||||
|
||||
protected:
|
||||
uint32_t last_input_{0};
|
||||
uint32_t min_time_between_inputs_;
|
||||
};
|
||||
|
||||
/// Same as ThrottleFilter, but a value in `prioritized` always passes. `prioritized` is a codegen table
|
||||
/// in flash, shared by filters with the same list and ended by nullptr.
|
||||
class ThrottleWithPriorityFilter : public ThrottleFilter {
|
||||
public:
|
||||
ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, const char *const *prioritized)
|
||||
: ThrottleFilter(min_time_between_inputs), prioritized_(prioritized) {}
|
||||
bool new_value(std::string &value) override;
|
||||
|
||||
protected:
|
||||
const char *const *prioritized_;
|
||||
};
|
||||
|
||||
/// Passes every value on and, once none has come for `time_period`, sends one value again. A Component rather than
|
||||
/// a scheduler timeout for the reason given on the sensor's TimeoutFilterBase: it re-arms on every value.
|
||||
class TimeoutFilterBase : public Filter, public Component {
|
||||
public:
|
||||
bool new_value(std::string &value) override;
|
||||
void loop() override;
|
||||
float get_setup_priority() const override { return setup_priority::HARDWARE; }
|
||||
|
||||
protected:
|
||||
explicit TimeoutFilterBase(uint32_t time_period) : time_period_(time_period) { this->disable_loop(); }
|
||||
/// Sends `text`; never moves a stored string on, so it keeps its buffer.
|
||||
void send_(const char *text, size_t len);
|
||||
virtual void send_timeout_value() = 0;
|
||||
|
||||
uint32_t time_period_;
|
||||
uint32_t timeout_start_time_{0};
|
||||
};
|
||||
|
||||
/// Sends the last value again.
|
||||
class TimeoutFilterLast : public TimeoutFilterBase {
|
||||
public:
|
||||
explicit TimeoutFilterLast(uint32_t time_period) : TimeoutFilterBase(time_period) {}
|
||||
bool new_value(std::string &value) override;
|
||||
|
||||
protected:
|
||||
void send_timeout_value() override { this->send_(this->last_.data(), this->last_.size()); }
|
||||
std::string last_;
|
||||
};
|
||||
|
||||
/// Codegen use only: `timeout` (value `last`) followed by `throttle_with_priority` with the same period, as one
|
||||
/// object. Output matches the two filter chain exactly.
|
||||
class TimeoutThrottleFilter : public TimeoutFilterLast {
|
||||
public:
|
||||
TimeoutThrottleFilter(uint32_t time_period, const char *const *prioritized)
|
||||
: TimeoutFilterLast(time_period), prioritized_(prioritized) {}
|
||||
bool new_value(std::string &value) override;
|
||||
|
||||
protected:
|
||||
void send_timeout_value() override;
|
||||
const char *const *prioritized_;
|
||||
uint32_t last_input_{0}; // When the throttle stage last let a value through
|
||||
};
|
||||
|
||||
/// Sends the configured text.
|
||||
class TimeoutFilterConfigured : public TimeoutFilterBase {
|
||||
public:
|
||||
TimeoutFilterConfigured(uint32_t time_period, const char *value) : TimeoutFilterBase(time_period), value_(value) {}
|
||||
|
||||
protected:
|
||||
void send_timeout_value() override { this->send_(this->value_, strlen(this->value_)); }
|
||||
const char *value_;
|
||||
};
|
||||
|
||||
} // namespace esphome::text_sensor
|
||||
|
||||
#endif // USE_TEXT_SENSOR_FILTER
|
||||
@@ -5,37 +5,25 @@ import esphome.codegen as cg
|
||||
from esphome.components import display, network, socket
|
||||
from esphome.components.const import (
|
||||
BYTE_ORDER_LITTLE,
|
||||
CONF_BUTTONS,
|
||||
CONF_HOST,
|
||||
CONF_RECONNECT_INTERVAL,
|
||||
)
|
||||
from esphome.components.touchscreen import CONF_TOUCHSCREEN_ID, Touchscreen
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACTION,
|
||||
CONF_AUTO_CLEAR_ENABLED,
|
||||
CONF_DISPLAY,
|
||||
CONF_DISPLAY_ID,
|
||||
CONF_ID,
|
||||
CONF_KEY,
|
||||
CONF_ON_CONNECT,
|
||||
CONF_ON_DISCONNECT,
|
||||
CONF_PLATFORM,
|
||||
CONF_PORT,
|
||||
CONF_TEXT,
|
||||
CONF_X,
|
||||
CONF_Y,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
|
||||
from .keysyms import button_mask, keysym
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@MindFreeze"]
|
||||
DEPENDENCIES = ["display", "network"]
|
||||
AUTO_LOAD = ["socket", "unicode"]
|
||||
AUTO_LOAD = ["socket"]
|
||||
DOMAIN = "vnc"
|
||||
MULTI_CONF = True
|
||||
|
||||
@@ -46,14 +34,11 @@ CONF_PIXEL_FORMAT = "pixel_format"
|
||||
vnc_ns = cg.esphome_ns.namespace("vnc")
|
||||
VncClient = vnc_ns.class_("VncClient", cg.Component)
|
||||
VncPixelFormat = vnc_ns.enum("VncPixelFormat", is_class=True)
|
||||
VncKeyAction = vnc_ns.enum("VncKeyAction", is_class=True)
|
||||
|
||||
PIXEL_FORMATS = VncPixelFormat.options(
|
||||
"auto", "rgb565", "rgb332", prefix="VNC_PIXEL_FORMAT_"
|
||||
)
|
||||
|
||||
KEY_ACTIONS = VncKeyAction.options("press", "down", "up", prefix="VNC_KEY_ACTION_")
|
||||
|
||||
# Drivers that cannot take another component's pixels yet.
|
||||
UNSUPPORTED_DISPLAYS = dict.fromkeys(
|
||||
("mipi_dsi", "mipi_rgb", "mipi_spi"), "it is not supported by vnc yet"
|
||||
@@ -87,7 +72,6 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_PIXEL_FORMAT, default="auto"): cv.enum(
|
||||
PIXEL_FORMATS, lower=True
|
||||
),
|
||||
cv.Optional(CONF_TOUCHSCREEN_ID): cv.use_id(Touchscreen),
|
||||
cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}),
|
||||
}
|
||||
@@ -131,14 +115,6 @@ def _final_validate(config: ConfigType) -> None:
|
||||
raise cv.Invalid(
|
||||
f"Display '{disp_id}' is also used by LVGL", path=[CONF_DISPLAY_ID]
|
||||
)
|
||||
if (ts_id := config.get(CONF_TOUCHSCREEN_ID)) is not None:
|
||||
ts_display = fv.get_declaring_config(ts_id).get(CONF_DISPLAY)
|
||||
if ts_display is not None and ts_display.id != disp_id.id:
|
||||
raise cv.Invalid(
|
||||
f"Touchscreen '{ts_id}' belongs to display '{ts_display}', "
|
||||
f"not '{disp_id}'",
|
||||
path=[CONF_TOUCHSCREEN_ID],
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
@@ -162,89 +138,15 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_update_delay(config[CONF_UPDATE_DELAY]))
|
||||
cg.add(var.set_max_update_delay(config[CONF_MAX_UPDATE_DELAY]))
|
||||
cg.add(var.set_pixel_format(config[CONF_PIXEL_FORMAT]))
|
||||
if (
|
||||
await cg.add_variable(var.register_touchscreen, config.get(CONF_TOUCHSCREEN_ID))
|
||||
is not None
|
||||
):
|
||||
cg.add_define("USE_VNC_INPUT")
|
||||
cg.add_define("USE_VNC_TOUCHSCREEN")
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
_CLIENT_ID = {cv.GenerateID(): cv.use_id(VncClient)}
|
||||
|
||||
VNC_ID_SCHEMA = automation.maybe_simple_id(_CLIENT_ID)
|
||||
VNC_ID_SCHEMA = automation.maybe_simple_id(
|
||||
{cv.GenerateID(): cv.use_id(VncClient)},
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"vnc.refresh", VNC_ID_SCHEMA, automation.ApplyCall("refresh()")
|
||||
)
|
||||
|
||||
automation.register_apply_condition("vnc.is_connected", VNC_ID_SCHEMA, "is_connected()")
|
||||
|
||||
|
||||
def _register_input_action(
|
||||
name: str, schema: cv.Schema, *fields: automation.ApplyField | automation.ApplyCall
|
||||
) -> None:
|
||||
builder = automation.apply_action_builder(name, schema, *fields)
|
||||
|
||||
@automation.register_action(name, automation.ApplyAction, schema, synchronous=True)
|
||||
async def input_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
cg.add_define("USE_VNC_INPUT")
|
||||
return await builder(config, action_id, template_arg, args)
|
||||
|
||||
|
||||
_register_input_action(
|
||||
"vnc.send_pointer",
|
||||
cv.Schema(
|
||||
{
|
||||
**_CLIENT_ID,
|
||||
cv.Required(CONF_X): cv.templatable(cv.uint16_t),
|
||||
cv.Required(CONF_Y): cv.templatable(cv.uint16_t),
|
||||
cv.Optional(CONF_BUTTONS, default=0): cv.templatable(button_mask),
|
||||
}
|
||||
),
|
||||
automation.ApplyCall(
|
||||
"send_pointer({}, {}, {})",
|
||||
((CONF_X, cg.uint16), (CONF_Y, cg.uint16), (CONF_BUTTONS, cg.uint8)),
|
||||
),
|
||||
)
|
||||
|
||||
_register_input_action(
|
||||
"vnc.send_key",
|
||||
automation.maybe_conf(
|
||||
CONF_KEY,
|
||||
cv.Schema(
|
||||
{
|
||||
**_CLIENT_ID,
|
||||
cv.Required(CONF_KEY): cv.templatable(keysym),
|
||||
cv.Optional(CONF_ACTION, default="press"): cv.enum(
|
||||
KEY_ACTIONS, lower=True
|
||||
),
|
||||
}
|
||||
),
|
||||
),
|
||||
automation.ApplyCall(
|
||||
"send_key({}, {})", ((CONF_KEY, cg.uint32), (CONF_ACTION, VncKeyAction))
|
||||
),
|
||||
)
|
||||
|
||||
_register_input_action(
|
||||
"vnc.type_text",
|
||||
automation.maybe_conf(
|
||||
CONF_TEXT,
|
||||
cv.Schema(
|
||||
{
|
||||
**_CLIENT_ID,
|
||||
cv.Required(CONF_TEXT): cv.templatable(cv.string),
|
||||
}
|
||||
),
|
||||
),
|
||||
automation.ApplyField(
|
||||
CONF_TEXT, "type_text", cg.std_string, const_fn=automation.literal_with_length
|
||||
),
|
||||
)
|
||||
@@ -1,136 +0,0 @@
|
||||
"""X11 keysyms and pointer button masks for VNC input, resolved at code generation."""
|
||||
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import HexInt
|
||||
|
||||
_KEYSYM_MAX = 0x1FFFFFFF
|
||||
# X11 encodes a Unicode character outside Latin-1 as this prefix plus its code point
|
||||
_UNICODE_KEYSYM = 0x01000000
|
||||
|
||||
KEYSYMS: dict[str, int] = {
|
||||
"BackSpace": 0xFF08,
|
||||
"Tab": 0xFF09,
|
||||
"Return": 0xFF0D,
|
||||
"Pause": 0xFF13,
|
||||
"Scroll_Lock": 0xFF14,
|
||||
"Escape": 0xFF1B,
|
||||
"Home": 0xFF50,
|
||||
"Left": 0xFF51,
|
||||
"Up": 0xFF52,
|
||||
"Right": 0xFF53,
|
||||
"Down": 0xFF54,
|
||||
"Page_Up": 0xFF55,
|
||||
"Page_Down": 0xFF56,
|
||||
"End": 0xFF57,
|
||||
"Print": 0xFF61,
|
||||
"Insert": 0xFF63,
|
||||
"Menu": 0xFF67,
|
||||
"Num_Lock": 0xFF7F,
|
||||
"KP_Enter": 0xFF8D,
|
||||
**{f"F{n}": 0xFFBE + n - 1 for n in range(1, 13)},
|
||||
"Shift_L": 0xFFE1,
|
||||
"Shift_R": 0xFFE2,
|
||||
"Control_L": 0xFFE3,
|
||||
"Control_R": 0xFFE4,
|
||||
"Caps_Lock": 0xFFE5,
|
||||
"Meta_L": 0xFFE7,
|
||||
"Meta_R": 0xFFE8,
|
||||
"Alt_L": 0xFFE9,
|
||||
"Alt_R": 0xFFEA,
|
||||
"Super_L": 0xFFEB,
|
||||
"Super_R": 0xFFEC,
|
||||
"Delete": 0xFFFF,
|
||||
"space": 0x20,
|
||||
"ISO_Left_Tab": 0xFE20,
|
||||
}
|
||||
|
||||
ALIASES: dict[str, str] = {
|
||||
"alt": "Alt_L",
|
||||
"ctrl": "Control_L",
|
||||
"del": "Delete",
|
||||
"enter": "Return",
|
||||
"esc": "Escape",
|
||||
"ins": "Insert",
|
||||
"next": "Page_Down",
|
||||
"pgdn": "Page_Down",
|
||||
"pgup": "Page_Up",
|
||||
"prior": "Page_Up",
|
||||
"shift": "Shift_L",
|
||||
"super": "Super_L",
|
||||
}
|
||||
|
||||
_BY_LOWER_NAME: dict[str, int] = {
|
||||
**{name.lower(): code for name, code in KEYSYMS.items()},
|
||||
**{alias: KEYSYMS[name] for alias, name in ALIASES.items()},
|
||||
}
|
||||
|
||||
BUTTONS: dict[str, int] = {
|
||||
"left": 1,
|
||||
"middle": 2,
|
||||
"right": 4,
|
||||
"wheel_up": 8,
|
||||
"wheel_down": 16,
|
||||
}
|
||||
|
||||
_BUTTON_NAMES = cv.ensure_list(cv.one_of(*BUTTONS, lower=True))
|
||||
|
||||
|
||||
_keysym_number = cv.hex_int_range(min=0, max=_KEYSYM_MAX)
|
||||
|
||||
|
||||
# Keep in step with keysym_for_codepoint() in vnc_client.cpp.
|
||||
def _character_keysym(char: str) -> HexInt:
|
||||
code = ord(char)
|
||||
if 0x20 <= code <= 0x7E or 0xA0 <= code <= 0xFF:
|
||||
return HexInt(code)
|
||||
if code <= 0x9F:
|
||||
raise cv.Invalid(
|
||||
f"Control character U+{code:04X} is not a key. Use a key name such as Return, Tab or BackSpace"
|
||||
)
|
||||
return HexInt(_UNICODE_KEYSYM | code)
|
||||
|
||||
|
||||
def keysym(value: object) -> HexInt:
|
||||
"""An X11 keysym from a key name, a single character or a number.
|
||||
|
||||
An unquoted digit 0 to 9 is read as that digit, as in tinyusb_keyboard. Names are matched
|
||||
without regard to case, but a single character is always that character, so "a" and "A" differ.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
raise cv.Invalid(
|
||||
'YAML read this key as a boolean. Put it in quotes, for example key: "y"'
|
||||
)
|
||||
if isinstance(value, int):
|
||||
if 0 <= value <= 9:
|
||||
return HexInt(ord("0") + value)
|
||||
return _keysym_number(value)
|
||||
if not isinstance(value, str):
|
||||
raise cv.Invalid(
|
||||
f"Expected a key name, a single character or a keysym number, got {value!r}"
|
||||
)
|
||||
if len(value) == 1:
|
||||
return _character_keysym(value)
|
||||
lower = value.lower()
|
||||
if lower.startswith("0x"):
|
||||
return _keysym_number(value)
|
||||
if (code := _BY_LOWER_NAME.get(lower)) is not None:
|
||||
return HexInt(code)
|
||||
raise cv.Invalid(
|
||||
f"Unknown key '{value}'. Use a single character, a keysym number such as 0xFF0D, "
|
||||
f""
|
||||
"or a key name such as Return, Escape, Tab, BackSpace, Delete, Left, Page_Up, F1 or Control_L"
|
||||
)
|
||||
|
||||
|
||||
def button_mask(value: object) -> int:
|
||||
"""A pointer button mask from a number 0 to 255, or one or more button names."""
|
||||
if isinstance(value, bool):
|
||||
raise cv.Invalid(
|
||||
f"Expected a button mask or button names ({', '.join(BUTTONS)}), got a boolean"
|
||||
)
|
||||
if isinstance(value, int):
|
||||
return cv.uint8_t(value)
|
||||
mask = 0
|
||||
for name in _BUTTON_NAMES(value):
|
||||
mask |= BUTTONS[name]
|
||||
return mask
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "vnc_client.h"
|
||||
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/components/unicode/unicode.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -9,7 +8,6 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iterator>
|
||||
|
||||
@@ -55,17 +53,6 @@ static constexpr uint8_t MSG_SET_COLOUR_MAP = 1;
|
||||
static constexpr uint8_t MSG_BELL = 2;
|
||||
static constexpr uint8_t MSG_SERVER_CUT_TEXT = 3;
|
||||
|
||||
#ifdef USE_VNC_INPUT
|
||||
static constexpr uint8_t MSG_KEY_EVENT = 4;
|
||||
static constexpr uint8_t MSG_POINTER_EVENT = 5;
|
||||
|
||||
static constexpr uint32_t KEYSYM_BACKSPACE = 0xFF08;
|
||||
static constexpr uint32_t KEYSYM_TAB = 0xFF09;
|
||||
static constexpr uint32_t KEYSYM_RETURN = 0xFF0D;
|
||||
// X11 encodes a character outside Latin-1 as this prefix plus its code point.
|
||||
static constexpr uint32_t KEYSYM_UNICODE = 0x01000000;
|
||||
#endif
|
||||
|
||||
static inline uint16_t read_be16(const uint8_t *p) { return encode_uint16(p[0], p[1]); }
|
||||
static inline uint32_t read_be32(const uint8_t *p) { return encode_uint32(p[0], p[1], p[2], p[3]); }
|
||||
static inline void write_be16(uint8_t *p, uint16_t v) {
|
||||
@@ -90,59 +77,8 @@ static int parse_three_digits(const uint8_t *p) {
|
||||
return value;
|
||||
}
|
||||
|
||||
#ifdef USE_VNC_INPUT
|
||||
// Keep in step with _character_keysym() in keysyms.py.
|
||||
static uint32_t keysym_for_codepoint(uint32_t cp) {
|
||||
switch (cp) {
|
||||
case '\n':
|
||||
return KEYSYM_RETURN;
|
||||
case '\t':
|
||||
return KEYSYM_TAB;
|
||||
case '\b':
|
||||
return KEYSYM_BACKSPACE;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (cp < 0x20 || (cp >= 0x7F && cp < 0xA0)) {
|
||||
return 0;
|
||||
}
|
||||
return cp <= 0xFF ? cp : (KEYSYM_UNICODE | cp);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
static constexpr uint16_t TOUCH_SLOP_PX = 8;
|
||||
|
||||
static bool is_down(const touchscreen::TouchPoint &tp) {
|
||||
// A lifted finger lingers in the list as RELEASED or RELEASING until every finger is up.
|
||||
return (tp.state & (touchscreen::STATE_PRESSED | touchscreen::STATE_UPDATED)) != 0 &&
|
||||
(tp.state & touchscreen::STATE_RELEASING) == 0;
|
||||
}
|
||||
|
||||
// A first touch at (0, 0) matches a new point's default previous position, so only touch() reports it.
|
||||
void VncClient::touch(touchscreen::TouchPoint tp) {
|
||||
if (is_down(tp)) {
|
||||
this->handle_touch_(tp.x, tp.y);
|
||||
}
|
||||
}
|
||||
|
||||
void VncClient::update(const touchscreen::TouchPoints_t &tpoints) {
|
||||
for (const auto &tp : tpoints) {
|
||||
if (is_down(tp)) {
|
||||
this->handle_touch_(tp.x, tp.y);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VncClient::release() { this->handle_release_(); }
|
||||
#endif
|
||||
|
||||
void VncClient::setup() {
|
||||
this->setup_ms_ = App.get_loop_component_start_time();
|
||||
#ifdef USE_VNC_INPUT
|
||||
this->last_input_ms_ = this->setup_ms_ - INPUT_BOOST_MS; // no input yet
|
||||
#endif
|
||||
this->link_.begin(TAG);
|
||||
// Sized for the display now; a rotation changed later swaps the sides but keeps the cell count.
|
||||
const uint32_t wanted = static_cast<uint32_t>((this->display_->get_width() + 15) / 16) *
|
||||
@@ -185,9 +121,6 @@ void VncClient::dump_config() {
|
||||
this->max_update_delay_ms_,
|
||||
LOG_STR_ARG(PixelFormatStrings::get_log_str(static_cast<uint8_t>(this->pixel_format_), 0)),
|
||||
this->hash_capacity_);
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
ESP_LOGCONFIG(TAG, " Touchscreen: %s", this->has_touchscreen_ ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no"));
|
||||
#endif
|
||||
}
|
||||
|
||||
void VncClient::on_shutdown() { this->link_.close(); }
|
||||
@@ -262,15 +195,10 @@ void VncClient::loop() {
|
||||
// A display still starting up (it8951) can go from its start-up straight into a refresh without parking.
|
||||
const bool display_starting =
|
||||
!this->display_parks_ && this->display_->has_overridden_loop() && now - this->setup_ms_ < DISPLAY_START_MS;
|
||||
uint32_t update_delay = this->update_delay_ms_;
|
||||
#ifdef USE_VNC_INPUT
|
||||
if (now - this->last_input_ms_ < INPUT_BOOST_MS) {
|
||||
update_delay = 0;
|
||||
}
|
||||
#endif
|
||||
// Wait until the screen stops changing, so an animation becomes one refresh showing where it ended.
|
||||
if (this->refresh_pending_ && !this->in_update_ && !this->display_busy_ && !display_starting &&
|
||||
(now - this->last_change_ms_ >= update_delay || now - this->first_change_ms_ >= this->max_update_delay_ms_)) {
|
||||
(now - this->last_change_ms_ >= this->update_delay_ms_ ||
|
||||
now - this->first_change_ms_ >= this->max_update_delay_ms_)) {
|
||||
// Synchronous displays flush inside update(), so this pass does nothing else.
|
||||
this->refresh_pending_ = false;
|
||||
this->display_->update();
|
||||
@@ -279,9 +207,6 @@ void VncClient::loop() {
|
||||
this->service_rx_(now);
|
||||
}
|
||||
if (this->state_ == VncState::VNC_STATE_RUNNING) {
|
||||
#ifdef USE_VNC_INPUT
|
||||
this->send_pending_();
|
||||
#endif
|
||||
// A new session lays the screen out again for the new rotation and redraws all of it.
|
||||
if (static_cast<uint8_t>(this->display_->get_rotation() / 90) != this->hash_rotation_) {
|
||||
this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Display rotation changed"));
|
||||
@@ -338,13 +263,6 @@ void VncClient::reset_session_() {
|
||||
this->refresh_pending_ = false;
|
||||
this->need_full_ = false;
|
||||
this->frame_drawn_ = false;
|
||||
#ifdef USE_VNC_INPUT
|
||||
this->pending_count_ = 0;
|
||||
this->ptr_buttons_ = 0;
|
||||
#endif
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
this->touching_ = false;
|
||||
#endif
|
||||
this->step_ = VncStep::VNC_STEP_VERSION;
|
||||
this->reason_failure_ = VncFailure::VNC_FAILURE_TRANSIENT;
|
||||
this->high_freq_.stop();
|
||||
@@ -1066,144 +984,4 @@ bool VncClient::send_update_request_(bool incremental, uint32_t now) {
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef USE_VNC_INPUT
|
||||
bool VncClient::send_key_event_(uint32_t keysym, VncKeyAction action) {
|
||||
// A press is the down and the up event, sent whole or not at all.
|
||||
uint8_t msg[2 * KEY_EVENT_SIZE] = {MSG_KEY_EVENT, static_cast<uint8_t>(action != VncKeyAction::VNC_KEY_ACTION_UP)};
|
||||
msg[KEY_EVENT_SIZE] = MSG_KEY_EVENT;
|
||||
write_be32(msg + 4, keysym);
|
||||
write_be32(msg + KEY_EVENT_SIZE + 4, keysym);
|
||||
return this->send_input_(msg, action == VncKeyAction::VNC_KEY_ACTION_PRESS ? sizeof(msg) : KEY_EVENT_SIZE,
|
||||
action == VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
}
|
||||
|
||||
// Input goes out after the releases that are still waiting. A release that does not fit waits for room,
|
||||
// because a key or button left down would change every later key and click; other input is dropped.
|
||||
bool VncClient::send_input_(const uint8_t *msg, size_t len, bool release) {
|
||||
if (this->send_pending_() && this->send_(msg, len)) {
|
||||
this->note_input_();
|
||||
return true;
|
||||
}
|
||||
if (!release) {
|
||||
return false;
|
||||
}
|
||||
if (this->pending_count_ == MAX_PENDING_RELEASES) {
|
||||
// Losing a release would leave its key or button held on the server
|
||||
this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Too many releases waiting for the send buffer"));
|
||||
return false;
|
||||
}
|
||||
std::memcpy(this->pending_[this->pending_count_++], msg, len);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VncClient::send_pending_() {
|
||||
while (this->pending_count_ != 0) {
|
||||
const uint8_t *msg = this->pending_[0];
|
||||
if (!this->send_(msg, msg[0] == MSG_POINTER_EVENT ? POINTER_EVENT_SIZE : KEY_EVENT_SIZE)) {
|
||||
return false;
|
||||
}
|
||||
this->note_input_();
|
||||
this->pending_count_--;
|
||||
std::memmove(this->pending_[0], this->pending_[1], this->pending_count_ * sizeof(this->pending_[0]));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VncClient::send_pointer(uint16_t x, uint16_t y, uint8_t buttons) {
|
||||
if (!this->is_connected()) {
|
||||
ESP_LOGV(TAG, "Not connected, pointer event dropped");
|
||||
return false;
|
||||
}
|
||||
x = std::min<uint16_t>(x, this->fb_w_ - 1);
|
||||
y = std::min<uint16_t>(y, this->fb_h_ - 1);
|
||||
uint8_t msg[POINTER_EVENT_SIZE] = {MSG_POINTER_EVENT, buttons};
|
||||
write_be16(msg + 2, x);
|
||||
write_be16(msg + 4, y);
|
||||
const bool release = buttons != this->ptr_buttons_ && (buttons & ~this->ptr_buttons_) == 0;
|
||||
if (!this->send_input_(msg, sizeof(msg), release)) {
|
||||
ESP_LOGW(TAG, "Send buffer full, pointer event dropped");
|
||||
return false;
|
||||
}
|
||||
this->ptr_x_ = x;
|
||||
this->ptr_y_ = y;
|
||||
this->ptr_buttons_ = buttons;
|
||||
return true;
|
||||
}
|
||||
|
||||
void VncClient::note_input_() { this->last_input_ms_ = App.get_loop_component_start_time(); }
|
||||
|
||||
void VncClient::send_key(uint32_t keysym, VncKeyAction action) {
|
||||
if (!this->is_connected()) {
|
||||
ESP_LOGV(TAG, "Not connected, key dropped");
|
||||
return;
|
||||
}
|
||||
if (!this->send_key_event_(keysym, action)) {
|
||||
ESP_LOGW(TAG, "Send buffer full, key dropped");
|
||||
}
|
||||
}
|
||||
|
||||
void VncClient::type_text(const char *text, size_t len) {
|
||||
if (!this->is_connected()) {
|
||||
ESP_LOGV(TAG, "Not connected, text dropped");
|
||||
return;
|
||||
}
|
||||
size_t typed = 0;
|
||||
for (size_t i = 0; i < len;) {
|
||||
// The decoder reads up to a NUL, so give it only the bytes that belong to this text.
|
||||
char sequence[5] = {};
|
||||
std::memcpy(sequence, text + i, std::min<size_t>(4, len - i));
|
||||
size_t used;
|
||||
const uint32_t cp = unicode::extract_unicode_codepoint(sequence, &used);
|
||||
// A broken sequence or a NUL types nothing; carry on at the next byte.
|
||||
i += used == 0 ? 1 : used;
|
||||
const uint32_t keysym = keysym_for_codepoint(cp);
|
||||
if (keysym == 0) {
|
||||
continue;
|
||||
}
|
||||
if (!this->send_key_event_(keysym, VncKeyAction::VNC_KEY_ACTION_PRESS)) {
|
||||
ESP_LOGW(TAG, "Send buffer full, text cut after %u characters", static_cast<unsigned>(typed));
|
||||
return;
|
||||
}
|
||||
typed++;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
void VncClient::handle_touch_(uint16_t x, uint16_t y) {
|
||||
if (!this->is_connected()) {
|
||||
return;
|
||||
}
|
||||
if (!this->touching_) {
|
||||
if (x >= this->vis_w_ || y >= this->vis_h_) {
|
||||
return;
|
||||
}
|
||||
this->press_x_ = x;
|
||||
this->press_y_ = y;
|
||||
this->dragging_ = false;
|
||||
this->touching_ = this->send_pointer(x, y, 1);
|
||||
return;
|
||||
}
|
||||
x = std::min<uint16_t>(x, this->vis_w_ - 1);
|
||||
y = std::min<uint16_t>(y, this->vis_h_ - 1);
|
||||
// Small jitter during a tap would start a text selection in a browser.
|
||||
if (!this->dragging_) {
|
||||
if (std::abs(x - this->press_x_) <= TOUCH_SLOP_PX && std::abs(y - this->press_y_) <= TOUCH_SLOP_PX) {
|
||||
return;
|
||||
}
|
||||
this->dragging_ = true;
|
||||
}
|
||||
if (x != this->ptr_x_ || y != this->ptr_y_) {
|
||||
this->send_pointer(x, y, 1);
|
||||
}
|
||||
}
|
||||
|
||||
void VncClient::handle_release_() {
|
||||
if (this->touching_) {
|
||||
this->touching_ = false;
|
||||
this->send_pointer(this->ptr_x_, this->ptr_y_, 0);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::vnc
|
||||
@@ -4,13 +4,9 @@
|
||||
#include "esphome/components/socket/tcp_client_link.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
#include "esphome/components/touchscreen/touchscreen.h"
|
||||
#endif
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace esphome::vnc {
|
||||
|
||||
@@ -20,12 +16,6 @@ enum class VncPixelFormat : uint8_t {
|
||||
VNC_PIXEL_FORMAT_RGB332,
|
||||
};
|
||||
|
||||
enum class VncKeyAction : uint8_t {
|
||||
VNC_KEY_ACTION_PRESS,
|
||||
VNC_KEY_ACTION_DOWN,
|
||||
VNC_KEY_ACTION_UP,
|
||||
};
|
||||
|
||||
enum class VncState : uint8_t {
|
||||
VNC_STATE_IDLE,
|
||||
VNC_STATE_HANDSHAKE,
|
||||
@@ -65,12 +55,7 @@ enum class VncFailure : uint8_t {
|
||||
};
|
||||
|
||||
/// RFB (VNC) client that draws the remote framebuffer into a display.
|
||||
class VncClient : public Component
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
,
|
||||
public touchscreen::TouchListener
|
||||
#endif
|
||||
{
|
||||
class VncClient : public Component {
|
||||
public:
|
||||
explicit VncClient(display::Display *display, bool big_endian = false) : display_(display), big_endian_(big_endian) {}
|
||||
|
||||
@@ -80,15 +65,6 @@ class VncClient : public Component
|
||||
void set_update_delay(uint32_t ms) { this->update_delay_ms_ = ms; }
|
||||
void set_max_update_delay(uint32_t ms) { this->max_update_delay_ms_ = ms; }
|
||||
void set_pixel_format(VncPixelFormat format) { this->pixel_format_ = format; }
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
void register_touchscreen(touchscreen::Touchscreen *touchscreen) {
|
||||
touchscreen->register_listener(this);
|
||||
this->has_touchscreen_ = true;
|
||||
}
|
||||
void touch(touchscreen::TouchPoint tp) override;
|
||||
void update(const touchscreen::TouchPoints_t &tpoints) override;
|
||||
void release() override;
|
||||
#endif
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
@@ -100,13 +76,6 @@ class VncClient : public Component
|
||||
/// Ask the server for the whole screen again.
|
||||
void refresh();
|
||||
|
||||
#ifdef USE_VNC_INPUT
|
||||
bool send_pointer(uint16_t x, uint16_t y, uint8_t buttons);
|
||||
void send_key(uint32_t keysym, VncKeyAction action);
|
||||
void type_text(const char *text, size_t len);
|
||||
void type_text(const std::string &text) { this->type_text(text.data(), text.size()); }
|
||||
#endif
|
||||
|
||||
template<typename F> void add_on_connect_callback(F &&callback) {
|
||||
this->connect_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
@@ -117,13 +86,6 @@ class VncClient : public Component
|
||||
protected:
|
||||
static constexpr size_t RX_BUFFER_SIZE = 512;
|
||||
static constexpr size_t TILE_BUFFER_SIZE = 16 * 16 * 2;
|
||||
#ifdef USE_VNC_INPUT
|
||||
// For this long after input, frames are painted as soon as they arrive: the reaction matters more than a calm panel.
|
||||
static constexpr uint32_t INPUT_BOOST_MS = 2000;
|
||||
static constexpr size_t KEY_EVENT_SIZE = 8;
|
||||
static constexpr size_t POINTER_EVENT_SIZE = 6;
|
||||
static constexpr size_t MAX_PENDING_RELEASES = 4;
|
||||
#endif
|
||||
|
||||
void connect_step_(uint32_t now);
|
||||
void start_handshake_(uint32_t now);
|
||||
@@ -145,17 +107,6 @@ class VncClient : public Component
|
||||
bool budget_exhausted_() const;
|
||||
bool send_(const uint8_t *data, size_t len);
|
||||
bool send_update_request_(bool incremental, uint32_t now);
|
||||
#ifdef USE_VNC_INPUT
|
||||
bool send_key_event_(uint32_t keysym, VncKeyAction action);
|
||||
bool send_input_(const uint8_t *msg, size_t len, bool release);
|
||||
bool send_pending_();
|
||||
void note_input_();
|
||||
#endif
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
/// Touch input in display coordinates.
|
||||
void handle_touch_(uint16_t x, uint16_t y);
|
||||
void handle_release_();
|
||||
#endif
|
||||
VncParse fail_(VncFailure failure, const LogString *reason);
|
||||
VncParse fail_send_() { return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Send failed")); }
|
||||
VncFailure lost_failure_() const;
|
||||
@@ -184,9 +135,6 @@ class VncClient : public Component
|
||||
// The first and the latest frame drawn since the display last refreshed.
|
||||
uint32_t first_change_ms_{0};
|
||||
uint32_t last_change_ms_{0};
|
||||
#ifdef USE_VNC_INPUT
|
||||
uint32_t last_input_ms_{0};
|
||||
#endif
|
||||
uint32_t rx_total_{0};
|
||||
uint32_t update_start_bytes_{0};
|
||||
uint32_t last_request_ms_{0};
|
||||
@@ -213,19 +161,7 @@ class VncClient : public Component
|
||||
uint16_t hash_capacity_{0};
|
||||
uint16_t hash_cols_{0};
|
||||
uint16_t hash_rows_{0};
|
||||
#ifdef USE_VNC_INPUT
|
||||
uint16_t ptr_x_{0};
|
||||
uint16_t ptr_y_{0};
|
||||
#endif
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
uint16_t press_x_{0};
|
||||
uint16_t press_y_{0};
|
||||
#endif
|
||||
|
||||
#ifdef USE_VNC_INPUT
|
||||
uint8_t ptr_buttons_{0}; // as the server will see them once the waiting releases are sent
|
||||
uint8_t pending_count_{0};
|
||||
#endif
|
||||
uint8_t tile_w_{0};
|
||||
uint8_t tile_h_{0};
|
||||
uint8_t subrects_left_{0};
|
||||
@@ -257,18 +193,9 @@ class VncClient : public Component
|
||||
bool busy_warned_{false};
|
||||
bool refresh_retried_{false}; // one retry per refresh the driver abandoned
|
||||
bool network_was_up_{false};
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
bool has_touchscreen_{false};
|
||||
bool dragging_{false};
|
||||
bool touching_{false};
|
||||
#endif
|
||||
bool big_endian_; // the pixel order the display takes
|
||||
|
||||
uint8_t rx_[RX_BUFFER_SIZE];
|
||||
#ifdef USE_VNC_INPUT
|
||||
// Key-up and button-release messages waiting for room, oldest first.
|
||||
uint8_t pending_[MAX_PENDING_RELEASES][KEY_EVENT_SIZE];
|
||||
#endif
|
||||
alignas(4) uint8_t tile_[TILE_BUFFER_SIZE];
|
||||
};
|
||||
|
||||
|
||||
@@ -910,8 +910,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
CORE.add_job(_add_automations, config)
|
||||
|
||||
if not CORE.using_toolchain_esp_idf:
|
||||
cg.add_build_flag("-fno-exceptions")
|
||||
cg.add_build_flag("-fno-exceptions")
|
||||
|
||||
# Libraries
|
||||
for lib in config[CONF_LIBRARIES]:
|
||||
|
||||
@@ -536,8 +536,6 @@
|
||||
#define USE_SOCKET_TCP_CLIENT_LINK
|
||||
#define USE_SOCKET_TCP_LISTENER
|
||||
#define USE_LWIP_FAST_SELECT
|
||||
#define USE_VNC_INPUT
|
||||
#define USE_VNC_TOUCHSCREEN
|
||||
|
||||
#define USE_SPEAKER
|
||||
#define USE_SPEAKER_MEDIA_PLAYER_ON_OFF
|
||||
|
||||
@@ -372,6 +372,31 @@ async def build_registry_list(registry, config):
|
||||
return actions
|
||||
|
||||
|
||||
async def build_registry_list_merged(
|
||||
registry: Registry,
|
||||
config: list[ConfigType],
|
||||
merge: Callable[[ConfigType, ConfigType], tuple[Any, list] | None],
|
||||
) -> list[MockObj]:
|
||||
"""Like build_registry_list, but two neighbouring entries that ``merge`` accepts are built as one Component.
|
||||
|
||||
``merge(first, second)`` returns the merged class and its constructor arguments, or None.
|
||||
"""
|
||||
built = []
|
||||
i = 0
|
||||
while i < len(config):
|
||||
if i + 1 < len(config) and (merged := merge(config[i], config[i + 1])):
|
||||
type_id = config[i][CONF_TYPE_ID].copy()
|
||||
type_id.type, args = merged
|
||||
var = new_Pvariable(type_id, *args)
|
||||
await register_component(var, {})
|
||||
built.append(var)
|
||||
i += 2
|
||||
continue
|
||||
built.append(await build_registry_entry(registry, config[i]))
|
||||
i += 1
|
||||
return built
|
||||
|
||||
|
||||
async def past_safe_mode():
|
||||
if CONF_SAFE_MODE not in CORE.config:
|
||||
return None
|
||||
|
||||
@@ -42,26 +42,20 @@ _MIRROR_ATTEMPTS = 3
|
||||
_MIRROR_SWEEP_ATTEMPTS = NETWORK_MAX_ATTEMPTS
|
||||
|
||||
|
||||
def get_project_link_flags(flags: Iterable[str] | None = None) -> list[str]:
|
||||
"""Return the -Wl, linker flags from ``flags`` in their given order
|
||||
(default: the current build's flags, sorted)."""
|
||||
def get_project_link_flags() -> list[str]:
|
||||
"""Return the sorted -Wl, linker flags from the current build."""
|
||||
from esphome.core import CORE # local import to avoid circular dependency
|
||||
|
||||
if flags is None:
|
||||
flags = sorted(CORE.build_flags)
|
||||
return [flag for flag in flags if flag.startswith("-Wl,")]
|
||||
return sorted(flag for flag in CORE.build_flags if flag.startswith("-Wl,"))
|
||||
|
||||
|
||||
def get_project_compile_flags(flags: Iterable[str] | None = None) -> list[str]:
|
||||
"""Return the -D and -W (non-linker) flags from ``flags`` in their given
|
||||
order (default: the current build's flags, sorted)."""
|
||||
def get_project_compile_flags() -> list[str]:
|
||||
"""Return the sorted -D and -W (non-linker) flags from the current build."""
|
||||
from esphome.core import CORE # local import to avoid circular dependency
|
||||
|
||||
if flags is None:
|
||||
flags = sorted(CORE.build_flags)
|
||||
return [
|
||||
flag
|
||||
for flag in flags
|
||||
for flag in sorted(CORE.build_flags)
|
||||
if flag.startswith("-D")
|
||||
or (flag.startswith("-W") and not flag.startswith("-Wl,"))
|
||||
]
|
||||
|
||||
@@ -1036,15 +1036,8 @@ class ESPHomeLoaderMixin:
|
||||
) -> OrderedDict[str, dict[str, Any]]:
|
||||
files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml"))
|
||||
mapping = OrderedDict()
|
||||
paths: dict[str, Path] = {}
|
||||
for fname in files:
|
||||
filename = fname.stem
|
||||
if (first := paths.setdefault(filename, fname)) != fname:
|
||||
raise yaml.MarkedYAMLError(
|
||||
f"!include_dir_named: {first} and {fname} both use the key "
|
||||
f"'{filename}'; rename one of them",
|
||||
node.start_mark,
|
||||
)
|
||||
mapping[filename] = self.yaml_loader(fname)
|
||||
return mapping
|
||||
|
||||
@@ -1356,8 +1349,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[:] = sorted(d for d in dirs if _is_file_valid(d))
|
||||
for f in sorted(files):
|
||||
dirs[:] = [d for d in dirs if _is_file_valid(d)]
|
||||
for f in files:
|
||||
filename = Path(f)
|
||||
if _is_file_valid(f) and filename.match(pattern):
|
||||
filename = Path(root) / filename
|
||||
|
||||
+4
-4
@@ -1,18 +1,18 @@
|
||||
# cryptography 49+ ships no Intel macOS wheels (arm64 only); esptool caps <49 there.
|
||||
# Keep 48.0.1, the last universal2 release, so esphome stays installable on Intel Macs.
|
||||
cryptography==50.0.2; platform_system != "Darwin" or platform_machine != "x86_64"
|
||||
cryptography==50.0.2; platform_system == "Darwin" and platform_machine == "x86_64"
|
||||
cryptography==48.0.1; platform_system == "Darwin" and platform_machine == "x86_64"
|
||||
voluptuous==0.16.0
|
||||
PyYAML==6.0.3
|
||||
paho-mqtt==2.1.0
|
||||
colorama==0.4.6
|
||||
tzlocal==5.4.5 # from time
|
||||
tzlocal==5.4.4 # from time
|
||||
tzdata>=2026.5 # from time
|
||||
pyserial==3.5
|
||||
platformio==6.2.0
|
||||
esptool==5.6.0
|
||||
click==8.5.0
|
||||
aioesphomeapi==46.23.0
|
||||
aioesphomeapi==46.22.0
|
||||
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
||||
zeroconf==0.151.5
|
||||
puremagic==2.2.0
|
||||
@@ -28,7 +28,7 @@ bleak==3.0.2
|
||||
smpclient==7.3.0
|
||||
# smp 4.2+ needs cbor2 6+, which ships no Intel macOS wheels (arm64 only).
|
||||
smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64"
|
||||
smp==4.2.0; platform_system == "Darwin" and platform_machine == "x86_64"
|
||||
smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64"
|
||||
requests==2.34.2
|
||||
py7zr==1.1.4
|
||||
platformdirs==4.13.5 # native esp-idf toolchain global cache dir
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pylint==4.1.3
|
||||
ruff==0.17.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
ruff==0.17.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
prek==0.5.7 # .github/workflows/ci.yml reads this pin
|
||||
prek==0.5.6 # .github/workflows/ci.yml reads this pin
|
||||
yamlrocks==0.7.1 # used by script/sync_dependency_versions.py
|
||||
|
||||
# Unit tests
|
||||
|
||||
@@ -107,7 +107,6 @@ CLANG_TIDY_SPLIT_THRESHOLD = 65
|
||||
# bucket, not wall time (calibrated with the conftest compile cap); it
|
||||
# sizes the bucket count for small subsets.
|
||||
INTEGRATION_TESTS_SPLIT_THRESHOLD = 10
|
||||
# ci.yml restores one integration ccache slice per bucket; add a step there too
|
||||
INTEGRATION_TESTS_SPLIT_BUCKETS = 5
|
||||
INTEGRATION_TESTS_TARGET_BUCKET_WEIGHT = 360.0
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Tests for the show_test_card and update_interval check in the display schema."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.display import FULL_DISPLAY_SCHEMA, _validate_test_card
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
WARNING = "`show_test_card: True` is used with `update_interval: never`"
|
||||
|
||||
|
||||
def test_test_card_with_update_interval_never_warns(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The test card is only drawn when something else updates the display."""
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
|
||||
FULL_DISPLAY_SCHEMA({"show_test_card": True, "update_interval": "never"})
|
||||
assert WARNING in caplog.text
|
||||
|
||||
|
||||
def test_warning_names_the_display(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""With several displays the warning says which one it is about."""
|
||||
config = {
|
||||
"id": ID("status_epaper", is_declaration=True),
|
||||
"show_test_card": True,
|
||||
"update_interval": cv.update_interval("never"),
|
||||
}
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
|
||||
_validate_test_card(config)
|
||||
assert f"status_epaper: {WARNING}" in caplog.text
|
||||
|
||||
|
||||
def test_warning_without_display_id_has_no_prefix(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A generated id is unset at validation time, so the warning starts with the message."""
|
||||
config = {
|
||||
"id": ID(None, is_declaration=True),
|
||||
"show_test_card": True,
|
||||
"update_interval": cv.update_interval("never"),
|
||||
}
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
|
||||
_validate_test_card(config)
|
||||
assert WARNING in caplog.text
|
||||
assert f": {WARNING}" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
pytest.param(
|
||||
{"show_test_card": True, "update_interval": "1s"}, id="test_card_1s"
|
||||
),
|
||||
pytest.param({"show_test_card": True}, id="test_card_default_interval"),
|
||||
pytest.param(
|
||||
{"show_test_card": False, "update_interval": "never"},
|
||||
id="no_test_card_never",
|
||||
),
|
||||
pytest.param({"update_interval": "never"}, id="never_only"),
|
||||
],
|
||||
)
|
||||
def test_other_combinations_do_not_warn(
|
||||
config: ConfigType, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Only the test card together with `update_interval: never` warns."""
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
|
||||
FULL_DISPLAY_SCHEMA(config)
|
||||
assert WARNING not in caplog.text
|
||||
|
||||
|
||||
def test_test_card_without_update_interval_accepted(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Platforms such as epaper_spi may leave update_interval unset at this stage."""
|
||||
config = {"show_test_card": True}
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
|
||||
assert _validate_test_card(config) is config
|
||||
assert WARNING not in caplog.text
|
||||
@@ -1,9 +1,8 @@
|
||||
"""The ``-DLV_CONF_PATH`` build flag carries a path. PlatformIO, the host
|
||||
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."""
|
||||
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."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,9 +12,7 @@ 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:
|
||||
@@ -40,7 +37,3 @@ 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]
|
||||
@@ -100,3 +100,43 @@ def test_substitution_table_names_avoid_user_ids(
|
||||
assert len(tables) == 2
|
||||
assert "text_sensor_substitutions" not in tables
|
||||
assert "text_sensor_mapfilter_id_table" not in tables
|
||||
|
||||
|
||||
def test_throttle_and_timeout_filters(generate_main: Callable[[str], str]) -> None:
|
||||
"""Priority values go in a PROGMEM table ended by nullptr; no priority is a plain throttle."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/text_sensor/test_text_sensor_throttle_timeout.yaml"
|
||||
)
|
||||
|
||||
table = re.search(
|
||||
r'static constexpr const char \* (\w+)\[\] PROGMEM = \{"NA", nullptr\}',
|
||||
main_cpp,
|
||||
)
|
||||
assert table is not None
|
||||
# timeout (last) then throttle_with_priority with the same period merge into one filter
|
||||
assert f"text_sensor::TimeoutThrottleFilter(1000, {table.group(1)})" in main_cpp
|
||||
alone = re.search(
|
||||
r'static constexpr const char \* (\w+)\[\] PROGMEM = \{"Off", "NA", nullptr\}',
|
||||
main_cpp,
|
||||
)
|
||||
assert alone is not None
|
||||
assert f"text_sensor::ThrottleWithPriorityFilter(300, {alone.group(1)})" in main_cpp
|
||||
assert "text_sensor::ThrottleFilter(500)" in main_cpp
|
||||
assert "text_sensor::TimeoutThrottleFilter(2000, nullptr)" in main_cpp
|
||||
assert 'text_sensor::TimeoutFilterConfigured(30000, "Offline")' in main_cpp
|
||||
|
||||
|
||||
def test_timeout_throttle_filters_default_merges() -> None:
|
||||
"""The validated default is a pair the chain builder merges into one filter."""
|
||||
from esphome.components.text_sensor import (
|
||||
_timeout_throttle_pair,
|
||||
timeout_throttle_filters,
|
||||
validate_filters,
|
||||
)
|
||||
|
||||
first, second = validate_filters(timeout_throttle_filters("NA"))
|
||||
pair = _timeout_throttle_pair(first, second)
|
||||
assert pair is not None
|
||||
period, values = pair
|
||||
assert period.total_milliseconds == 1000
|
||||
assert values == ["NA"]
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: d1_mini_lite
|
||||
|
||||
text_sensor:
|
||||
- platform: template
|
||||
id: ts_position
|
||||
filters:
|
||||
- timeout: 1s
|
||||
- throttle_with_priority:
|
||||
timeout: 1s
|
||||
value: NA
|
||||
- platform: template
|
||||
id: ts_plain
|
||||
filters:
|
||||
- throttle_with_priority: 500ms
|
||||
- timeout:
|
||||
timeout: 30s
|
||||
value: Offline
|
||||
- platform: template
|
||||
id: ts_merged_plain
|
||||
filters:
|
||||
- timeout: 2s
|
||||
- throttle_with_priority: 2s
|
||||
- platform: template
|
||||
id: ts_priority_alone
|
||||
filters:
|
||||
- throttle_with_priority:
|
||||
timeout: 300ms
|
||||
value:
|
||||
- "Off"
|
||||
- NA
|
||||
@@ -56,17 +56,6 @@ EPAPER_SPI = """
|
||||
reset_pin: GPIO25
|
||||
"""
|
||||
|
||||
TOUCHSCREEN = """
|
||||
i2c:
|
||||
sda: GPIO32
|
||||
scl: GPIO33
|
||||
|
||||
touchscreen:
|
||||
- platform: gt911
|
||||
id: touch
|
||||
display: {display}
|
||||
"""
|
||||
|
||||
|
||||
S3_BASE = """
|
||||
esphome:
|
||||
@@ -166,10 +155,6 @@ 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:
|
||||
@@ -278,30 +263,6 @@ 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:
|
||||
@@ -316,30 +277,11 @@ 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
|
||||
|
||||
|
||||
@@ -463,20 +405,6 @@ display:
|
||||
assert "use epaper_spi" in out
|
||||
|
||||
|
||||
def test_touchscreen_on_other_display_rejected(
|
||||
config_error: Callable[..., str],
|
||||
) -> None:
|
||||
out = config_error(
|
||||
IT8951,
|
||||
EPAPER_SPI,
|
||||
TOUCHSCREEN.format(display="other"),
|
||||
"\nvnc:\n - host: 192.168.1.20\n display_id: epd\n"
|
||||
" touchscreen_id: touch\n",
|
||||
)
|
||||
|
||||
assert "Touchscreen 'touch' belongs to display 'other', not 'epd'" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("vnc_options", "error"),
|
||||
[
|
||||
|
||||
@@ -400,37 +400,6 @@ 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
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#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,4 +17,3 @@ sensor:
|
||||
sensor: integration_my_sensor
|
||||
name: Integration Sensor
|
||||
time_unit: s
|
||||
state_class: total_increasing
|
||||
@@ -6,4 +6,3 @@ sensor:
|
||||
sensor: integration_my_sensor
|
||||
name: Integration Sensor
|
||||
time_unit: s
|
||||
state_class: total_increasing
|
||||
@@ -6,4 +6,3 @@ sensor:
|
||||
sensor: integration_my_sensor
|
||||
name: Integration Sensor
|
||||
time_unit: s
|
||||
state_class: total_increasing
|
||||
@@ -1,5 +1,3 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
@@ -12,12 +10,3 @@ 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,48 +8,33 @@
|
||||
|
||||
#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, bool (Nextion::*RQ)(bool), void (Nextion::*PP)()>
|
||||
void (Nextion::*R)(NextionQueue *), bool NextionBase::*U>
|
||||
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::remove_from_q_,
|
||||
&Nextion::process_pending_in_queue_>;
|
||||
&Nextion::release_queue_entry_, &Nextion::is_setup_>;
|
||||
|
||||
/// Accepts the commands the component writes; the display never answers
|
||||
class FakeUART : public uart::UARTComponent {
|
||||
public:
|
||||
/// 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);
|
||||
}
|
||||
void write_array(const uint8_t *data, size_t len) override {}
|
||||
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; }
|
||||
@@ -125,19 +110,6 @@ 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_;
|
||||
@@ -268,55 +240,4 @@ 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
|
||||
@@ -0,0 +1,11 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
# filter.cpp only compiles with the define the real to_code emits for configured filters
|
||||
cg.add_define("USE_TEXT_SENSOR_FILTER")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -30,6 +30,29 @@ text_sensor:
|
||||
- substitute:
|
||||
- BAR -> baz
|
||||
|
||||
- platform: template
|
||||
name: "Test Throttle"
|
||||
id: test_throttle
|
||||
filters:
|
||||
- throttle: 1s
|
||||
|
||||
- platform: template
|
||||
name: "Test Throttle With Priority"
|
||||
id: test_throttle_with_priority
|
||||
filters:
|
||||
- timeout: 1s
|
||||
- throttle_with_priority:
|
||||
timeout: 1s
|
||||
value: NA
|
||||
|
||||
- platform: template
|
||||
name: "Test Timeout Value"
|
||||
id: test_timeout_value
|
||||
filters:
|
||||
- timeout:
|
||||
timeout: 30s
|
||||
value: Offline
|
||||
|
||||
- platform: template
|
||||
name: "Test Map Single"
|
||||
id: test_map_single
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/text_sensor/filter.h"
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::text_sensor::testing {
|
||||
|
||||
class TextFilterTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
this->sensor_.add_on_state_callback([this](const std::string &state) { this->seen_.push_back(state); });
|
||||
}
|
||||
void publish_at_(uint32_t now, const char *value) {
|
||||
LoopBlockingGuard guard{nullptr, nullptr, now};
|
||||
this->sensor_.publish_state(value);
|
||||
}
|
||||
static void loop_at(Component &filter, uint32_t now) {
|
||||
LoopBlockingGuard guard{nullptr, nullptr, now};
|
||||
filter.loop();
|
||||
}
|
||||
|
||||
TextSensor sensor_;
|
||||
std::vector<std::string> seen_;
|
||||
};
|
||||
|
||||
static constexpr const char *PRIORITIZED[] = {"NA", nullptr};
|
||||
|
||||
TEST_F(TextFilterTest, ThrottleDropsValuesInsideThePeriod) {
|
||||
ThrottleFilter throttle(1000);
|
||||
this->sensor_.add_filter(&throttle);
|
||||
this->publish_at_(1000, "a");
|
||||
this->publish_at_(1500, "b");
|
||||
this->publish_at_(2000, "c");
|
||||
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "c"}));
|
||||
}
|
||||
|
||||
TEST_F(TextFilterTest, PrioritizedValuePassesAtOnce) {
|
||||
ThrottleWithPriorityFilter throttle(1000, PRIORITIZED);
|
||||
this->sensor_.add_filter(&throttle);
|
||||
this->publish_at_(1000, "a");
|
||||
this->publish_at_(1100, "NA");
|
||||
this->publish_at_(1200, "b");
|
||||
this->publish_at_(2100, "c");
|
||||
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "NA", "c"}));
|
||||
}
|
||||
|
||||
TEST_F(TextFilterTest, TimeoutRearmsOnEachValueAndFiresOnce) {
|
||||
TimeoutFilterLast timeout(1000);
|
||||
this->sensor_.add_filter(&timeout);
|
||||
this->publish_at_(1000, "a");
|
||||
this->publish_at_(1800, "b");
|
||||
loop_at(timeout, 2500);
|
||||
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "b"}));
|
||||
EXPECT_FALSE(timeout.is_idle());
|
||||
loop_at(timeout, 2800);
|
||||
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "b", "b"}));
|
||||
EXPECT_TRUE(timeout.is_idle());
|
||||
}
|
||||
|
||||
TEST_F(TextFilterTest, TimeoutSendsTheConfiguredValue) {
|
||||
TimeoutFilterConfigured timeout(1000, "Offline");
|
||||
this->sensor_.add_filter(&timeout);
|
||||
this->publish_at_(1000, "a");
|
||||
loop_at(timeout, 2000);
|
||||
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "Offline"}));
|
||||
}
|
||||
|
||||
// Feeds the same values to the timeout + throttle chain and to the merged filter
|
||||
static std::vector<std::string> run_sequence(std::initializer_list<Filter *> filters, Component &timeout) {
|
||||
TextSensor sensor;
|
||||
std::vector<std::string> seen;
|
||||
sensor.add_on_state_callback([&seen](const std::string &state) { seen.push_back(state); });
|
||||
sensor.add_filters(filters);
|
||||
const auto publish_at = [&sensor](uint32_t now, const char *value) {
|
||||
LoopBlockingGuard guard{nullptr, nullptr, now};
|
||||
sensor.publish_state(value);
|
||||
};
|
||||
const auto loop_at = [&timeout](uint32_t now) {
|
||||
LoopBlockingGuard guard{nullptr, nullptr, now};
|
||||
timeout.loop();
|
||||
};
|
||||
publish_at(1000, "a");
|
||||
publish_at(1200, "b"); // held by the throttle, sent once updates pause
|
||||
loop_at(2200);
|
||||
publish_at(2300, "c");
|
||||
publish_at(2400, "NA"); // a priority value passes at once
|
||||
publish_at(2500, "d");
|
||||
loop_at(3500);
|
||||
return seen;
|
||||
}
|
||||
|
||||
TEST(TextFilterChain, CombinedTimeoutThrottleMatchesTheChain) {
|
||||
TimeoutFilterLast timeout(1000);
|
||||
ThrottleWithPriorityFilter throttle(1000, PRIORITIZED);
|
||||
const auto chain = run_sequence({&timeout, &throttle}, timeout);
|
||||
TimeoutThrottleFilter combined(1000, PRIORITIZED);
|
||||
EXPECT_EQ(run_sequence({&combined}, combined), chain);
|
||||
EXPECT_EQ(chain, (std::vector<std::string>{"a", "b", "NA", "d"}));
|
||||
}
|
||||
|
||||
TEST_F(TextFilterTest, CombinedWithoutPriorityIsAPlainThrottle) {
|
||||
TimeoutThrottleFilter combined(1000, nullptr);
|
||||
this->sensor_.add_filter(&combined);
|
||||
this->publish_at_(1000, "a");
|
||||
this->publish_at_(1300, "NA");
|
||||
loop_at(combined, 2300);
|
||||
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "NA"}));
|
||||
}
|
||||
|
||||
} // namespace esphome::text_sensor::testing
|
||||
@@ -1,6 +1,5 @@
|
||||
# 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
|
||||
@@ -9,11 +8,8 @@ from tests.testing_helpers import ComponentManifestOverride
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# MULTI_CONF components get no config in a unit test build, so to_code would never run.
|
||||
manifest.multi_conf = False
|
||||
manifest.dependencies = [*manifest.dependencies, "touchscreen"]
|
||||
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
socket_component.require_tcp_client_link()
|
||||
cg.add_define("USE_VNC_INPUT")
|
||||
cg.add_define("USE_VNC_TOUCHSCREEN")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -16,60 +16,6 @@ interval:
|
||||
- interval: 60s
|
||||
then:
|
||||
- vnc.refresh: vnc_client
|
||||
- vnc.send_pointer:
|
||||
id: vnc_client
|
||||
x: 10
|
||||
y: 20
|
||||
- vnc.send_pointer:
|
||||
id: vnc_client
|
||||
x: 10
|
||||
y: 20
|
||||
buttons: 1
|
||||
- vnc.send_pointer:
|
||||
id: vnc_client
|
||||
x: 10
|
||||
y: 20
|
||||
buttons: [left, wheel_down]
|
||||
- vnc.send_pointer:
|
||||
id: vnc_client
|
||||
x: 10
|
||||
y: 20
|
||||
buttons: right
|
||||
- vnc.send_pointer:
|
||||
id: vnc_client
|
||||
x: !lambda return 30;
|
||||
y: !lambda return 40;
|
||||
buttons: !lambda return 4;
|
||||
- vnc.send_key:
|
||||
id: vnc_client
|
||||
key: Return
|
||||
- vnc.send_key:
|
||||
id: vnc_client
|
||||
key: "a"
|
||||
- vnc.send_key:
|
||||
id: vnc_client
|
||||
key: 0xFF1B
|
||||
- vnc.send_key:
|
||||
id: vnc_client
|
||||
key: !lambda return 0xFF09;
|
||||
- vnc.send_key:
|
||||
id: vnc_client
|
||||
key: Shift_L
|
||||
action: down
|
||||
- vnc.send_key:
|
||||
id: vnc_client
|
||||
key: Shift_L
|
||||
action: up
|
||||
- vnc.send_key:
|
||||
id: vnc_client
|
||||
key: "é"
|
||||
action: press
|
||||
- vnc.type_text:
|
||||
id: vnc_client
|
||||
text: "Hello\n"
|
||||
- vnc.type_text:
|
||||
id: vnc_client
|
||||
text: !lambda return std::string("world");
|
||||
- if:
|
||||
condition:
|
||||
vnc.is_connected: vnc_client
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
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
|
||||
@@ -1,575 +0,0 @@
|
||||
#include "vnc_test_helpers.h"
|
||||
|
||||
#include "esphome/components/touchscreen/touchscreen.h"
|
||||
|
||||
#ifdef USE_HOST
|
||||
|
||||
namespace esphome::vnc::testing {
|
||||
|
||||
static constexpr uint32_t KEYSYM_RETURN = 0xFF0D;
|
||||
static constexpr uint32_t KEYSYM_SHIFT_L = 0xFFE1;
|
||||
static constexpr uint32_t KEYSYM_CONTROL_L = 0xFFE3;
|
||||
|
||||
static touchscreen::TouchPoint point(uint8_t id, uint16_t x, uint16_t y, uint8_t state) {
|
||||
touchscreen::TouchPoint tp{};
|
||||
tp.id = id;
|
||||
tp.x = x;
|
||||
tp.y = y;
|
||||
tp.state = static_cast<int8_t>(state);
|
||||
return tp;
|
||||
}
|
||||
|
||||
/// One finger, reported to the listener the way Touchscreen does: update() then touch() on first contact
|
||||
/// (no update() when it lands on (0, 0), the new point's previous position), update() while it moves, and on
|
||||
/// lift update() with the point RELEASING, then release().
|
||||
class Finger {
|
||||
public:
|
||||
explicit Finger(VncClient &client) : client_(client) {}
|
||||
void at(uint16_t x, uint16_t y) {
|
||||
if (!this->down_) {
|
||||
this->tp_ = point(0, 0, 0, touchscreen::STATE_PRESSED);
|
||||
} else {
|
||||
this->tp_.state = touchscreen::STATE_UPDATED;
|
||||
}
|
||||
this->tp_.x_prev = this->tp_.x;
|
||||
this->tp_.y_prev = this->tp_.y;
|
||||
this->tp_.x = x;
|
||||
this->tp_.y = y;
|
||||
if (x != this->tp_.x_prev || y != this->tp_.y_prev) {
|
||||
this->client_.update({this->tp_});
|
||||
}
|
||||
if (!this->down_) {
|
||||
this->client_.touch(this->tp_);
|
||||
}
|
||||
this->down_ = true;
|
||||
}
|
||||
void lift() {
|
||||
ASSERT_TRUE(this->down_);
|
||||
this->tp_.state |= touchscreen::STATE_RELEASING;
|
||||
this->client_.update({this->tp_});
|
||||
this->client_.release();
|
||||
this->down_ = false;
|
||||
}
|
||||
|
||||
protected:
|
||||
VncClient &client_;
|
||||
touchscreen::TouchPoint tp_{};
|
||||
bool down_{false};
|
||||
};
|
||||
|
||||
class VncInput : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override { this->begin(64, 48, 64, 48); }
|
||||
void begin(int disp_w, int disp_h, uint16_t fb_w, uint16_t fb_h) {
|
||||
this->f_.reset();
|
||||
this->s_.reset();
|
||||
this->d_ = std::make_unique<FakeDisplay>(disp_w, disp_h);
|
||||
this->s_ = std::make_unique<Session>(this->d_.get());
|
||||
this->s_->start(fb_w, fb_h);
|
||||
this->f_ = std::make_unique<Finger>(this->s_->client);
|
||||
}
|
||||
Session &s() { return *this->s_; }
|
||||
TestVncClient &c() { return this->s_->client; }
|
||||
Finger &f() { return *this->f_; }
|
||||
std::unique_ptr<FakeDisplay> d_;
|
||||
std::unique_ptr<Session> s_;
|
||||
std::unique_ptr<Finger> f_;
|
||||
};
|
||||
|
||||
TEST_F(VncInput, PointerEventBytes) {
|
||||
c().send_pointer(10, 20, 1);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 10, 20)));
|
||||
c().send_pointer(63, 47, 8 | 16);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(24, 63, 47)));
|
||||
c().send_pointer(1000, 1000, 0);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(0, 63, 47)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, KeyEventBytes) {
|
||||
c().send_key(KEYSYM_RETURN, VncKeyAction::VNC_KEY_ACTION_DOWN);
|
||||
EXPECT_EQ(hex(s().take()), hex({4, 1, 0, 0, 0, 0, 0xFF, 0x0D}));
|
||||
c().send_key(KEYSYM_RETURN, VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
EXPECT_EQ(hex(s().take()), hex({4, 0, 0, 0, 0, 0, 0xFF, 0x0D}));
|
||||
c().send_key(0x01000000 | 0x20AC, VncKeyAction::VNC_KEY_ACTION_PRESS);
|
||||
EXPECT_EQ(hex(s().take()), hex(press(0x010020AC)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TypeText) {
|
||||
c().type_text("aB\n", 3);
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press('a'), press('B'), press(KEYSYM_RETURN)})));
|
||||
c().type_text(std::string("\t\b"));
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press(0xFF09), press(0xFF08)})));
|
||||
// Other control characters have no key.
|
||||
c().type_text(std::string("x\x01y\x7F"));
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press('x'), press('y')})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TypeTextUtf8) {
|
||||
c().type_text(std::string("\xC3\xA9\xE2\x82\xAC"));
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press(0xE9), press(0x010020AC)})));
|
||||
c().type_text(std::string("\xF0\x9F\x98\x80"));
|
||||
EXPECT_EQ(hex(s().take()), hex(press(0x0101F600)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TypeTextSkipsBrokenUtf8) {
|
||||
// A lead byte without its continuation, a stray continuation, and a cut-off sequence.
|
||||
c().type_text(std::string("\xC3(\x80"
|
||||
"a\xE2\x82"));
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press('('), press('a')})));
|
||||
}
|
||||
|
||||
// A cut-off sequence ends at the first byte that is not a continuation byte, so "\xE2a" still types the 'a'.
|
||||
TEST_F(VncInput, TruncatedUtf8KeepsTheNextCharacter) {
|
||||
c().type_text(std::string("\xE2"
|
||||
"a"));
|
||||
EXPECT_EQ(hex(s().take()), hex(press('a')));
|
||||
c().type_text(std::string("x\xF0"
|
||||
"yz"));
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press('x'), press('y'), press('z')})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, InvalidUtf8ValuesAreSkipped) {
|
||||
// Overlong 'A', a surrogate, and a value above U+10FFFF; U+10FFFF itself is kept.
|
||||
c().type_text(std::string("\xC1\x81"
|
||||
"a\xED\xA0\x80"
|
||||
"b\xF4\x90\x80\x80"
|
||||
"c\xF4\x8F\xBF\xBF"));
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press('a'), press('b'), press('c'), press(0x0110FFFF)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, LengthCutsAMultiByteCharacter) {
|
||||
c().type_text("a\xC3\xA9", 2);
|
||||
EXPECT_EQ(hex(s().take()), hex(press('a')));
|
||||
c().type_text("\xC3\xA9", 1);
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(VncInput, EmbeddedNulIsSkipped) {
|
||||
c().type_text("a\0b", 3);
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press('a'), press('b')})));
|
||||
}
|
||||
|
||||
// --- A full send buffer never gets a partial message.
|
||||
|
||||
class VncInputFull : public VncInput {
|
||||
protected:
|
||||
/// Fills the socket, then the client's buffer up to `room` free bytes.
|
||||
void fill(size_t room) {
|
||||
uint8_t junk[1024];
|
||||
std::memset(junk, 0xEE, sizeof(junk));
|
||||
for (;;) {
|
||||
ssize_t n = ::write(s().client_fd, junk, sizeof(junk));
|
||||
if (n <= 0) {
|
||||
break;
|
||||
}
|
||||
this->expected_.insert(this->expected_.end(), junk, junk + n);
|
||||
}
|
||||
while (::write(s().client_fd, junk, 1) == 1) {
|
||||
this->expected_.push_back(0xEE);
|
||||
}
|
||||
ASSERT_GE(c().link().tx_free(), room);
|
||||
Bytes filler(c().link().tx_free() - room, 0xDD);
|
||||
ASSERT_EQ(c().link().queue(filler.data(), filler.size()), filler.size());
|
||||
this->expected_.insert(this->expected_.end(), filler.begin(), filler.end());
|
||||
ASSERT_EQ(c().link().tx_free(), room);
|
||||
}
|
||||
/// Everything that reaches the server once it reads again.
|
||||
Bytes drain() {
|
||||
Bytes all;
|
||||
for (int idle = 0; idle < 3;) {
|
||||
Bytes more = s().take();
|
||||
all.insert(all.end(), more.begin(), more.end());
|
||||
idle = more.empty() ? idle + 1 : 0;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
Bytes expected_;
|
||||
};
|
||||
|
||||
TEST_F(VncInputFull, PointerDroppedWhole) {
|
||||
this->fill(5);
|
||||
c().send_pointer(1, 2, 1);
|
||||
c().send_key('a', VncKeyAction::VNC_KEY_ACTION_DOWN);
|
||||
EXPECT_EQ(hex(this->drain()), hex(this->expected_));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, PressNeedsRoomForBothHalves) {
|
||||
this->fill(15);
|
||||
c().send_key('a', VncKeyAction::VNC_KEY_ACTION_PRESS);
|
||||
c().send_key('b', VncKeyAction::VNC_KEY_ACTION_DOWN);
|
||||
EXPECT_EQ(hex(this->drain()), hex(cat({this->expected_, key(true, 'b')})));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, DroppedKeyUpIsSentOnceThereIsRoom) {
|
||||
this->fill(5);
|
||||
c().send_key(KEYSYM_SHIFT_L, VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
EXPECT_EQ(hex(this->drain()), hex(this->expected_));
|
||||
s().loop();
|
||||
EXPECT_EQ(hex(s().take()), hex(key(false, KEYSYM_SHIFT_L)));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, LaterInputWaitsForTheDroppedKeyUp) {
|
||||
this->fill(5);
|
||||
c().send_key(KEYSYM_SHIFT_L, VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
EXPECT_EQ(hex(this->drain()), hex(this->expected_));
|
||||
c().send_key('a', VncKeyAction::VNC_KEY_ACTION_PRESS);
|
||||
c().send_pointer(3, 4, 0);
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({key(false, KEYSYM_SHIFT_L), press('a'), pointer(0, 3, 4)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, ClickReleaseWaitsForRoom) {
|
||||
this->fill(6);
|
||||
c().send_pointer(5, 5, 1);
|
||||
c().send_pointer(5, 5, 0);
|
||||
EXPECT_EQ(hex(this->drain()), hex(cat({this->expected_, pointer(1, 5, 5)})));
|
||||
s().loop();
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(0, 5, 5)));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, ReleaseOverflowEndsTheSession) {
|
||||
this->fill(5);
|
||||
for (uint32_t k = 0; k < 4; k++) {
|
||||
c().send_key('a' + k, VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
}
|
||||
EXPECT_TRUE(c().is_connected());
|
||||
c().send_key('e', VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
EXPECT_FALSE(c().is_connected());
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, BothHeldModifiersAreReleased) {
|
||||
this->fill(5);
|
||||
c().send_key(KEYSYM_SHIFT_L, VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
c().send_key(KEYSYM_CONTROL_L, VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
EXPECT_EQ(hex(this->drain()), hex(this->expected_));
|
||||
s().loop();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({key(false, KEYSYM_SHIFT_L), key(false, KEYSYM_CONTROL_L)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, TextStopsAtAWholeCharacter) {
|
||||
this->fill(40);
|
||||
c().type_text(std::string("abcd"));
|
||||
EXPECT_EQ(hex(this->drain()), hex(cat({this->expected_, press('a'), press('b')})));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, DroppedReleaseIsSentOnceThereIsRoom) {
|
||||
f().at(10, 10);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 10, 10)));
|
||||
this->fill(3);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(this->drain()), hex(this->expected_));
|
||||
s().loop();
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(0, 10, 10)));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, TouchPressRetriedWhenDropped) {
|
||||
this->fill(3);
|
||||
f().at(10, 10);
|
||||
Bytes all = this->drain();
|
||||
EXPECT_EQ(hex(all), hex(this->expected_));
|
||||
f().at(11, 11);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 11, 11)));
|
||||
}
|
||||
|
||||
TEST(VncInputState, DroppedWhenNotRunning) {
|
||||
FakeDisplay d;
|
||||
Session s(&d);
|
||||
Finger f(s.client);
|
||||
s.attach();
|
||||
s.deliver(Rfb().version());
|
||||
s.take();
|
||||
s.client.send_pointer(1, 1, 1);
|
||||
s.client.send_key('a', VncKeyAction::VNC_KEY_ACTION_PRESS);
|
||||
s.client.type_text(std::string("abc"));
|
||||
f.at(5, 5);
|
||||
f.lift();
|
||||
s.loop();
|
||||
EXPECT_EQ(s.take().size(), 0u);
|
||||
}
|
||||
|
||||
TEST(VncInputState, DroppedAfterTheSessionEnds) {
|
||||
FakeDisplay d;
|
||||
Session s(&d);
|
||||
Finger f(s.client);
|
||||
s.start(64, 48);
|
||||
f.at(5, 5);
|
||||
s.take();
|
||||
s.close_peer();
|
||||
s.loop();
|
||||
f.lift();
|
||||
s.client.send_pointer(1, 1, 1);
|
||||
|
||||
// The next session starts with no button held: the first touch is a press again.
|
||||
s.start(64, 48);
|
||||
f.at(7, 8);
|
||||
EXPECT_EQ(hex(s.take()), hex(pointer(1, 7, 8)));
|
||||
}
|
||||
|
||||
// --- Touch
|
||||
|
||||
TEST_F(VncInput, TouchPressIsSentAtOnce) {
|
||||
f().at(10, 12);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 10, 12)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchMovesInsideTheSlopAreSuppressed) {
|
||||
f().at(20, 20);
|
||||
s().take();
|
||||
f().at(25, 24);
|
||||
f().at(28, 12);
|
||||
f().at(12, 28);
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
f().at(29, 20);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 29, 20)));
|
||||
// Once dragging, every change goes out, even back inside the slop.
|
||||
f().at(21, 20);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 21, 20)));
|
||||
f().at(21, 20);
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TapReleasesWhereItPressed) {
|
||||
f().at(10, 10);
|
||||
f().at(13, 12);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 10, 10), pointer(0, 10, 10)})));
|
||||
c().release();
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(VncInput, DragReleasesAtTheLastPosition) {
|
||||
f().at(10, 10);
|
||||
f().at(30, 30);
|
||||
f().at(40, 35);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()),
|
||||
hex(cat({pointer(1, 10, 10), pointer(1, 30, 30), pointer(1, 40, 35), pointer(0, 40, 35)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, EachTapStartsANewSlop) {
|
||||
f().at(10, 10);
|
||||
f().at(40, 10);
|
||||
f().lift();
|
||||
s().take();
|
||||
f().at(50, 10);
|
||||
f().at(52, 10);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 50, 10), pointer(0, 50, 10)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchIgnoresButtonsLeftByAnAutomation) {
|
||||
c().send_pointer(5, 5, 8 | 4);
|
||||
s().take();
|
||||
f().at(10, 10);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 10, 10), pointer(0, 10, 10)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchPressesEvenWhileAnAutomationHoldsLeft) {
|
||||
c().send_pointer(5, 5, 1);
|
||||
s().take();
|
||||
f().at(3, 3);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 3, 3)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, PressOutsideTheVisibleAreaIsIgnored) {
|
||||
this->begin(64, 48, 100, 80);
|
||||
f().at(64, 10);
|
||||
f().at(10, 48);
|
||||
f().lift();
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
f().at(63, 47);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 63, 47)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, PressOutsideASmallerServerScreenIsIgnored) {
|
||||
this->begin(64, 48, 32, 20);
|
||||
f().at(40, 10);
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
f().at(31, 19);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 31, 19)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, DragIsClampedToTheVisibleArea) {
|
||||
this->begin(64, 48, 100, 80);
|
||||
f().at(50, 30);
|
||||
f().at(200, 300);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 50, 30), pointer(1, 63, 47), pointer(0, 63, 47)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, ClampedMoveInsideTheSlopStaysATap) {
|
||||
this->begin(64, 48, 100, 80);
|
||||
f().at(60, 40);
|
||||
f().at(200, 300);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 60, 40), pointer(0, 60, 40)})));
|
||||
}
|
||||
|
||||
// The client never scales: a touch at display x,y is a pointer event at framebuffer x,y.
|
||||
TEST_F(VncInput, TouchCoordinatesAreNotScaled) {
|
||||
this->begin(960, 540, 960, 540);
|
||||
const uint16_t points[][2] = {{0, 0}, {192, 108}, {480, 270}, {767, 431}, {900, 500}, {959, 539}, {959, 0}, {0, 539}};
|
||||
for (const auto &p : points) {
|
||||
f().at(p[0], p[1]);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, p[0], p[1]), pointer(0, p[0], p[1])}))) << p[0] << "," << p[1];
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchCoordinatesAreNotScaledOnALargerServer) {
|
||||
this->begin(960, 540, 1280, 720);
|
||||
f().at(900, 500);
|
||||
f().at(950, 530);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 900, 500), pointer(1, 950, 530), pointer(0, 950, 530)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, FirstTouchAtTheOriginComesOnlyThroughTouch) {
|
||||
c().touch(point(0, 0, 0, touchscreen::STATE_PRESSED));
|
||||
c().release();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 0, 0), pointer(0, 0, 0)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchSkipsALiftedPoint) {
|
||||
c().touch(point(0, 5, 5, touchscreen::STATE_PRESSED | touchscreen::STATE_RELEASING));
|
||||
c().touch(point(0, 5, 5, touchscreen::STATE_RELEASED));
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchListenerSkipsStalePoints) {
|
||||
// Finger 0 was lifted earlier and lingers as RELEASED.
|
||||
c().update({point(0, 1, 1, touchscreen::STATE_RELEASED), point(1, 20, 21, touchscreen::STATE_PRESSED)});
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 20, 21)));
|
||||
// Finger 0 is being lifted now (still flagged PRESSED as well).
|
||||
c().update({point(0, 5, 5, touchscreen::STATE_PRESSED | touchscreen::STATE_RELEASING),
|
||||
point(1, 40, 21, touchscreen::STATE_UPDATED)});
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 40, 21)));
|
||||
// A point from an uncalibrated touchscreen.
|
||||
c().update({point(0, 3, 3, touchscreen::STATE_CALIBRATE)});
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
c().update({});
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
c().release();
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(0, 40, 21)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchListenerUsesTheFirstActivePoint) {
|
||||
c().update({point(0, 10, 10, touchscreen::STATE_PRESSED), point(1, 50, 40, touchscreen::STATE_PRESSED)});
|
||||
c().update({point(0, 10, 11, touchscreen::STATE_UPDATED), point(1, 60, 40, touchscreen::STATE_UPDATED)});
|
||||
c().release();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 10, 10), pointer(0, 10, 10)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchListenerPassesCoordinatesThrough) {
|
||||
this->begin(960, 540, 960, 540);
|
||||
c().update({point(0, 767, 431, touchscreen::STATE_PRESSED)});
|
||||
c().update({point(0, 192, 108, touchscreen::STATE_UPDATED)});
|
||||
c().release();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 767, 431), pointer(1, 192, 108), pointer(0, 192, 108)})));
|
||||
}
|
||||
|
||||
// --- Through the real touchscreen state machine: raw controller points to pointer events.
|
||||
|
||||
class FakeTouchscreen : public touchscreen::Touchscreen {
|
||||
public:
|
||||
struct Raw {
|
||||
uint8_t id;
|
||||
int16_t x;
|
||||
int16_t y;
|
||||
};
|
||||
void attach(display::Display *display) {
|
||||
this->set_display(display);
|
||||
// What Touchscreen::call_setup() does.
|
||||
this->display_width_ = display->get_width();
|
||||
this->display_height_ = display->get_height();
|
||||
}
|
||||
/// One controller read with these fingers down, then the deferred listener calls.
|
||||
void report(std::vector<Raw> points) {
|
||||
this->points_ = std::move(points);
|
||||
this->store_.touched = true;
|
||||
this->loop();
|
||||
App.scheduler.call(millis());
|
||||
}
|
||||
|
||||
protected:
|
||||
void update_touches() override {
|
||||
this->skip_update_ = false;
|
||||
for (const auto &p : this->points_) {
|
||||
this->add_raw_touch_position_(p.id, p.x, p.y);
|
||||
}
|
||||
}
|
||||
std::vector<Raw> points_;
|
||||
};
|
||||
|
||||
/// The display position the touchscreen component computes for a raw value.
|
||||
static uint16_t mapped(int raw, int raw_max, int size) {
|
||||
const int normalized = raw <= 0 ? 0 : (raw >= raw_max ? 0xfff : 0xfff * raw / raw_max);
|
||||
return static_cast<uint16_t>(normalized * size / 0x1000);
|
||||
}
|
||||
|
||||
class VncTouchscreen : public VncInput {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
// The test main does not construct App; the touchscreen defers its listener calls to the scheduler.
|
||||
static const bool APP_CONSTRUCTED = (new (&App) Application(), true);
|
||||
(void) APP_CONSTRUCTED;
|
||||
this->begin(960, 540, 960, 540);
|
||||
this->ts_.set_calibration(0, 960, 0, 540);
|
||||
this->ts_.attach(this->d_.get());
|
||||
this->c().register_touchscreen(&this->ts_);
|
||||
}
|
||||
FakeTouchscreen ts_;
|
||||
};
|
||||
|
||||
TEST_F(VncTouchscreen, TapsLandWhereTheFingerIs) {
|
||||
const int16_t taps[][2] = {{10, 10}, {100, 50}, {480, 270}, {700, 400}, {900, 500}, {955, 535}};
|
||||
for (const auto &t : taps) {
|
||||
this->ts_.report({{0, t[0], t[1]}});
|
||||
this->ts_.report({});
|
||||
const uint16_t x = mapped(t[0], 960, 960);
|
||||
const uint16_t y = mapped(t[1], 540, 540);
|
||||
EXPECT_LE(std::abs(x - t[0]), 1);
|
||||
EXPECT_LE(std::abs(y - t[1]), 1);
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, x, y), pointer(0, x, y)}))) << t[0] << "," << t[1];
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(VncTouchscreen, TapAtTheOrigin) {
|
||||
this->ts_.report({{0, 0, 0}});
|
||||
this->ts_.report({});
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 0, 0), pointer(0, 0, 0)})));
|
||||
}
|
||||
|
||||
TEST_F(VncTouchscreen, DragFollowsTheFinger) {
|
||||
this->ts_.report({{0, 100, 100}});
|
||||
this->ts_.report({{0, 104, 103}});
|
||||
this->ts_.report({{0, 300, 200}});
|
||||
this->ts_.report({{0, 600, 400}});
|
||||
this->ts_.report({});
|
||||
const uint16_t x0 = mapped(100, 960, 960), y0 = mapped(100, 540, 540);
|
||||
const uint16_t x1 = mapped(300, 960, 960), y1 = mapped(200, 540, 540);
|
||||
const uint16_t x2 = mapped(600, 960, 960), y2 = mapped(400, 540, 540);
|
||||
EXPECT_EQ(hex(s().take()),
|
||||
hex(cat({pointer(1, x0, y0), pointer(1, x1, y1), pointer(1, x2, y2), pointer(0, x2, y2)})));
|
||||
}
|
||||
|
||||
TEST_F(VncTouchscreen, LiftedFirstFingerDoesNotPullThePointerBack) {
|
||||
this->ts_.report({{0, 100, 100}});
|
||||
this->ts_.report({{0, 100, 100}, {1, 500, 300}});
|
||||
// Finger 0 lifts; it lingers in the list as RELEASING, then RELEASED.
|
||||
this->ts_.report({{1, 510, 300}});
|
||||
this->ts_.report({{1, 520, 300}});
|
||||
this->ts_.report({});
|
||||
Bytes tx = s().take();
|
||||
ASSERT_GE(tx.size(), 12u);
|
||||
const Bytes release(tx.end() - 6, tx.end());
|
||||
EXPECT_EQ(hex(release), hex(pointer(0, mapped(520, 960, 960), mapped(300, 540, 540))));
|
||||
for (size_t i = 0; i + 6 <= tx.size(); i += 6) {
|
||||
const uint16_t x = (tx[i + 2] << 8) | tx[i + 3];
|
||||
EXPECT_TRUE(x == mapped(100, 960, 960) || x >= mapped(500, 960, 960)) << "event at x=" << x;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::vnc::testing
|
||||
|
||||
#endif // USE_HOST
|
||||
@@ -125,59 +125,6 @@ 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,15 +8,6 @@ interval:
|
||||
- interval: 60s
|
||||
then:
|
||||
- vnc.refresh
|
||||
- vnc.send_pointer:
|
||||
x: 10
|
||||
y: 20
|
||||
- vnc.send_key: Return
|
||||
- vnc.send_key: 0xFF1B
|
||||
- vnc.send_key:
|
||||
key: Shift_L
|
||||
action: down
|
||||
- vnc.type_text: "Hello\n"
|
||||
- if:
|
||||
condition: vnc.is_connected
|
||||
then:
|
||||
|
||||
@@ -365,9 +365,6 @@ inline Bytes handshake_38(uint16_t w, uint16_t h) {
|
||||
inline Bytes fbur(bool incremental, uint16_t w, uint16_t h) {
|
||||
return Rfb().u8(3).u8(incremental ? 1 : 0).u16(0).u16(0).u16(w).u16(h).bytes;
|
||||
}
|
||||
inline Bytes pointer(uint8_t mask, uint16_t x, uint16_t y) { return Rfb().u8(5).u8(mask).u16(x).u16(y).bytes; }
|
||||
inline Bytes key(bool down, uint32_t keysym) { return Rfb().u8(4).u8(down ? 1 : 0).u16(0).u32(keysym).bytes; }
|
||||
inline Bytes press(uint32_t keysym) { return cat({key(true, keysym), key(false, keysym)}); }
|
||||
inline Bytes set_pixel_format_565() { return {0, 0, 0, 0, 16, 16, 0, 1, 0, 31, 0, 63, 0, 31, 11, 5, 0, 0, 0, 0}; }
|
||||
inline Bytes set_pixel_format_332() { return {0, 0, 0, 0, 8, 8, 0, 1, 0, 7, 0, 7, 0, 3, 5, 2, 0, 0, 0, 0}; }
|
||||
inline Bytes set_encodings() { return {2, 0, 0, 3, 0, 0, 0, 5, 0, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0x11}; }
|
||||
|
||||
@@ -16,7 +16,6 @@ import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
@@ -29,7 +28,6 @@ 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,
|
||||
@@ -41,7 +39,6 @@ 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,
|
||||
@@ -83,13 +80,6 @@ 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."""
|
||||
@@ -237,12 +227,9 @@ 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' if DEBUG_SYMBOLS else ""),
|
||||
' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n'
|
||||
' - "-g" # Add debug symbols',
|
||||
)
|
||||
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:
|
||||
@@ -785,46 +772,16 @@ 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``.
|
||||
``symbols_path`` is the unstripped build when ``binary_path`` is a stripped
|
||||
copy; a crash backtrace reads its symbols from it.
|
||||
"""
|
||||
``run_binary_and_wait_for_port``."""
|
||||
# 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()
|
||||
@@ -853,9 +810,6 @@ 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."
|
||||
@@ -897,9 +851,6 @@ async def run_binary(
|
||||
# Last resort: SIGKILL
|
||||
process.kill()
|
||||
await process.wait()
|
||||
_print_crash_backtrace(
|
||||
symbols_path or binary_path, process.returncode, process.pid
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -19,4 +19,3 @@ 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
|
||||
@@ -1,26 +0,0 @@
|
||||
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
|
||||
@@ -1,27 +0,0 @@
|
||||
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
|
||||
@@ -1,27 +0,0 @@
|
||||
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"
|
||||
@@ -1,96 +0,0 @@
|
||||
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"
|
||||
@@ -1,73 +0,0 @@
|
||||
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"
|
||||
@@ -1,41 +0,0 @@
|
||||
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,31 +12,6 @@ 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
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,30 +0,0 @@
|
||||
"""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
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Integration test for how commands cancel a running position action on a template cover."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import CoverInfo, CoverState, EntityState
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
def _seen(positions: list[float], value: float) -> bool:
|
||||
return any(p == pytest.approx(value, abs=0.01) for p in positions)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cover_position_action_cancel(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that stop, toggle, open, close and a new position cancel a running position action."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
positions: list[float] = []
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, CoverState):
|
||||
positions.append(state.position)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
|
||||
cover = require_entity(entities, "test_cover", CoverInfo)
|
||||
|
||||
# position_action publishes its target 1s after the command. The second
|
||||
# command comes 0.2s after a move to 0.5, so 0.5 shows up within the
|
||||
# 1.5s wait only if the position action for 0.5 was not cancelled.
|
||||
async def move_to_half_then(**command: Any) -> None:
|
||||
positions.clear()
|
||||
client.cover_command(key=cover.key, position=0.5)
|
||||
await asyncio.sleep(0.2)
|
||||
client.cover_command(key=cover.key, **command)
|
||||
await asyncio.sleep(1.5)
|
||||
assert not _seen(positions, 0.5)
|
||||
|
||||
await move_to_half_then(stop=True)
|
||||
|
||||
await move_to_half_then(toggle=True)
|
||||
|
||||
# open_action publishes 1.0
|
||||
await move_to_half_then(position=1.0)
|
||||
assert positions[-1] == pytest.approx(1.0, abs=0.01)
|
||||
|
||||
# close_action publishes 0.0
|
||||
await move_to_half_then(position=0.0)
|
||||
assert positions[-1] == pytest.approx(0.0, abs=0.01)
|
||||
|
||||
# the position action for 0.3 runs to its end
|
||||
await move_to_half_then(position=0.3)
|
||||
assert positions[-1] == pytest.approx(0.3, abs=0.01)
|
||||
@@ -141,10 +141,9 @@ async def _build(
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> tuple[int, int, Path, Path]:
|
||||
) -> tuple[int, int, Path]:
|
||||
"""Reserve an OTA port, compile the fixture with it, and release both
|
||||
ports right before the binary is started. Returns the stripped binary to
|
||||
run and upload, then the unstripped build for crash backtraces."""
|
||||
ports right before the binary is started."""
|
||||
api_port, api_socket = reserved_tcp_port
|
||||
with _reserve_port() as (ota_port, ota_socket):
|
||||
config_path = await write_yaml_config(
|
||||
@@ -153,11 +152,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), binary_path
|
||||
return api_port, ota_port, await _strip_debug(binary_path)
|
||||
|
||||
|
||||
async def _strip_debug(binary_path: Path) -> Path:
|
||||
"""A debug re-run builds with -g; every upload sends the image, so drop the
|
||||
"""The fixtures build 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(
|
||||
@@ -208,7 +207,6 @@ 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)
|
||||
|
||||
@@ -304,9 +302,7 @@ async def test_host_ota_self_update(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (
|
||||
proc,
|
||||
_lines,
|
||||
):
|
||||
@@ -345,9 +341,7 @@ 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, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (
|
||||
proc,
|
||||
_lines,
|
||||
):
|
||||
@@ -428,9 +422,7 @@ async def test_host_ota_encrypted(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
async with run_binary(dev.binary_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(
|
||||
@@ -466,9 +458,7 @@ 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, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
async with run_binary(dev.binary_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)
|
||||
@@ -495,9 +485,7 @@ 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, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
async with run_binary(dev.binary_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")
|
||||
@@ -547,9 +535,7 @@ 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, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
async with run_binary(dev.binary_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")
|
||||
@@ -584,9 +570,7 @@ 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, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, _lines):
|
||||
async with run_binary(dev.binary_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)
|
||||
@@ -595,9 +579,7 @@ 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, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
async with run_binary(dev.binary_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)
|
||||
@@ -629,10 +611,7 @@ 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, symbols_path=dev.symbols_path) as (
|
||||
proc,
|
||||
_lines,
|
||||
):
|
||||
async with run_binary(dev.binary_path) as (proc, _lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
pid_before = proc.pid
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""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
|
||||
@@ -1,94 +0,0 @@
|
||||
"""Integration test for the pipsolar switches with mock UART.
|
||||
|
||||
Each write queues the switch's on or off command on the hub, a direction with
|
||||
no command queues nothing, nothing is written at boot, and the write itself
|
||||
does not publish the switch state (the hub's polled status does).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from aioesphomeapi import EntityState, SwitchInfo
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
QUEUED = re.compile(r"Command queued successfully: (\w+) at position")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_pipsolar_switches(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Switch writes queue their commands and publish nothing themselves."""
|
||||
external_components_path = str(
|
||||
Path(__file__).parent / "fixtures" / "external_components"
|
||||
)
|
||||
yaml_config = yaml_config.replace(
|
||||
"EXTERNAL_COMPONENT_PATH", external_components_path
|
||||
)
|
||||
waiter = LineWaiter()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
utility = require_entity(entities, "priority_utility", SwitchInfo)
|
||||
voltage_range = require_entity(entities, "input_voltage_range", SwitchInfo)
|
||||
balance = require_entity(entities, "pv_power_balance", SwitchInfo)
|
||||
|
||||
states: list[EntityState] = []
|
||||
initial = asyncio.get_running_loop().create_future()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
states.append(state)
|
||||
if len(states) == 3 and not initial.done():
|
||||
initial.set_result(None)
|
||||
|
||||
client.subscribe_states(on_state)
|
||||
await asyncio.wait_for(initial, timeout=5.0)
|
||||
assert all(state.missing_state for state in states)
|
||||
# Nothing is restored or written at boot
|
||||
assert waiter.count(QUEUED) == 0
|
||||
|
||||
async def queued_after(key: int, on: bool) -> str:
|
||||
future = waiter.expect(QUEUED)
|
||||
client.switch_command(key, on)
|
||||
match = await waiter.wait(future)
|
||||
return match.group(1)
|
||||
|
||||
sent = waiter.expect("Sending command from queue: POP00")
|
||||
assert await queued_after(utility.key, True) == "POP00"
|
||||
await waiter.wait(sent)
|
||||
|
||||
# The utility switch has no off command, so the next queued command
|
||||
# is the one written after it
|
||||
future = waiter.expect(QUEUED)
|
||||
client.switch_command(utility.key, False)
|
||||
client.switch_command(balance.key, True)
|
||||
assert (await waiter.wait(future)).group(1) == "PSPB1"
|
||||
assert await queued_after(balance.key, False) == "PSPB0"
|
||||
assert await queued_after(voltage_range.key, True) == "PGR01"
|
||||
assert await queued_after(voltage_range.key, False) == "PGR00"
|
||||
|
||||
# A fresh connection's snapshot shows the writes left every switch unpublished
|
||||
async with api_client_connected() as fresh:
|
||||
snapshot: list[EntityState] = []
|
||||
done = asyncio.get_running_loop().create_future()
|
||||
|
||||
def on_snapshot(state: EntityState) -> None:
|
||||
snapshot.append(state)
|
||||
if len(snapshot) == 3 and not done.done():
|
||||
done.set_result(None)
|
||||
|
||||
fresh.subscribe_states(on_snapshot)
|
||||
await asyncio.wait_for(done, timeout=5.0)
|
||||
assert all(state.missing_state for state in snapshot), snapshot
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Integration test for the vnc component against a fake RFB server.
|
||||
|
||||
One compiled device runs every step in order against one server: the first frame, request
|
||||
pacing, input, a reconnect after the server closes the link, a stream cut into tiny pieces, an
|
||||
pacing, 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,20 +209,6 @@ 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)
|
||||
@@ -305,10 +291,6 @@ 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,
|
||||
@@ -317,7 +299,7 @@ async def test_vnc_client(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The client renders, paces, sends input and recovers from drops and bad streams."""
|
||||
"""The client renders, paces 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()
|
||||
@@ -441,53 +423,6 @@ 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()
|
||||
|
||||
@@ -9,7 +9,6 @@ import sys
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
# Add the script directory to Python path so we can import the module
|
||||
script_dir = str((Path(__file__).parent / ".." / ".." / "script").resolve())
|
||||
@@ -1309,20 +1308,6 @@ def test_esp_idf_infra_changed(changed_files: list[str], expected: bool) -> None
|
||||
assert determine_jobs._esp_idf_infra_changed(changed_files) is expected
|
||||
|
||||
|
||||
def test_integration_ccache_restores_every_bucket_slice() -> None:
|
||||
"""ci.yml restores one ccache slice per full-run bucket; a bucket count
|
||||
change without a matching restore step would leave a slice unread."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
workflow = yaml.safe_load((repo_root / ".github/workflows/ci.yml").read_text())
|
||||
keys = [
|
||||
step["with"]["restore-keys"]
|
||||
for step in workflow["jobs"]["integration-tests"]["steps"]
|
||||
if step.get("name", "").startswith("Restore integration ccache (slice")
|
||||
]
|
||||
buckets = determine_jobs.INTEGRATION_TESTS_SPLIT_BUCKETS
|
||||
assert keys == [f"integration-ccache-v1-{i}-" for i in range(buckets)]
|
||||
|
||||
|
||||
def test_esp_idf_infra_trigger_paths_exist() -> None:
|
||||
"""A renamed or moved trigger module must fail here, not silently stop
|
||||
forcing the esp32 IDF compile."""
|
||||
|
||||
@@ -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,168 +640,6 @@ 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."""
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
"""Tests for the vnc keysym and pointer button validators."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.vnc.keysyms import (
|
||||
ALIASES,
|
||||
BUTTONS,
|
||||
KEYSYMS,
|
||||
button_mask,
|
||||
keysym,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import HexInt
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "code"),
|
||||
[
|
||||
("BackSpace", 0xFF08),
|
||||
("Tab", 0xFF09),
|
||||
("Return", 0xFF0D),
|
||||
("Pause", 0xFF13),
|
||||
("Scroll_Lock", 0xFF14),
|
||||
("Escape", 0xFF1B),
|
||||
("Home", 0xFF50),
|
||||
("Left", 0xFF51),
|
||||
("Up", 0xFF52),
|
||||
("Right", 0xFF53),
|
||||
("Down", 0xFF54),
|
||||
("Page_Up", 0xFF55),
|
||||
("Page_Down", 0xFF56),
|
||||
("End", 0xFF57),
|
||||
("Print", 0xFF61),
|
||||
("Insert", 0xFF63),
|
||||
("Menu", 0xFF67),
|
||||
("Num_Lock", 0xFF7F),
|
||||
("KP_Enter", 0xFF8D),
|
||||
("F1", 0xFFBE),
|
||||
("F2", 0xFFBF),
|
||||
("F12", 0xFFC9),
|
||||
("Shift_L", 0xFFE1),
|
||||
("Shift_R", 0xFFE2),
|
||||
("Control_L", 0xFFE3),
|
||||
("Control_R", 0xFFE4),
|
||||
("Caps_Lock", 0xFFE5),
|
||||
("Meta_L", 0xFFE7),
|
||||
("Meta_R", 0xFFE8),
|
||||
("Alt_L", 0xFFE9),
|
||||
("Alt_R", 0xFFEA),
|
||||
("Super_L", 0xFFEB),
|
||||
("Super_R", 0xFFEC),
|
||||
("Delete", 0xFFFF),
|
||||
("space", 0x20),
|
||||
("ISO_Left_Tab", 0xFE20),
|
||||
],
|
||||
)
|
||||
def test_keysym_names(name: str, code: int) -> None:
|
||||
"""Every documented name maps to its X11 keysym, in any case."""
|
||||
assert KEYSYMS[name] == code
|
||||
assert keysym(name) == code
|
||||
assert keysym(name.lower()) == code
|
||||
assert keysym(name.upper()) == code
|
||||
|
||||
|
||||
def test_function_keys_are_contiguous() -> None:
|
||||
"""F1 to F12 run without gaps."""
|
||||
assert [KEYSYMS[f"F{n}"] for n in range(1, 13)] == list(range(0xFFBE, 0xFFCA))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("alias", "code"),
|
||||
[
|
||||
("enter", 0xFF0D),
|
||||
("Enter", 0xFF0D),
|
||||
("esc", 0xFF1B),
|
||||
("ESC", 0xFF1B),
|
||||
("del", 0xFFFF),
|
||||
("ins", 0xFF63),
|
||||
("pgup", 0xFF55),
|
||||
("PgDn", 0xFF56),
|
||||
("ctrl", 0xFFE3),
|
||||
("shift", 0xFFE1),
|
||||
("alt", 0xFFE9),
|
||||
("super", 0xFFEB),
|
||||
("backspace", 0xFF08),
|
||||
("tab", 0xFF09),
|
||||
("return", 0xFF0D),
|
||||
("RETURN", 0xFF0D),
|
||||
("page_up", 0xFF55),
|
||||
("Prior", 0xFF55),
|
||||
("Next", 0xFF56),
|
||||
],
|
||||
)
|
||||
def test_keysym_aliases(alias: str, code: int) -> None:
|
||||
"""Short aliases and lowercase names resolve to the X11 keysym."""
|
||||
assert keysym(alias) == code
|
||||
|
||||
|
||||
def test_lowercase_names_are_unique() -> None:
|
||||
"""No two names or aliases collide once lowercased."""
|
||||
names = [name.lower() for name in KEYSYMS] + list(ALIASES)
|
||||
assert len(names) == len(set(names))
|
||||
assert all(alias == alias.lower() for alias in ALIASES)
|
||||
assert all(name in KEYSYMS for name in ALIASES.values())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("digit", "code"), [(n, 0x30 + n) for n in range(10)])
|
||||
def test_keysym_unquoted_digit(digit: int, code: int) -> None:
|
||||
"""An unquoted digit 0 to 9 is that digit, not a raw keysym."""
|
||||
assert keysym(digit) == code
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [10, 0x41, 0xFF0D, 0x01000041, 0x1FFFFFFF])
|
||||
def test_keysym_int_passes_through(code: int) -> None:
|
||||
"""Other integers in range are raw keysyms."""
|
||||
assert keysym(code) == code
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [-1, 0x20000000])
|
||||
def test_keysym_int_out_of_range(code: int) -> None:
|
||||
"""Integers outside the keysym range are rejected."""
|
||||
with pytest.raises(cv.Invalid, match="value must be at"):
|
||||
keysym(code)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False])
|
||||
def test_keysym_bool_rejected(value: bool) -> None:
|
||||
"""YAML booleans such as y and n ask the user to quote the key."""
|
||||
with pytest.raises(cv.Invalid, match="quotes"):
|
||||
keysym(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("char", "code"),
|
||||
[
|
||||
(" ", 0x20),
|
||||
("a", 0x61),
|
||||
("A", 0x41),
|
||||
("0", 0x30),
|
||||
("9", 0x39),
|
||||
("~", 0x7E),
|
||||
(" ", 0xA0),
|
||||
("é", 0xE9),
|
||||
("ÿ", 0xFF),
|
||||
("€", 0x010020AC),
|
||||
("Ā", 0x01000100),
|
||||
("😀", 0x0101F600),
|
||||
],
|
||||
)
|
||||
def test_keysym_single_character(char: str, code: int) -> None:
|
||||
"""Latin-1 characters are their code point, others use the Unicode keysym range."""
|
||||
assert keysym(char) == code
|
||||
|
||||
|
||||
def test_keysym_single_character_is_case_sensitive() -> None:
|
||||
"""A single letter is never folded by the case-insensitive name lookup."""
|
||||
assert keysym("a") != keysym("A")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("char", ["\x00", "\n", "\t", "\x1f", "\x7f", "\x80", "\x9f"])
|
||||
def test_keysym_control_character_rejected(char: str) -> None:
|
||||
"""Control characters are not keys."""
|
||||
with pytest.raises(cv.Invalid, match="Control character"):
|
||||
keysym(char)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "code"),
|
||||
[
|
||||
("0xFF0D", 0xFF0D),
|
||||
("0xff0d", 0xFF0D),
|
||||
("0XFF0D", 0xFF0D),
|
||||
("0x0", 0),
|
||||
("0x1FFFFFFF", 0x1FFFFFFF),
|
||||
],
|
||||
)
|
||||
def test_keysym_hex_string(value: str, code: int) -> None:
|
||||
"""Hexadecimal strings are raw keysyms."""
|
||||
assert keysym(value) == code
|
||||
|
||||
|
||||
def test_keysym_hex_string_out_of_range() -> None:
|
||||
"""A hexadecimal keysym above the range is rejected."""
|
||||
with pytest.raises(cv.Invalid, match="value must be at"):
|
||||
keysym("0x20000000")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0x", "0xZZ", "0x12G"])
|
||||
def test_keysym_hex_string_invalid(value: str) -> None:
|
||||
"""A malformed hexadecimal string is rejected."""
|
||||
with pytest.raises(cv.Invalid, match="cannot parse"):
|
||||
keysym(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "Foo", "Return2", "65", "F13", "left_shift"])
|
||||
def test_keysym_unknown_name(value: str) -> None:
|
||||
"""Unknown names list a few valid ones."""
|
||||
with pytest.raises(cv.Invalid, match="Unknown key") as exc_info:
|
||||
keysym(value)
|
||||
assert "Return" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, 1.5, ["a"], {"key": "a"}])
|
||||
def test_keysym_wrong_type(value: object) -> None:
|
||||
"""Values that are not strings or integers are rejected."""
|
||||
with pytest.raises(cv.Invalid, match="Expected a key name"):
|
||||
keysym(value)
|
||||
|
||||
|
||||
def test_keysym_returns_hex_int() -> None:
|
||||
"""Keysyms render as hexadecimal in the generated code."""
|
||||
assert isinstance(keysym("Return"), HexInt)
|
||||
assert isinstance(keysym("a"), HexInt)
|
||||
assert isinstance(keysym(5), HexInt)
|
||||
assert isinstance(keysym(0xFF0D), HexInt)
|
||||
assert isinstance(keysym("0xFF0D"), HexInt)
|
||||
|
||||
|
||||
def test_buttons_table() -> None:
|
||||
"""Button names map to the RFB pointer mask bits."""
|
||||
assert BUTTONS == {
|
||||
"left": 1,
|
||||
"middle": 2,
|
||||
"right": 4,
|
||||
"wheel_up": 8,
|
||||
"wheel_down": 16,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "mask"),
|
||||
[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(255, 255),
|
||||
("left", 1),
|
||||
("LEFT", 1),
|
||||
("middle", 2),
|
||||
("right", 4),
|
||||
("wheel_up", 8),
|
||||
("wheel_down", 16),
|
||||
(["left"], 1),
|
||||
(["left", "right"], 5),
|
||||
(["left", "middle", "right", "wheel_up", "wheel_down"], 31),
|
||||
(["left", "left"], 1),
|
||||
([], 0),
|
||||
(None, 0),
|
||||
],
|
||||
)
|
||||
def test_button_mask(value: object, mask: int) -> None:
|
||||
"""Masks are numbers or the OR of the named buttons."""
|
||||
assert button_mask(value) == mask
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [-1, 256])
|
||||
def test_button_mask_out_of_range(value: int) -> None:
|
||||
"""Numeric masks must fit in one byte."""
|
||||
with pytest.raises(cv.Invalid, match="value must be at"):
|
||||
button_mask(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["back", ["left", "back"], [1]])
|
||||
def test_button_mask_unknown_name(value: object) -> None:
|
||||
"""Unknown button names are rejected."""
|
||||
with pytest.raises(cv.Invalid, match="Unknown value"):
|
||||
button_mask(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False])
|
||||
def test_button_mask_bool_rejected(value: bool) -> None:
|
||||
"""Booleans are not silently read as button 1 or no buttons."""
|
||||
with pytest.raises(cv.Invalid, match="boolean"):
|
||||
button_mask(value)
|
||||
@@ -204,31 +204,6 @@ 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,17 +2239,6 @@ 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"})):
|
||||
|
||||
@@ -413,64 +413,6 @@ all_sensors: !include_dir_named named_dir
|
||||
assert "sensor3" in actual["all_sensors"]
|
||||
|
||||
|
||||
def test_find_files_sorted(tmp_path: Path) -> None:
|
||||
"""Files come back in name order, each directory's files before its
|
||||
subdirectories, whatever order the filesystem lists them in."""
|
||||
tree = {
|
||||
tmp_path: (["z_dir", "a_dir"], ["b.yaml", "a.yaml"]),
|
||||
tmp_path / "a_dir": ([], ["c.yaml"]),
|
||||
tmp_path / "z_dir": ([], ["d.yaml"]),
|
||||
}
|
||||
|
||||
def fake_walk(top: Path) -> Generator[tuple[str, list[str], list[str]]]:
|
||||
dirs, files = (list(x) for x in tree[Path(top)])
|
||||
yield str(top), dirs, files
|
||||
for d in dirs:
|
||||
yield from fake_walk(Path(top) / d)
|
||||
|
||||
with patch("esphome.yaml_util.os.walk", fake_walk):
|
||||
found = list(yaml_util.find_files(tmp_path, "*.yaml"))
|
||||
|
||||
assert found == [
|
||||
tmp_path / "a.yaml",
|
||||
tmp_path / "b.yaml",
|
||||
tmp_path / "a_dir" / "c.yaml",
|
||||
tmp_path / "z_dir" / "d.yaml",
|
||||
]
|
||||
|
||||
|
||||
def test_include_dir_list_is_sorted(
|
||||
tmp_path: Path, yaml_util_module: ModuleType
|
||||
) -> None:
|
||||
test_dir = tmp_path / "test_dir"
|
||||
(test_dir / "sub").mkdir(parents=True)
|
||||
for name in ("c", "a", "b"):
|
||||
(test_dir / f"{name}.yaml").write_text(f"key: {name}")
|
||||
(test_dir / "sub" / "d.yaml").write_text("key: d")
|
||||
test_yaml = tmp_path / "test.yaml"
|
||||
test_yaml.write_text("test: !include_dir_list test_dir\n")
|
||||
|
||||
actual = yaml_util_module.load_yaml(test_yaml)
|
||||
|
||||
assert [entry["key"] for entry in actual["test"]] == ["a", "b", "c", "d"]
|
||||
|
||||
|
||||
def test_include_dir_named_duplicate_name_is_an_error(
|
||||
tmp_path: Path, yaml_util_module: ModuleType
|
||||
) -> None:
|
||||
"""Two files with the same name in different subdirectories would map to
|
||||
the same key, so one of them would be silently dropped."""
|
||||
test_dir = tmp_path / "test_dir"
|
||||
(test_dir / "sub").mkdir(parents=True)
|
||||
(test_dir / "a.yaml").write_text("key: top")
|
||||
(test_dir / "sub" / "a.yaml").write_text("key: sub")
|
||||
test_yaml = tmp_path / "test.yaml"
|
||||
test_yaml.write_text("test: !include_dir_named test_dir\n")
|
||||
|
||||
with pytest.raises(EsphomeError, match="both use the key 'a'"):
|
||||
yaml_util_module.load_yaml(test_yaml)
|
||||
|
||||
|
||||
def test_secret_values_tracking(
|
||||
fixture_path: Path, yaml_util_module: ModuleType
|
||||
) -> None:
|
||||
|
||||
Reference in new issue
Block a user