mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 14:46:20 +00:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a0e5ffce8 | ||
|
|
713b3b2bc9 | ||
|
|
d6179b6d56 | ||
|
|
73a95c411d | ||
|
|
de7af3865b | ||
|
|
338e498960 | ||
|
|
15e1ac2500 | ||
|
|
3402ee8bb1 | ||
|
|
61d2632851 | ||
|
|
e9d940aca4 | ||
|
|
3f01f9895f | ||
|
|
801a1817b5 | ||
|
|
32c76ae828 | ||
|
|
c664f5fc95 | ||
|
|
2bc4681fd6 | ||
|
|
646501b0ef | ||
|
|
de3e657d8b | ||
|
|
1add726892 | ||
|
|
5a000cf5e4 | ||
|
|
6ed676fe32 | ||
|
|
7cceddb8a3 | ||
|
|
039b897e7b | ||
|
|
b178f74e5d | ||
|
|
e5224e22ae | ||
|
|
be66e8b99c | ||
|
|
4b91c8aff4 | ||
|
|
617e2ec1e0 | ||
|
|
1afac0312d | ||
|
|
b05465145f | ||
|
|
990fc402fd | ||
|
|
dd51624fbb | ||
|
|
45a056e337 | ||
|
|
37782f7206 | ||
|
|
945c2458b3 | ||
|
|
7420d23867 | ||
|
|
191686c5b3 | ||
|
|
137351fa8d | ||
|
|
db5173697a | ||
|
|
c7940382a9 | ||
|
|
87045ab9c0 | ||
|
|
f1c4086778 | ||
|
|
f337d0acff | ||
|
|
9cc05b30d4 | ||
|
|
e0b68c4d6d | ||
|
|
8e624b4117 | ||
|
|
787a909aa4 | ||
|
|
905485b673 | ||
|
|
99677390e0 | ||
|
|
f2121130f9 | ||
|
|
1a01c34ec4 | ||
|
|
3c46cc9c35 | ||
|
|
e192ec8fee | ||
|
|
8a1aa5753d |
@@ -49,7 +49,7 @@ runs:
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
python --version
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows'
|
||||
@@ -58,5 +58,5 @@ runs:
|
||||
python -m venv venv
|
||||
source ./venv/Scripts/activate
|
||||
python --version
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
|
||||
@@ -70,6 +70,7 @@ async function isStackedPr(github, context) {
|
||||
async function detectMergeBranch(github, context) {
|
||||
const labels = new Set();
|
||||
const baseRef = context.payload.pull_request.base.ref;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
|
||||
if (baseRef === 'release') {
|
||||
labels.add('merging-to-release');
|
||||
@@ -78,7 +79,7 @@ async function detectMergeBranch(github, context) {
|
||||
} else if (await isStackedPr(github, context)) {
|
||||
// GitHub manages the merge order for a stack, so these are not blocked.
|
||||
labels.add('stacked-pr');
|
||||
} else if (baseRef !== 'dev') {
|
||||
} else if (baseRef !== defaultBranch) {
|
||||
// A chain built by hand: it must not merge until its base branch does.
|
||||
labels.add('chained-pr');
|
||||
}
|
||||
|
||||
@@ -43,14 +43,14 @@ const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
|
||||
|
||||
// Builds a fresh context for detectMergeBranch tests instead of mutating the
|
||||
// shared CONTEXT fixture above (which other describe blocks rely on).
|
||||
function makeMergeContext(baseRef, { stack } = {}) {
|
||||
function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) {
|
||||
const pull_request = { number: 1, base: { ref: baseRef } };
|
||||
if (stack !== undefined) {
|
||||
pull_request.stack = stack;
|
||||
}
|
||||
return {
|
||||
repo: { owner: 'esphome', repo: 'esphome' },
|
||||
payload: { pull_request }
|
||||
payload: { pull_request, repository: { default_branch: defaultBranch } }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,6 +136,21 @@ describe('detectMergeBranch', () => {
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
assert.equal(state.calls, 1);
|
||||
});
|
||||
|
||||
it('base ref matches default branch adds no labels', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('other', { defaultBranch: 'other' });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), []);
|
||||
});
|
||||
|
||||
it('base ref dev when the default branch is main adds chained-pr', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('dev', { defaultBranch: 'main' });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -41,32 +41,10 @@ jobs:
|
||||
version: "0.11.15"
|
||||
|
||||
- name: Install apt dependencies
|
||||
# PR-only workflow, so nothing on dev could seed a shared apt cache
|
||||
# entry; the cached apt action would save one copy per PR. Plain apt
|
||||
# with every call bounded: the apt.conf.d timeouts make a dead
|
||||
# mirror fail over in seconds, and timeout runs under sudo so it can
|
||||
# kill apt-get itself. Install without update first: image lists are
|
||||
# fresh, and the index refresh is what a congested mirror makes slow.
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
|
||||
Acquire::Retries "1";
|
||||
Acquire::http::Timeout "15";
|
||||
Acquire::https::Timeout "15";
|
||||
EOF
|
||||
# Common path: the image's package lists are fresh enough.
|
||||
if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \
|
||||
apt-get install -y protobuf-compiler; then
|
||||
protoc --version
|
||||
exit 0
|
||||
fi
|
||||
# Rescue path: refresh the lists once with a generous bound; the
|
||||
# apt config already fails a stalled mirror over quickly.
|
||||
sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \
|
||||
dpkg --configure -a || true
|
||||
sudo timeout -k 15 300 apt-get update
|
||||
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \
|
||||
apt-get install -y protobuf-compiler
|
||||
sudo apt update
|
||||
sudo apt-cache show protobuf-compiler
|
||||
sudo apt install -y protobuf-compiler
|
||||
protoc --version
|
||||
- name: Install python dependencies
|
||||
run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt
|
||||
|
||||
+43
-98
@@ -68,22 +68,6 @@ jobs:
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
|
||||
seed-apt-cache:
|
||||
name: Seed apt package cache
|
||||
runs-on: ubuntu-24.04
|
||||
# PR-branch cache saves are invisible to other PRs, so dev/beta/release
|
||||
# pushes seed the one shared entry PR jobs restore. The key is derived
|
||||
# only from the package list and version; keep both identical in every
|
||||
# step that restores it. In ci-status needs so a broken seed fails dev.
|
||||
if: github.event_name == 'push'
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Install apt packages (cached)
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
|
||||
determine-jobs:
|
||||
name: Determine which jobs to run
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -339,8 +323,7 @@ jobs:
|
||||
|
||||
integration-tests:
|
||||
name: Run integration tests (${{ matrix.bucket.name }})
|
||||
# Must match seed-apt-cache's image: the apt cache key has no OS in it.
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
@@ -352,16 +335,24 @@ jobs:
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install apt packages (cached)
|
||||
# ccache speeds up the host compiles. A cache hit never touches apt
|
||||
# (mirror outages cannot hang the job); the timeout bounds the cold
|
||||
# path. Packages and version must match seed-apt-cache exactly;
|
||||
# libsdl2-dev is unused here and carried only for cache-key parity.
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
- name: Install ccache
|
||||
# Speeds up the host compiles: tests in a bucket compile overlapping
|
||||
# component sets, so later tests reuse earlier tests' objects.
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends ccache
|
||||
- name: Restore ccache (restore-only)
|
||||
# esphome stores the PlatformIO ccache under the machine-global cache
|
||||
# dir (see _ccache_env() in esphome/platformio/toolchain.py). The
|
||||
# bucket-name prefix prefers a same-bucket seed; the bare prefix falls
|
||||
# back to any seed when the bucket layout differs from dev.
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
path: ~/.cache/esphome/platformio-ccache
|
||||
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
integration-ccache-${{ matrix.bucket.name }}-
|
||||
integration-ccache-
|
||||
- name: Set up Python 3.13
|
||||
id: python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
@@ -410,6 +401,14 @@ jobs:
|
||||
# esphome stores the PlatformIO ccache under the machine-global cache
|
||||
# dir (see _ccache_env() in esphome/platformio/toolchain.py).
|
||||
run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s
|
||||
- name: Save ccache
|
||||
# Pull request saves land in per-PR scopes nothing else can reuse;
|
||||
# dev pushes seed the shared copy instead.
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/esphome/platformio-ccache
|
||||
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
|
||||
|
||||
import-time:
|
||||
name: Check import esphome.__main__ time
|
||||
@@ -442,13 +441,16 @@ jobs:
|
||||
benchmarks:
|
||||
name: Run CodSpeed benchmarks
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: >-
|
||||
(github.event_name == 'push' && github.ref_name == 'dev') ||
|
||||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
|
||||
github.repository == 'esphome/esphome' && (
|
||||
(github.event_name == 'push' && github.ref_name == 'dev') ||
|
||||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
|
||||
)
|
||||
# CodSpeed benchmarks require a CodSpeed account linked to the repository to run
|
||||
# (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself.
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -462,58 +464,12 @@ jobs:
|
||||
- name: Build benchmarks
|
||||
id: build
|
||||
run: |
|
||||
# pipefail: without it a failed build is masked by the grep/cut
|
||||
# pipeline below, leaving BINARY empty and silently dropping every
|
||||
# C++ benchmark from the run while the job still reports success.
|
||||
set -o pipefail
|
||||
. venv/bin/activate
|
||||
BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
|
||||
export BENCHMARK_LIB_CONFIG
|
||||
# --build-only prints BUILD_BINARY=<path> to stdout; the grep is
|
||||
# non-fatal so a missing marker reaches the check below instead of
|
||||
# tripping errexit at this assignment
|
||||
BINARY=$(script/cpp_benchmark.py --all --build-only | { grep '^BUILD_BINARY=' || true; } | tail -1 | cut -d= -f2-)
|
||||
if [ -z "$BINARY" ]; then
|
||||
echo "::error::Benchmark build did not report a binary path"
|
||||
exit 1
|
||||
fi
|
||||
export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
|
||||
# --build-only prints BUILD_BINARY=<path> to stdout
|
||||
BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-)
|
||||
echo "binary=$BINARY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Bound apt fetches and pre-install libc6-dbg
|
||||
# The CodSpeed runner installs valgrind + libc6-dbg via its own
|
||||
# unbounded apt-get update; per-invocation apt options cannot reach
|
||||
# it. The apt.conf.d timeouts below bound every later apt call in
|
||||
# this job, the runner's included. Pre-installing libc6-dbg lets the
|
||||
# runner skip apt once its valgrind cache is restored (it checks
|
||||
# ``dpkg -s libc6-dbg``, so the cache action's unregistered restores
|
||||
# would not count). Install without update first: image lists are
|
||||
# fresh, and the index refresh is what a congested mirror makes
|
||||
# slow. Best effort; the job timeout is the last backstop.
|
||||
timeout-minutes: 15
|
||||
continue-on-error: true
|
||||
run: |
|
||||
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
|
||||
Acquire::Retries "1";
|
||||
Acquire::http::Timeout "15";
|
||||
Acquire::https::Timeout "15";
|
||||
EOF
|
||||
if dpkg -s libc6-dbg >/dev/null 2>&1; then
|
||||
echo "libc6-dbg already installed"
|
||||
exit 0
|
||||
fi
|
||||
# Common path: the image's package lists are fresh enough.
|
||||
if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \
|
||||
apt-get install -y libc6-dbg; then
|
||||
exit 0
|
||||
fi
|
||||
# Rescue path: refresh the lists once with a generous bound; the
|
||||
# apt config already fails a stalled mirror over quickly.
|
||||
sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \
|
||||
dpkg --configure -a || true
|
||||
sudo timeout -k 15 300 apt-get update
|
||||
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \
|
||||
apt-get install -y libc6-dbg
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
|
||||
with:
|
||||
@@ -598,29 +554,24 @@ jobs:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Restore Python
|
||||
id: restore-python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
|
||||
# Key on the exact Python version as well: LibreTiny creates a venv under
|
||||
# ~/.platformio/penv whose interpreter is a symlink into the runner's
|
||||
# hosted toolcache, so a cache saved on an older runner image breaks once
|
||||
# a new image ships a newer patch release and drops the old interpreter.
|
||||
- name: Cache platformio
|
||||
if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.platformio
|
||||
key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }}
|
||||
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
|
||||
|
||||
- name: Cache platformio
|
||||
if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.platformio
|
||||
key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }}
|
||||
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
|
||||
|
||||
- name: Cache ESP-IDF install
|
||||
if: matrix.cache_idf
|
||||
@@ -937,17 +888,12 @@ jobs:
|
||||
- name: List components
|
||||
run: echo ${{ matrix.batch.components }}
|
||||
|
||||
- name: Install apt packages (cached)
|
||||
# A cache hit (seeded on dev by seed-apt-cache) never touches apt,
|
||||
# so mirror outages cannot hang this PR-only job; the timeout bounds
|
||||
# the cold path. Packages and version must match seed-apt-cache
|
||||
# exactly. The action has no --no-install-recommends; same package
|
||||
# set this job used before #17463.
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
- name: Install apt packages
|
||||
# Not cached: this job is pull-request-only, so a cache save could
|
||||
# never be shared and would only consume quota.
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends libsdl2-dev ccache
|
||||
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -1482,7 +1428,6 @@ jobs:
|
||||
# this check.
|
||||
needs:
|
||||
- common
|
||||
- seed-apt-cache
|
||||
- determine-jobs
|
||||
- ci-custom
|
||||
- pylint
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.8.0
|
||||
PROJECT_NUMBER = 2026.9.0-dev
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.12.0
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+14
-14
@@ -2732,7 +2732,8 @@ def run_esphome(argv):
|
||||
conf_path.name,
|
||||
)
|
||||
|
||||
if config is None:
|
||||
cache_missed = config is None
|
||||
if cache_missed:
|
||||
from esphome.config import read_config
|
||||
|
||||
config = read_config(
|
||||
@@ -2741,26 +2742,25 @@ def run_esphome(argv):
|
||||
# Snapshot only needed by `esphome config --no-defaults`.
|
||||
snapshot_user_config=getattr(args, "no_defaults", False),
|
||||
)
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config. Skip when the storage
|
||||
# sidecar is absent (no compile has run): the cache would
|
||||
# never be loaded back, so writing secrets to disk is wasted.
|
||||
if cache_eligible and config is not None:
|
||||
from esphome.compiled_config import save_compiled_config
|
||||
from esphome.storage_json import ext_storage_path
|
||||
|
||||
if ext_storage_path(conf_path.name).exists():
|
||||
save_compiled_config(config)
|
||||
if config is None:
|
||||
return 2
|
||||
if config is None:
|
||||
return 2
|
||||
CORE.config = config
|
||||
|
||||
# Fallback for platforms whose validators didn't set the toolchain
|
||||
# (only the esp32 component reads esp32.framework.toolchain). All
|
||||
# other platforms only support PlatformIO today.
|
||||
# other platforms only support PlatformIO today. Must run before the
|
||||
# cache refresh below so its sidecar records the same toolchain a
|
||||
# compile would.
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config.
|
||||
if cache_eligible and cache_missed:
|
||||
from esphome.compiled_config import save_compiled_config_and_sidecar
|
||||
|
||||
save_compiled_config_and_sidecar(config)
|
||||
|
||||
if args.command not in POST_CONFIG_ACTIONS:
|
||||
safe_print(f"Unknown command {args.command}")
|
||||
return 1
|
||||
|
||||
@@ -18,9 +18,9 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from esphome.const import __version__ as ESPHOME_VERSION
|
||||
from esphome.core import CORE, Lambda
|
||||
from esphome.core import CORE, EsphomeError, Lambda
|
||||
from esphome.helpers import write_file
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path, storage_path
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -65,7 +65,71 @@ def save_compiled_config(config: ConfigType) -> None:
|
||||
# non-basic dict key), so every upload/logs pays the slow path.
|
||||
_LOGGER.warning("Cannot cache the validated config: %s", err)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.debug("Skipping compiled config cache write: %s", err)
|
||||
# Likely persistent (permissions, full disk): every upload/logs
|
||||
# pays the slow path until it clears, so surface it.
|
||||
_LOGGER.warning("Skipping compiled config cache write: %s", err)
|
||||
|
||||
|
||||
def save_compiled_config_and_sidecar(config: ConfigType) -> None:
|
||||
"""Refresh the cache from the upload/logs fallback (CORE.config must be set).
|
||||
|
||||
The cache is only written when a complete sidecar is on disk:
|
||||
load_compiled_config can't use it otherwise, and it holds resolved
|
||||
secrets.
|
||||
"""
|
||||
if _refresh_sidecar():
|
||||
save_compiled_config(config)
|
||||
|
||||
|
||||
def _refresh_sidecar() -> bool:
|
||||
"""Ensure a complete sidecar is on disk; True when one is.
|
||||
|
||||
Writes one (without claiming a build) when missing or wizard-only.
|
||||
Failures are non-fatal; the next upload/logs pays the slow path again.
|
||||
"""
|
||||
try:
|
||||
path = storage_path()
|
||||
try:
|
||||
old = StorageJSON.load_strict(path)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Present but unreadable: it may hold a real build's metadata,
|
||||
# and a fresh rewrite would also stop the next compile from
|
||||
# cleaning a possibly incoherent build tree.
|
||||
_LOGGER.warning(
|
||||
"Not caching: storage sidecar %s is unreadable (%s)", path, err
|
||||
)
|
||||
return False
|
||||
if old is not None and old.can_apply_to_core():
|
||||
# Compile-written; nothing to refresh.
|
||||
return True
|
||||
if CORE.build_path is not None and CORE.build_path.exists():
|
||||
# An unvalidated build tree: its absent or mismatched sidecar
|
||||
# is what makes the next compile wipe it, so don't vouch for
|
||||
# a build this run never saw.
|
||||
_LOGGER.warning(
|
||||
"Not caching: build tree %s has no matching sidecar; "
|
||||
"'esphome compile' will settle it",
|
||||
CORE.build_path,
|
||||
)
|
||||
return False
|
||||
new = StorageJSON.from_esphome_core(CORE, old, claim_build=False)
|
||||
if not new.can_apply_to_core():
|
||||
_LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete")
|
||||
return False
|
||||
new.save(path)
|
||||
return True
|
||||
except (OSError, EsphomeError) as err:
|
||||
# write_file wraps OSError into EsphomeError. Persistent
|
||||
# (unwritable storage dir), so surface that every upload/logs
|
||||
# pays the slow path.
|
||||
_LOGGER.warning("Could not refresh the storage sidecar: %s", err)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# A structural bug; keep the traceback so it isn't mistaken
|
||||
# for the I/O failure above.
|
||||
_LOGGER.warning(
|
||||
"Unexpected error refreshing the storage sidecar", exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
@@ -98,11 +162,8 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
return None
|
||||
|
||||
storage = StorageJSON.load(ext_storage_path(conf_path.name))
|
||||
if storage is None:
|
||||
return None
|
||||
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
|
||||
# sidecars leave both of these unset and can't drive upload/logs.
|
||||
if not storage.core_platform and not storage.target_platform:
|
||||
if storage is None or not storage.can_apply_to_core():
|
||||
_LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete")
|
||||
return None
|
||||
storage.apply_to_core()
|
||||
return config
|
||||
|
||||
@@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
# and plaintext disabled. Only a factory reset can remove it.
|
||||
cg.add_define("USE_API_PLAINTEXT")
|
||||
cg.add_define("USE_API_NOISE")
|
||||
cg.add_library("esphome/noise-c", "0.1.21")
|
||||
cg.add_library("esphome/noise-c", "0.1.11")
|
||||
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
|
||||
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
|
||||
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
|
||||
|
||||
@@ -300,12 +300,46 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
|
||||
}
|
||||
|
||||
light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const {
|
||||
const light::ChannelColors &colors = this->channel_colors_;
|
||||
uint8_t *led = this->buf_ + (index * colors.bytes_per_led());
|
||||
return {led + colors.r,
|
||||
led + colors.g,
|
||||
led + colors.b,
|
||||
colors.has_white() ? led + colors.w : nullptr,
|
||||
int32_t r = 0, g = 0, b = 0;
|
||||
switch (this->rgb_order_) {
|
||||
case ORDER_RGB:
|
||||
r = 0;
|
||||
g = 1;
|
||||
b = 2;
|
||||
break;
|
||||
case ORDER_RBG:
|
||||
r = 0;
|
||||
g = 2;
|
||||
b = 1;
|
||||
break;
|
||||
case ORDER_GRB:
|
||||
r = 1;
|
||||
g = 0;
|
||||
b = 2;
|
||||
break;
|
||||
case ORDER_GBR:
|
||||
r = 2;
|
||||
g = 0;
|
||||
b = 1;
|
||||
break;
|
||||
case ORDER_BGR:
|
||||
r = 2;
|
||||
g = 1;
|
||||
b = 0;
|
||||
break;
|
||||
case ORDER_BRG:
|
||||
r = 1;
|
||||
g = 2;
|
||||
b = 0;
|
||||
break;
|
||||
}
|
||||
uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3;
|
||||
uint8_t white = this->is_wrgb_ ? 0 : 3;
|
||||
|
||||
return {this->buf_ + (index * multiplier) + r + this->is_wrgb_,
|
||||
this->buf_ + (index * multiplier) + g + this->is_wrgb_,
|
||||
this->buf_ + (index * multiplier) + b + this->is_wrgb_,
|
||||
this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr,
|
||||
&this->effect_data_[index],
|
||||
&this->correction_};
|
||||
}
|
||||
@@ -315,12 +349,35 @@ void BekenSPILEDStripLightOutput::dump_config() {
|
||||
"Beken SPI LED Strip:\n"
|
||||
" Pin: %u",
|
||||
this->pin_);
|
||||
char channel_colors[5];
|
||||
const char *rgb_order;
|
||||
switch (this->rgb_order_) {
|
||||
case ORDER_RGB:
|
||||
rgb_order = "RGB";
|
||||
break;
|
||||
case ORDER_RBG:
|
||||
rgb_order = "RBG";
|
||||
break;
|
||||
case ORDER_GRB:
|
||||
rgb_order = "GRB";
|
||||
break;
|
||||
case ORDER_GBR:
|
||||
rgb_order = "GBR";
|
||||
break;
|
||||
case ORDER_BGR:
|
||||
rgb_order = "BGR";
|
||||
break;
|
||||
case ORDER_BRG:
|
||||
rgb_order = "BRG";
|
||||
break;
|
||||
default:
|
||||
rgb_order = "UNKNOWN";
|
||||
break;
|
||||
}
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Channel colors: %s\n"
|
||||
" RGB Order: %s\n"
|
||||
" Max refresh rate: %" PRIu32 "\n"
|
||||
" Number of LEDs: %u",
|
||||
this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_);
|
||||
rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_);
|
||||
}
|
||||
|
||||
float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#ifdef USE_BK72XX
|
||||
|
||||
#include "esphome/components/light/addressable_light.h"
|
||||
#include "esphome/components/light/channel_colors.h"
|
||||
#include "esphome/components/light/light_output.h"
|
||||
#include "esphome/core/color.h"
|
||||
#include "esphome/core/component.h"
|
||||
@@ -11,6 +10,15 @@
|
||||
|
||||
namespace esphome::beken_spi_led_strip {
|
||||
|
||||
enum RGBOrder : uint8_t {
|
||||
ORDER_RGB,
|
||||
ORDER_RBG,
|
||||
ORDER_GRB,
|
||||
ORDER_GBR,
|
||||
ORDER_BGR,
|
||||
ORDER_BRG,
|
||||
};
|
||||
|
||||
class BekenSPILEDStripLightOutput final : public light::AddressableLight {
|
||||
public:
|
||||
void setup() override;
|
||||
@@ -20,7 +28,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
|
||||
int32_t size() const override { return this->num_leds_; }
|
||||
light::LightTraits get_traits() override {
|
||||
auto traits = light::LightTraits();
|
||||
if (this->channel_colors_.has_white()) {
|
||||
if (this->is_rgbw_ || this->is_wrgb_) {
|
||||
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
|
||||
} else {
|
||||
traits.set_supported_color_modes({light::ColorMode::RGB});
|
||||
@@ -30,13 +38,16 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
|
||||
|
||||
void set_pin(uint8_t pin) { this->pin_ = pin; }
|
||||
void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; }
|
||||
void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; }
|
||||
void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; }
|
||||
void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; }
|
||||
|
||||
/// Set a maximum refresh rate in µs as some lights do not like being updated too often.
|
||||
void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; }
|
||||
|
||||
void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency);
|
||||
|
||||
void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; }
|
||||
|
||||
void clear_effect_data() override {
|
||||
for (int i = 0; i < this->size(); i++)
|
||||
this->effect_data_[i] = 0;
|
||||
@@ -47,7 +58,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
|
||||
protected:
|
||||
light::ESPColorView get_view_internal(int32_t index) const override;
|
||||
|
||||
size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); }
|
||||
size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); }
|
||||
|
||||
uint8_t *buf_{nullptr};
|
||||
uint8_t *effect_data_{nullptr};
|
||||
@@ -55,11 +66,13 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
|
||||
|
||||
uint8_t pin_;
|
||||
uint16_t num_leds_;
|
||||
bool is_rgbw_;
|
||||
bool is_wrgb_;
|
||||
|
||||
uint32_t spi_frequency_{6666666};
|
||||
uint8_t bit0_{0xE0};
|
||||
uint8_t bit1_{0xFC};
|
||||
light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE};
|
||||
RGBOrder rgb_order_;
|
||||
|
||||
uint32_t last_refresh_{0};
|
||||
optional<uint32_t> max_refresh_rate_{};
|
||||
|
||||
@@ -3,7 +3,6 @@ from dataclasses import dataclass
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import libretiny, light
|
||||
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CHIPSET,
|
||||
@@ -14,7 +13,6 @@ from esphome.const import (
|
||||
CONF_PIN,
|
||||
CONF_RGB_ORDER,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@Mat931"]
|
||||
DEPENDENCIES = ["libretiny"]
|
||||
@@ -24,6 +22,17 @@ BekenSPILEDStripLightOutput = beken_spi_led_strip_ns.class_(
|
||||
"BekenSPILEDStripLightOutput", light.AddressableLight
|
||||
)
|
||||
|
||||
RGBOrder = beken_spi_led_strip_ns.enum("RGBOrder")
|
||||
|
||||
RGB_ORDERS = {
|
||||
"RGB": RGBOrder.ORDER_RGB,
|
||||
"RBG": RGBOrder.ORDER_RBG,
|
||||
"GRB": RGBOrder.ORDER_GRB,
|
||||
"GBR": RGBOrder.ORDER_GBR,
|
||||
"BGR": RGBOrder.ORDER_BGR,
|
||||
"BRG": RGBOrder.ORDER_BRG,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LEDStripTimings:
|
||||
@@ -48,6 +57,8 @@ CHIPSETS = {
|
||||
}
|
||||
|
||||
|
||||
CONF_IS_WRGB = "is_wrgb"
|
||||
|
||||
SUPPORTED_PINS = {
|
||||
libretiny.const.FAMILY_BK7231N: [16],
|
||||
libretiny.const.FAMILY_BK7231T: [16],
|
||||
@@ -68,9 +79,10 @@ def _validate_pin(value):
|
||||
return value
|
||||
|
||||
|
||||
def _validate_num_leds(value: ConfigType) -> ConfigType:
|
||||
# A white channel makes each LED one byte wider, so fewer of them fit in the DMA buffer.
|
||||
max_num_leds = 123 if "W" in value[CONF_CHANNEL_COLORS] else 165 # 127 / 170
|
||||
def _validate_num_leds(value):
|
||||
max_num_leds = 165 # 170
|
||||
if value[CONF_IS_RGBW] or value[CONF_IS_WRGB]:
|
||||
max_num_leds = 123 # 127
|
||||
if value[CONF_NUM_LEDS] > max_num_leds:
|
||||
raise cv.Invalid(
|
||||
f"The maximum number of LEDs for this configuration is {max_num_leds}.",
|
||||
@@ -87,23 +99,18 @@ CONFIG_SCHEMA = cv.All(
|
||||
pins.internal_gpio_output_pin_number, _validate_pin
|
||||
),
|
||||
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors,
|
||||
# Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0
|
||||
cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True),
|
||||
cv.Optional(CONF_IS_RGBW): cv.boolean,
|
||||
cv.Optional(CONF_IS_WRGB): cv.boolean,
|
||||
cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True),
|
||||
cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds,
|
||||
cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True),
|
||||
cv.Optional(CONF_IS_RGBW, default=False): cv.boolean,
|
||||
cv.Optional(CONF_IS_WRGB, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
light.migrate_channel_colors(
|
||||
removed_in="2027.3.0", component="beken_spi_led_strip"
|
||||
),
|
||||
_validate_num_leds,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
|
||||
await light.register_light(var, config)
|
||||
await cg.register_component(var, config)
|
||||
@@ -123,6 +130,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
cg.add(
|
||||
var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS]))
|
||||
)
|
||||
cg.add(var.set_rgb_order(config[CONF_RGB_ORDER]))
|
||||
cg.add(var.set_is_rgbw(config[CONF_IS_RGBW]))
|
||||
cg.add(var.set_is_wrgb(config[CONF_IS_WRGB]))
|
||||
|
||||
@@ -10,7 +10,6 @@ CONF_ACCELEROMETER_RANGE = "accelerometer_range"
|
||||
CONF_B_CONSTANT = "b_constant"
|
||||
CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent"
|
||||
CONF_BYTE_ORDER = "byte_order"
|
||||
CONF_CHANNEL_COLORS = "channel_colors"
|
||||
CONF_CLIMATE_ID = "climate_id"
|
||||
CONF_CO2_EQUIVALENT = "co2_equivalent"
|
||||
CONF_COLOR_DEPTH = "color_depth"
|
||||
@@ -23,7 +22,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr"
|
||||
CONF_GYROSCOPE_RANGE = "gyroscope_range"
|
||||
CONF_IAQ = "iaq"
|
||||
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
|
||||
CONF_IS_WRGB = "is_wrgb"
|
||||
CONF_LABEL = "label"
|
||||
CONF_LIBRETINY = "libretiny"
|
||||
CONF_LOOP = "loop"
|
||||
CONF_NOX_INDEX = "nox_index"
|
||||
@@ -37,6 +36,7 @@ CONF_REQUEST_HEADERS = "request_headers"
|
||||
CONF_ROWS = "rows"
|
||||
CONF_SCAN_PARAMETERS = "scan_parameters"
|
||||
CONF_SHA256 = "sha256"
|
||||
CONF_SLOT = "slot"
|
||||
CONF_STATE_SAVE_INTERVAL = "state_save_interval"
|
||||
CONF_STOP_BITS = "stop_bits"
|
||||
CONF_TARGET_COUNT = "target_count"
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from esphome import automation, core
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_LABEL
|
||||
from esphome.components.number import Number
|
||||
from esphome.components.select import Select
|
||||
from esphome.components.switch import Switch
|
||||
@@ -30,7 +31,6 @@ display_menu_base_ns = cg.esphome_ns.namespace("display_menu_base")
|
||||
|
||||
CONF_ROTARY = "rotary"
|
||||
CONF_JOYSTICK = "joystick"
|
||||
CONF_LABEL = "label"
|
||||
CONF_MENU = "menu"
|
||||
CONF_BACK = "back"
|
||||
CONF_SELECT = "select"
|
||||
|
||||
@@ -570,6 +570,9 @@ def get_download_types(storage_json):
|
||||
the shape stable so the download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"title": "Factory format (Previously Modern)",
|
||||
@@ -1070,26 +1073,6 @@ def _parse_pio_platform_version(value):
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_p4_engineering_sample(value: ConfigType) -> bool:
|
||||
"""Fill in CONF_ENGINEERING_SAMPLE when unset, warning that production
|
||||
silicon (rev3) is assumed. Returns the normalized flag."""
|
||||
if (engineering_sample := value.get(CONF_ENGINEERING_SAMPLE)) is None:
|
||||
_LOGGER.warning(
|
||||
"Defaulting to ESP32-P4 production silicon (rev3).\n"
|
||||
"If you have an early engineering sample (pre-rev3), add this to your config:\n"
|
||||
"\n"
|
||||
" esp32:\n"
|
||||
" engineering_sample: true\n"
|
||||
"\n"
|
||||
"To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n"
|
||||
"Engineering samples will show a revision below v3.0.\n"
|
||||
"The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)."
|
||||
)
|
||||
engineering_sample = False
|
||||
value[CONF_ENGINEERING_SAMPLE] = engineering_sample
|
||||
return engineering_sample
|
||||
|
||||
|
||||
def _detect_variant(value):
|
||||
board = value.get(CONF_BOARD)
|
||||
variant = value.get(CONF_VARIANT)
|
||||
@@ -1102,8 +1085,6 @@ def _detect_variant(value):
|
||||
# name rather than carrying a PIO board name through the IDF build.
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
value = value.copy()
|
||||
if variant == VARIANT_ESP32P4:
|
||||
_normalize_p4_engineering_sample(value)
|
||||
value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower()
|
||||
return value
|
||||
if variant not in STANDARD_BOARDS:
|
||||
@@ -1114,8 +1095,22 @@ def _detect_variant(value):
|
||||
)
|
||||
value = value.copy()
|
||||
value[CONF_BOARD] = STANDARD_BOARDS[variant]
|
||||
if variant == VARIANT_ESP32P4 and _normalize_p4_engineering_sample(value):
|
||||
value[CONF_BOARD] = "esp32-p4-evboard"
|
||||
if variant == VARIANT_ESP32P4:
|
||||
engineering_sample = value.get(CONF_ENGINEERING_SAMPLE)
|
||||
if engineering_sample is None:
|
||||
_LOGGER.warning(
|
||||
"No board specified for ESP32-P4. Defaulting to production silicon (rev3).\n"
|
||||
"If you have an early engineering sample (pre-rev3), add this to your config:\n"
|
||||
"\n"
|
||||
" esp32:\n"
|
||||
" engineering_sample: true\n"
|
||||
"\n"
|
||||
"To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n"
|
||||
"Engineering samples will show a revision below v3.0.\n"
|
||||
"The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)."
|
||||
)
|
||||
elif engineering_sample:
|
||||
value[CONF_BOARD] = "esp32-p4-evboard"
|
||||
elif board in BOARDS:
|
||||
variant = variant or BOARDS[board][KEY_VARIANT]
|
||||
if variant != BOARDS[board][KEY_VARIANT]:
|
||||
@@ -1125,14 +1120,6 @@ def _detect_variant(value):
|
||||
)
|
||||
value = value.copy()
|
||||
value[CONF_VARIANT] = variant
|
||||
if variant == VARIANT_ESP32P4:
|
||||
board_is_es = BOARDS[board].get("engineering_sample", False)
|
||||
engineering_sample = value.setdefault(CONF_ENGINEERING_SAMPLE, board_is_es)
|
||||
if engineering_sample != board_is_es:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{board}'",
|
||||
path=[CONF_ENGINEERING_SAMPLE],
|
||||
)
|
||||
elif not variant:
|
||||
raise cv.Invalid(
|
||||
"This board is unknown, if you are sure you want to compile with this board selection, "
|
||||
@@ -1144,9 +1131,6 @@ def _detect_variant(value):
|
||||
"This board is unknown; the specified variant '%s' will be used but this may not work as expected.",
|
||||
variant,
|
||||
)
|
||||
if variant == VARIANT_ESP32P4:
|
||||
value = value.copy()
|
||||
_normalize_p4_engineering_sample(value)
|
||||
return value
|
||||
|
||||
|
||||
@@ -1450,6 +1434,20 @@ def final_validate(config):
|
||||
path=[CONF_ENGINEERING_SAMPLE],
|
||||
)
|
||||
)
|
||||
if (
|
||||
config[CONF_VARIANT] == VARIANT_ESP32P4
|
||||
and config.get(CONF_ENGINEERING_SAMPLE) is not None
|
||||
):
|
||||
board_is_es = BOARDS.get(config[CONF_BOARD], {}).get(
|
||||
"engineering_sample", False
|
||||
)
|
||||
if config[CONF_ENGINEERING_SAMPLE] != board_is_es:
|
||||
errs.append(
|
||||
cv.Invalid(
|
||||
f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{config[CONF_BOARD]}'",
|
||||
path=[CONF_ENGINEERING_SAMPLE],
|
||||
)
|
||||
)
|
||||
if advanced[CONF_EXECUTE_FROM_PSRAM]:
|
||||
if config[CONF_VARIANT] not in {VARIANT_ESP32S3, VARIANT_ESP32P4}:
|
||||
errs.append(
|
||||
@@ -2522,14 +2520,15 @@ async def to_code(config):
|
||||
f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True
|
||||
)
|
||||
|
||||
# ESP32-P4: pre-v3 and rev3 (v3.0+) silicon are not binary compatible.
|
||||
# CONFIG_ESP32P4_SELECTS_REV_LESS_V3 selects which layout ESP-IDF links;
|
||||
# validation normalizes CONF_ENGINEERING_SAMPLE from the board when unset.
|
||||
# ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3
|
||||
# from y to n. PlatformIO uses sections.ld.in (for rev <3) or
|
||||
# sections.rev3.ld.in (for rev >=3) based on board definition.
|
||||
# Set the sdkconfig option to match the board's chip revision.
|
||||
if variant == VARIANT_ESP32P4:
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_ESP32P4_SELECTS_REV_LESS_V3",
|
||||
config.get(CONF_ENGINEERING_SAMPLE, False),
|
||||
is_eng_sample = BOARDS.get(config[CONF_BOARD], {}).get(
|
||||
"engineering_sample", False
|
||||
)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP32P4_SELECTS_REV_LESS_V3", is_eng_sample)
|
||||
|
||||
# Set minimum chip revision for ESP32 variant
|
||||
# Setting this to 3.0 or higher reduces flash size by excluding workaround code,
|
||||
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
from esphome import pins
|
||||
from esphome.components import esp32
|
||||
from esphome.components.const import CONF_USE_PSRAM
|
||||
from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CLK_PIN,
|
||||
@@ -16,8 +16,10 @@ from esphome.const import (
|
||||
CONF_VARIANT,
|
||||
)
|
||||
from esphome.cpp_generator import add_define
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@swoboda1337"]
|
||||
DEPENDENCIES = ["esp32"]
|
||||
# esp32_ble raises the task watchdog around the remote BT controller bring-up
|
||||
AUTO_LOAD = ["watchdog"]
|
||||
|
||||
@@ -33,7 +35,6 @@ CONF_DATA_READY_PIN = "data_ready_pin"
|
||||
CONF_HANDSHAKE_ACTIVE_HIGH = "handshake_active_high"
|
||||
CONF_HANDSHAKE_PIN = "handshake_pin"
|
||||
CONF_SDIO_FREQUENCY = "sdio_frequency"
|
||||
CONF_SLOT = "slot"
|
||||
CONF_SPI_MODE = "spi_mode"
|
||||
|
||||
# Shared fields for both transport modes
|
||||
@@ -125,6 +126,22 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
# The esp_hosted releases compatible with older ESP-IDF versions crash at
|
||||
# boot with a heap double free in the SDIO RX path (fixed in esp_hosted
|
||||
# 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time.
|
||||
if (idf_ver := esp32.idf_version()) < cv.Version(5, 3, 0):
|
||||
raise cv.Invalid(
|
||||
f"esp32_hosted requires ESP-IDF 5.3 or newer, got {idf_ver}. "
|
||||
"Remove the framework version from your configuration to use the "
|
||||
"recommended version, or pin a version at or above 5.3."
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def _configure_sdio(config):
|
||||
slot = config[CONF_SLOT]
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
@@ -252,18 +269,14 @@ async def to_code(config):
|
||||
if config[CONF_USE_PSRAM]:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True)
|
||||
|
||||
# Library versions
|
||||
# Library versions; this component set requires ESP-IDF 5.3 or newer,
|
||||
# which is enforced at validation time.
|
||||
idf_ver = esp32.idf_version()
|
||||
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
|
||||
if idf_ver >= cv.Version(5, 5, 0):
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3")
|
||||
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12")
|
||||
else:
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.0.11")
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3")
|
||||
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12")
|
||||
esp32.add_extra_script(
|
||||
"post",
|
||||
"esp32_hosted.py",
|
||||
|
||||
@@ -221,12 +221,46 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) {
|
||||
}
|
||||
|
||||
light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index) const {
|
||||
const light::ChannelColors &colors = this->channel_colors_;
|
||||
uint8_t *led = this->buf_ + (index * colors.bytes_per_led());
|
||||
return {led + colors.r,
|
||||
led + colors.g,
|
||||
led + colors.b,
|
||||
colors.has_white() ? led + colors.w : nullptr,
|
||||
int32_t r = 0, g = 0, b = 0;
|
||||
switch (this->rgb_order_) {
|
||||
case ORDER_RGB:
|
||||
r = 0;
|
||||
g = 1;
|
||||
b = 2;
|
||||
break;
|
||||
case ORDER_RBG:
|
||||
r = 0;
|
||||
g = 2;
|
||||
b = 1;
|
||||
break;
|
||||
case ORDER_GRB:
|
||||
r = 1;
|
||||
g = 0;
|
||||
b = 2;
|
||||
break;
|
||||
case ORDER_GBR:
|
||||
r = 2;
|
||||
g = 0;
|
||||
b = 1;
|
||||
break;
|
||||
case ORDER_BGR:
|
||||
r = 2;
|
||||
g = 1;
|
||||
b = 0;
|
||||
break;
|
||||
case ORDER_BRG:
|
||||
r = 1;
|
||||
g = 2;
|
||||
b = 0;
|
||||
break;
|
||||
}
|
||||
uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3;
|
||||
uint8_t white = this->is_wrgb_ ? 0 : this->white_index_;
|
||||
|
||||
return {this->buf_ + (index * multiplier) + r + (white <= r),
|
||||
this->buf_ + (index * multiplier) + g + (white <= g),
|
||||
this->buf_ + (index * multiplier) + b + (white <= b),
|
||||
this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr,
|
||||
&this->effect_data_[index],
|
||||
&this->correction_};
|
||||
}
|
||||
@@ -237,12 +271,46 @@ void ESP32RMTLEDStripLightOutput::dump_config() {
|
||||
" Pin: %u",
|
||||
this->pin_);
|
||||
ESP_LOGCONFIG(TAG, " RMT Symbols: %" PRIu32, this->rmt_symbols_);
|
||||
char channel_colors[5];
|
||||
const char *rgb_order;
|
||||
switch (this->rgb_order_) {
|
||||
case ORDER_RGB:
|
||||
rgb_order = "RGB";
|
||||
break;
|
||||
case ORDER_RBG:
|
||||
rgb_order = "RBG";
|
||||
break;
|
||||
case ORDER_GRB:
|
||||
rgb_order = "GRB";
|
||||
break;
|
||||
case ORDER_GBR:
|
||||
rgb_order = "GBR";
|
||||
break;
|
||||
case ORDER_BGR:
|
||||
rgb_order = "BGR";
|
||||
break;
|
||||
case ORDER_BRG:
|
||||
rgb_order = "BRG";
|
||||
break;
|
||||
default:
|
||||
rgb_order = "UNKNOWN";
|
||||
break;
|
||||
}
|
||||
if (this->is_rgbw_ || this->is_wrgb_) {
|
||||
char rgbw_order[5];
|
||||
uint8_t white = this->is_wrgb_ ? 0 : this->white_index_;
|
||||
uint8_t rgb_index = 0;
|
||||
for (uint8_t i = 0; i < 4; i++) {
|
||||
rgbw_order[i] = i == white ? 'W' : rgb_order[rgb_index++];
|
||||
}
|
||||
rgbw_order[4] = '\0';
|
||||
ESP_LOGCONFIG(TAG, " RGBW Order: %s", rgbw_order);
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " RGB Order: %s", rgb_order);
|
||||
}
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Channel colors: %s\n"
|
||||
" Max refresh rate: %" PRIu32 "\n"
|
||||
" Number of LEDs: %u",
|
||||
this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_);
|
||||
this->max_refresh_rate_.value_or(0), this->num_leds_);
|
||||
}
|
||||
|
||||
float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/components/light/addressable_light.h"
|
||||
#include "esphome/components/light/channel_colors.h"
|
||||
#include "esphome/components/light/light_output.h"
|
||||
#include "esphome/core/color.h"
|
||||
#include "esphome/core/component.h"
|
||||
@@ -16,6 +15,15 @@
|
||||
|
||||
namespace esphome::esp32_rmt_led_strip {
|
||||
|
||||
enum RGBOrder : uint8_t {
|
||||
ORDER_RGB,
|
||||
ORDER_RBG,
|
||||
ORDER_GRB,
|
||||
ORDER_GBR,
|
||||
ORDER_BGR,
|
||||
ORDER_BRG,
|
||||
};
|
||||
|
||||
struct LedParams {
|
||||
rmt_symbol_word_t bit0;
|
||||
rmt_symbol_word_t bit1;
|
||||
@@ -31,7 +39,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
|
||||
int32_t size() const override { return this->num_leds_; }
|
||||
light::LightTraits get_traits() override {
|
||||
auto traits = light::LightTraits();
|
||||
if (this->channel_colors_.has_white()) {
|
||||
if (this->is_rgbw_ || this->is_wrgb_) {
|
||||
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
|
||||
} else {
|
||||
traits.set_supported_color_modes({light::ColorMode::RGB});
|
||||
@@ -42,7 +50,13 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
|
||||
void set_pin(uint8_t pin) { this->pin_ = pin; }
|
||||
void set_inverted(bool inverted) { this->invert_out_ = inverted; }
|
||||
void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; }
|
||||
void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; }
|
||||
void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; }
|
||||
void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; }
|
||||
void set_rgbw_order(uint8_t white_index) {
|
||||
this->is_rgbw_ = true;
|
||||
this->is_wrgb_ = false;
|
||||
this->white_index_ = white_index;
|
||||
}
|
||||
void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; }
|
||||
void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; }
|
||||
|
||||
@@ -52,6 +66,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
|
||||
void set_led_params(uint32_t bit0_high, uint32_t bit0_low, uint32_t bit1_high, uint32_t bit1_low,
|
||||
uint32_t reset_time_high, uint32_t reset_time_low);
|
||||
|
||||
void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; }
|
||||
void set_rmt_symbols(uint32_t rmt_symbols) { this->rmt_symbols_ = rmt_symbols; }
|
||||
|
||||
void clear_effect_data() override {
|
||||
@@ -64,7 +79,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
|
||||
protected:
|
||||
light::ESPColorView get_view_internal(int32_t index) const override;
|
||||
|
||||
size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); }
|
||||
size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); }
|
||||
|
||||
uint8_t *buf_{nullptr};
|
||||
uint8_t *effect_data_{nullptr};
|
||||
@@ -79,11 +94,15 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
|
||||
uint32_t rmt_symbols_{48};
|
||||
uint8_t pin_;
|
||||
uint16_t num_leds_;
|
||||
bool is_rgbw_{false};
|
||||
bool is_wrgb_{false};
|
||||
// An index after the RGB channels makes offset adjustment a no-op for three-channel strips.
|
||||
uint8_t white_index_{3};
|
||||
bool use_dma_{false};
|
||||
bool use_psram_{false};
|
||||
bool invert_out_{false};
|
||||
|
||||
light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE};
|
||||
RGBOrder rgb_order_{ORDER_RGB};
|
||||
|
||||
uint32_t last_refresh_{0};
|
||||
optional<uint32_t> max_refresh_rate_{};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, esp32_rmt, light
|
||||
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB, CONF_USE_PSRAM
|
||||
from esphome.components.const import CONF_USE_PSRAM
|
||||
from esphome.components.esp32 import include_builtin_idf_component
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -21,6 +22,8 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@jesserockz"]
|
||||
DEPENDENCIES = ["esp32"]
|
||||
|
||||
@@ -29,6 +32,17 @@ ESP32RMTLEDStripLightOutput = esp32_rmt_led_strip_ns.class_(
|
||||
"ESP32RMTLEDStripLightOutput", light.AddressableLight
|
||||
)
|
||||
|
||||
RGBOrder = esp32_rmt_led_strip_ns.enum("RGBOrder")
|
||||
|
||||
RGB_ORDERS = {
|
||||
"RGB": RGBOrder.ORDER_RGB,
|
||||
"RBG": RGBOrder.ORDER_RBG,
|
||||
"GRB": RGBOrder.ORDER_GRB,
|
||||
"GBR": RGBOrder.ORDER_GBR,
|
||||
"BGR": RGBOrder.ORDER_BGR,
|
||||
"BRG": RGBOrder.ORDER_BRG,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LEDStripTimings:
|
||||
@@ -48,6 +62,8 @@ CHIPSETS = {
|
||||
"SM16703": LEDStripTimings(300, 900, 900, 300, 0, 0),
|
||||
}
|
||||
|
||||
CONF_IS_WRGB = "is_wrgb"
|
||||
CONF_RGBW_ORDER = "rgbw_order"
|
||||
CONF_BIT0_HIGH = "bit0_high"
|
||||
CONF_BIT0_LOW = "bit0_low"
|
||||
CONF_BIT1_HIGH = "bit1_high"
|
||||
@@ -56,6 +72,26 @@ CONF_RESET_HIGH = "reset_high"
|
||||
CONF_RESET_LOW = "reset_low"
|
||||
|
||||
|
||||
def _validate_rgbw_order(value: str) -> str:
|
||||
value = cv.string(value).upper()
|
||||
if len(value) != 4 or set(value) != set("RGBW"):
|
||||
raise cv.Invalid("RGBW order must be a permutation of RGBW")
|
||||
return value
|
||||
|
||||
|
||||
def _split_rgbw_order(rgbw_order: str) -> tuple[str, int]:
|
||||
return rgbw_order.replace("W", ""), rgbw_order.index("W")
|
||||
|
||||
|
||||
def _validate_rgbw_order_exclusivity(config: ConfigType) -> ConfigType:
|
||||
if CONF_RGBW_ORDER in config and (config[CONF_IS_RGBW] or config[CONF_IS_WRGB]):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_RGBW_ORDER}' cannot be used with '{CONF_IS_RGBW}' or "
|
||||
f"'{CONF_IS_WRGB}'"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
esp32.only_on_variant(
|
||||
unsupported=list(esp32_rmt.VARIANTS_NO_RMT),
|
||||
@@ -66,11 +102,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput),
|
||||
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema,
|
||||
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors,
|
||||
# Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0
|
||||
cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True),
|
||||
cv.Optional(CONF_IS_RGBW): cv.boolean,
|
||||
cv.Optional(CONF_IS_WRGB): cv.boolean,
|
||||
cv.Optional(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True),
|
||||
cv.Optional(CONF_RGBW_ORDER): _validate_rgbw_order,
|
||||
cv.SplitDefault(
|
||||
CONF_RMT_SYMBOLS,
|
||||
esp32=192,
|
||||
@@ -84,6 +117,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
): cv.int_range(min=2),
|
||||
cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds,
|
||||
cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True),
|
||||
cv.Optional(CONF_IS_RGBW, default=False): cv.boolean,
|
||||
cv.Optional(CONF_IS_WRGB, default=False): cv.boolean,
|
||||
cv.Optional(CONF_USE_DMA): cv.All(
|
||||
esp32.only_on_variant(
|
||||
supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3]
|
||||
@@ -118,13 +153,12 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH),
|
||||
light.migrate_channel_colors(
|
||||
removed_in="2027.3.0", component="esp32_rmt_led_strip"
|
||||
),
|
||||
cv.has_exactly_one_key(CONF_RGB_ORDER, CONF_RGBW_ORDER),
|
||||
_validate_rgbw_order_exclusivity,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
# Re-enable ESP-IDF's RMT driver (excluded by default to save compile time)
|
||||
include_builtin_idf_component("esp_driver_rmt")
|
||||
|
||||
@@ -164,9 +198,14 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
cg.add(
|
||||
var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS]))
|
||||
)
|
||||
if (rgbw_order := config.get(CONF_RGBW_ORDER)) is not None:
|
||||
rgb_order, white_index = _split_rgbw_order(rgbw_order)
|
||||
cg.add(var.set_rgb_order(RGB_ORDERS[rgb_order]))
|
||||
cg.add(var.set_rgbw_order(white_index))
|
||||
else:
|
||||
cg.add(var.set_rgb_order(config[CONF_RGB_ORDER]))
|
||||
cg.add(var.set_is_rgbw(config[CONF_IS_RGBW]))
|
||||
cg.add(var.set_is_wrgb(config[CONF_IS_WRGB]))
|
||||
cg.add(var.set_use_psram(config[CONF_USE_PSRAM]))
|
||||
cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS]))
|
||||
if CONF_USE_DMA in config:
|
||||
|
||||
@@ -113,6 +113,9 @@ def get_download_types(storage_json):
|
||||
the shape stable so the download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"title": "Standard format",
|
||||
|
||||
@@ -355,7 +355,7 @@ def _validate(config):
|
||||
" clk:\n"
|
||||
" mode: %s\n"
|
||||
" pin: %s\n"
|
||||
"Removal scheduled for 2026.9.0.",
|
||||
"Removal scheduled for 2026.11.0.",
|
||||
config[CONF_CLK_MODE],
|
||||
mode,
|
||||
pin,
|
||||
|
||||
@@ -23,7 +23,6 @@ from esphome.components.image import (
|
||||
get_image_type_enum,
|
||||
get_transparency_enum,
|
||||
is_svg_file,
|
||||
validate_byte_order,
|
||||
validate_settings,
|
||||
validate_transparency,
|
||||
validate_type,
|
||||
@@ -201,7 +200,7 @@ OPTIONS_SCHEMA = {
|
||||
"NONE", "FLOYDSTEINBERG", upper=True
|
||||
),
|
||||
cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean,
|
||||
cv.Optional(CONF_BYTE_ORDER): validate_byte_order,
|
||||
cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True),
|
||||
cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
from esphome import pins
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ALLOW_OTHER_USES, CONF_INTERRUPT_PIN, CONF_INVERTED
|
||||
from esphome.types import ConfigType
|
||||
|
||||
|
||||
def validate_interrupt_pin(value: ConfigType) -> ConfigType:
|
||||
# The expander components own INT polarity (active-low, hardcoded falling-edge ISR)
|
||||
# and install a single ISR per GPIO, so neither inversion nor sharing is supported.
|
||||
value = pins.internal_gpio_input_pin_schema(value)
|
||||
if value.get(CONF_INVERTED):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; "
|
||||
"the expander INT line is fixed active-low"
|
||||
)
|
||||
if value.get(CONF_ALLOW_OTHER_USES):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; "
|
||||
"sharing the interrupt pin between multiple components is not implemented. "
|
||||
f"Remove the '{CONF_INTERRUPT_PIN}' to fall back to polling."
|
||||
)
|
||||
return value
|
||||
|
||||
@@ -10,14 +10,7 @@ from PIL import Image, UnidentifiedImageError
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DEFAULTS,
|
||||
CONF_FILE,
|
||||
CONF_FILES,
|
||||
CONF_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_TYPE,
|
||||
)
|
||||
from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -55,9 +48,6 @@ TRANSPARENCY_TYPES = (
|
||||
CONF_ALPHA_CHANNEL,
|
||||
)
|
||||
|
||||
# Shared validator for the image platform schemas and `_drop_incompatible_byte_order`.
|
||||
validate_byte_order = cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True)
|
||||
|
||||
|
||||
def get_image_type_enum(type):
|
||||
return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}")
|
||||
@@ -414,120 +404,6 @@ def get_image_metadata(image_id: str) -> ImageMetaData | None:
|
||||
return get_all_image_metadata().get(image_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# `defaults:`/`files:` expansion: a `platform:` entry merges shared `defaults:`
|
||||
# into every `files:` entry; the platform's CONFIG_SCHEMA validates each.
|
||||
# Permanent, unlike the legacy migration below.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _drop_incompatible_byte_order(
|
||||
merged: dict, explicit: dict, *, index: int | None = None
|
||||
) -> dict:
|
||||
"""Drop `byte_order` when the resolved type doesn't support it, unless written directly on `explicit`.
|
||||
|
||||
With `index`, inherited values are validated before being dropped (the legacy flattener always drops).
|
||||
"""
|
||||
if CONF_BYTE_ORDER in explicit:
|
||||
return merged
|
||||
type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper())
|
||||
if (
|
||||
CONF_BYTE_ORDER in merged
|
||||
and isinstance(type_class, type)
|
||||
and issubclass(type_class, ImageEncoder)
|
||||
and not type_class.is_endian()
|
||||
):
|
||||
if index is not None:
|
||||
try:
|
||||
validate_byte_order(merged[CONF_BYTE_ORDER])
|
||||
except cv.Invalid as exc:
|
||||
exc.prepend([index])
|
||||
raise
|
||||
del merged[CONF_BYTE_ORDER]
|
||||
return merged
|
||||
|
||||
|
||||
def _expand_platform_entry(index: int, entry: dict) -> list[dict]:
|
||||
if CONF_FILES not in entry:
|
||||
if CONF_DEFAULTS in entry:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_DEFAULTS}' may only be used together with '{CONF_FILES}'",
|
||||
path=[index],
|
||||
)
|
||||
return [entry]
|
||||
|
||||
extra_keys = set(entry) - {CONF_PLATFORM, CONF_DEFAULTS, CONF_FILES}
|
||||
if extra_keys:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_FILES}' cannot be combined with "
|
||||
f"{', '.join(sorted(extra_keys))} on the same entry",
|
||||
path=[index],
|
||||
)
|
||||
|
||||
files = entry[CONF_FILES]
|
||||
if files is None:
|
||||
raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index])
|
||||
if not isinstance(files, list):
|
||||
raise cv.Invalid(f"'{CONF_FILES}' must be a list", path=[index])
|
||||
if not files:
|
||||
raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index])
|
||||
|
||||
defaults = entry.get(CONF_DEFAULTS, {})
|
||||
if defaults is None:
|
||||
defaults = {}
|
||||
if not isinstance(defaults, dict):
|
||||
raise cv.Invalid(f"'{CONF_DEFAULTS}' must be a mapping", path=[index])
|
||||
# Neither `id:` nor `platform:` makes sense inside `defaults:`.
|
||||
for disallowed in (CONF_ID, CONF_PLATFORM):
|
||||
if disallowed in defaults:
|
||||
raise cv.Invalid(
|
||||
f"'{disallowed}' is not allowed inside '{CONF_DEFAULTS}'",
|
||||
path=[index],
|
||||
)
|
||||
|
||||
from esphome import yaml_util
|
||||
|
||||
platform = entry[CONF_PLATFORM]
|
||||
result: list[dict] = []
|
||||
for file_entry in files:
|
||||
if not isinstance(file_entry, dict):
|
||||
raise cv.Invalid(
|
||||
f"each entry in '{CONF_FILES}' must be a mapping", path=[index]
|
||||
)
|
||||
# The platform is chosen by the entry's own `platform:` key, not per file.
|
||||
if CONF_PLATFORM in file_entry:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_PLATFORM}' is not allowed inside '{CONF_FILES}'",
|
||||
path=[index],
|
||||
)
|
||||
# Keep the `files:` item's source range so whole-entry errors anchor there;
|
||||
# `make_data_base` needs a real ESPHomeDataBase, so skip it for plain dicts.
|
||||
source = (
|
||||
file_entry if isinstance(file_entry, yaml_util.ESPHomeDataBase) else None
|
||||
)
|
||||
merged = yaml_util.make_data_base(
|
||||
{CONF_PLATFORM: platform, **defaults, **file_entry}, source
|
||||
)
|
||||
result.append(_drop_incompatible_byte_order(merged, file_entry, index=index))
|
||||
return result
|
||||
|
||||
|
||||
def expand_platform_config(config: list) -> list:
|
||||
"""Expand `defaults:`/`files:` entries; the platform's own CONFIG_SCHEMA validates each result."""
|
||||
result = []
|
||||
for i, entry in enumerate(config):
|
||||
if isinstance(entry, dict) and CONF_PLATFORM in entry:
|
||||
result.extend(_expand_platform_entry(i, entry))
|
||||
else:
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
EXPAND_PLATFORM_CONFIG = expand_platform_config
|
||||
|
||||
# --------------------- end defaults/files expansion -------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy top-level component -> `image:` platform deprecation helpers
|
||||
# -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims.
|
||||
@@ -620,17 +496,11 @@ def _is_legacy_image_format(config: object) -> bool:
|
||||
proper error instead of the migration silently dropping the input.
|
||||
"""
|
||||
if isinstance(config, list):
|
||||
# Exclude `files:` entries -- the list branch would otherwise silently
|
||||
# migrate them to `platform: file` instead of raising the missing-platform error.
|
||||
# A bare list of (not-yet-platform-tagged) image dicts.
|
||||
return bool(config) and all(
|
||||
isinstance(entry, dict)
|
||||
and CONF_PLATFORM not in entry
|
||||
and CONF_FILES not in entry
|
||||
for entry in config
|
||||
isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config
|
||||
)
|
||||
if not isinstance(config, dict) or CONF_PLATFORM in config or CONF_FILES in config:
|
||||
# `platform:`/`files:` dicts are new-format (left for list-wrapping +
|
||||
# expansion); the legacy flattener has no `files:` branch and would drop them.
|
||||
if not isinstance(config, dict):
|
||||
return False
|
||||
# A single image dict, or the grouped `defaults:`/`images:`/type-key form.
|
||||
return (
|
||||
@@ -662,8 +532,18 @@ def _flatten_legacy_image_config(config: object) -> list[dict]:
|
||||
|
||||
def _add(entry: dict, extra: dict) -> None:
|
||||
merged = {**defaults, **extra, **entry}
|
||||
# Always drop, matching the pre-platform behavior -- see `_drop_incompatible_byte_order`.
|
||||
result.append(_drop_incompatible_byte_order(merged, {}))
|
||||
# The legacy `defaults:`/type-grouped forms only applied `byte_order` to
|
||||
# types that support it. Replicate that so an endian default merged into
|
||||
# e.g. a binary image stays valid.
|
||||
type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper())
|
||||
if (
|
||||
CONF_BYTE_ORDER in merged
|
||||
and isinstance(type_class, type)
|
||||
and issubclass(type_class, ImageEncoder)
|
||||
and not type_class.is_endian()
|
||||
):
|
||||
del merged[CONF_BYTE_ORDER]
|
||||
result.append(merged)
|
||||
|
||||
def _add_entries(entries: object, extra: dict) -> None:
|
||||
# `entries` may be a single image dict or a list of them; non-dict
|
||||
|
||||
@@ -64,6 +64,22 @@ static const char *const TAG = "ld2420";
|
||||
|
||||
// Local const's
|
||||
static constexpr uint16_t REFRESH_RATE_MS = 1000;
|
||||
static constexpr uint32_t CMD_ACK_TIMEOUT_MS = 1000;
|
||||
static constexpr uint8_t CMD_MAX_RETRIES = 3;
|
||||
|
||||
// Startup state machine timing. The module starts transmitting ~3.5 s after a
|
||||
// power cycle; the first listen window is roughly three times that to be
|
||||
// safe, and the shorter retry window still stays above the boot silence in
|
||||
// case the module reset itself between attempts.
|
||||
static constexpr uint32_t STARTUP_LISTEN_TIMEOUT_MS = 10000;
|
||||
static constexpr uint32_t STARTUP_RETRY_LISTEN_MS = 5000;
|
||||
static constexpr uint32_t STARTUP_LISTEN_SETTLE_MS = 500;
|
||||
static constexpr uint8_t STARTUP_SEQUENCE_MAX_RETRIES = 3;
|
||||
// Minimum reply data lengths for the startup reads: the limits read returns
|
||||
// three values and each gate read returns two, four bytes each plus the four
|
||||
// status bytes counted in the reply length field
|
||||
static constexpr uint8_t REPLY_MIN_LEN_LIMITS = 16;
|
||||
static constexpr uint8_t REPLY_MIN_LEN_GATE = 12;
|
||||
|
||||
// Command sets
|
||||
static constexpr uint16_t CMD_DISABLE_CONF = 0x00FE;
|
||||
@@ -185,10 +201,13 @@ static int32_t get_firmware_int(const char *version_string) {
|
||||
}
|
||||
|
||||
void LD2420Component::dump_config() {
|
||||
// Setup no longer blocks, so the config dump usually runs before the
|
||||
// version is read; do not present the "v0.0.0" placeholder as real
|
||||
const int32_t firmware = ld2420::get_firmware_int(this->firmware_ver_);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"LD2420:\n"
|
||||
" Firmware version: %7s",
|
||||
this->firmware_ver_);
|
||||
firmware > 0 ? this->firmware_ver_ : "unknown");
|
||||
#ifdef USE_NUMBER
|
||||
ESP_LOGCONFIG(TAG, "Number:");
|
||||
LOG_NUMBER(" ", "Gate Timeout:", this->gate_timeout_number_);
|
||||
@@ -210,60 +229,318 @@ void LD2420Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Select:");
|
||||
LOG_SELECT(" ", "Operating Mode", this->operating_selector_);
|
||||
#endif
|
||||
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
|
||||
if (firmware > 0 && firmware < CALIBRATE_VERSION_MIN) {
|
||||
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
|
||||
}
|
||||
}
|
||||
|
||||
void LD2420Component::setup() {
|
||||
if (this->set_config_mode(true) == LD2420_ERROR_TIMEOUT) {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
this->mark_failed();
|
||||
void LD2420Component::setup() { this->begin_startup_(); }
|
||||
|
||||
void LD2420Component::begin_startup_() {
|
||||
// Default to energy mode so the stream parser can frame data from a module
|
||||
// that kept streaming across a soft restart, before the mode is negotiated.
|
||||
this->system_mode_ = CMD_SYSTEM_MODE_ENERGY;
|
||||
this->startup_sequence_retries_ = 0;
|
||||
this->config_read_complete_ = false;
|
||||
this->begin_listen_();
|
||||
}
|
||||
|
||||
void LD2420Component::begin_listen_() {
|
||||
this->phase_start_ms_ = millis();
|
||||
this->startup_state_ = StartupState::STARTUP_STATE_LISTEN_SETTLE;
|
||||
}
|
||||
|
||||
void LD2420Component::drain_rx_() {
|
||||
uint8_t buf[MAX_LINE_LENGTH];
|
||||
size_t avail;
|
||||
while ((avail = this->available()) > 0) {
|
||||
if (!this->read_array(buf, std::min(avail, sizeof(buf)))) {
|
||||
ESP_LOGV(TAG, "Failed to drain the receive buffer");
|
||||
break;
|
||||
}
|
||||
}
|
||||
this->buffer_pos_ = 0;
|
||||
}
|
||||
|
||||
// Builds the command frame for the current startup state; returns false when
|
||||
// the state has no associated command
|
||||
bool LD2420Component::build_startup_frame_(CmdFrameT &frame) {
|
||||
switch (this->startup_state_) {
|
||||
case StartupState::STARTUP_STATE_ENTER_CONFIG:
|
||||
this->build_config_mode_frame_(frame, true);
|
||||
return true;
|
||||
case StartupState::STARTUP_STATE_READ_LIMITS:
|
||||
this->build_min_max_timeout_frame_(frame);
|
||||
return true;
|
||||
case StartupState::STARTUP_STATE_READ_VERSION:
|
||||
this->build_version_frame_(frame);
|
||||
return true;
|
||||
case StartupState::STARTUP_STATE_READ_GATES:
|
||||
this->build_gate_threshold_frame_(frame, this->startup_gate_);
|
||||
return true;
|
||||
case StartupState::STARTUP_STATE_SET_MODE:
|
||||
this->build_system_mode_frame_(frame, this->startup_target_mode_);
|
||||
return true;
|
||||
case StartupState::STARTUP_STATE_EXIT_CONFIG:
|
||||
this->build_config_mode_frame_(frame, false);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void LD2420Component::send_startup_cmd_() {
|
||||
CmdFrameT frame;
|
||||
if (!this->build_startup_frame_(frame)) {
|
||||
// Programming error: a command state without a frame would otherwise look
|
||||
// exactly like a module timeout
|
||||
ESP_LOGE(TAG, "No command frame for startup state %u", (unsigned) this->startup_state_);
|
||||
return;
|
||||
}
|
||||
this->get_min_max_distances_timeout_();
|
||||
#ifdef USE_NUMBER
|
||||
this->init_gate_config_numbers();
|
||||
#endif
|
||||
this->get_firmware_version_();
|
||||
const char *pfw = this->firmware_ver_;
|
||||
std::string fw_str(pfw);
|
||||
// Discard anything still buffered (including a late reply to a previous
|
||||
// send of the same command) so a stale ack cannot be matched to this one.
|
||||
// READ_LIMITS and all gate reads share the same command byte, so a late
|
||||
// reply accepted for the wrong request would shift every following gate's
|
||||
// thresholds by one.
|
||||
this->drain_rx_();
|
||||
this->startup_cmd_ = (uint8_t) frame.command;
|
||||
this->cmd_reply_.ack = false;
|
||||
this->cmd_reply_.error = 0;
|
||||
// A short reply acks without filling every data word; zero them so stale
|
||||
// values from the previous command cannot be stored as this command's data
|
||||
memset(this->cmd_reply_.data, 0, sizeof(this->cmd_reply_.data));
|
||||
this->write_cmd_frame_(frame);
|
||||
this->phase_start_ms_ = millis();
|
||||
}
|
||||
|
||||
for (auto &listener : this->listeners_) {
|
||||
listener->on_fw_version(fw_str);
|
||||
void LD2420Component::start_startup_cmd_(StartupState state) {
|
||||
this->startup_state_ = state;
|
||||
this->startup_cmd_attempts_ = 1;
|
||||
this->send_startup_cmd_();
|
||||
}
|
||||
|
||||
// Common ack handling for the startup commands: returns true once the reply to
|
||||
// the current startup frame arrived; resends on timeout, and after too many
|
||||
// failed sends either restarts the whole sequence or gives up with a warning.
|
||||
// A reply shorter than min_data_len is treated like silence so a truncated
|
||||
// read cannot be stored as zeroed configuration.
|
||||
bool LD2420Component::startup_ack_check_(uint8_t min_data_len) {
|
||||
if (this->cmd_reply_.ack && this->cmd_reply_.command == this->startup_cmd_ &&
|
||||
this->cmd_reply_.length >= min_data_len) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (uint8_t gate = 0; gate < TOTAL_GATES; gate++) {
|
||||
delay_microseconds_safe(125);
|
||||
this->get_gate_threshold_(gate);
|
||||
if (this->cmd_reply_.error > 0) {
|
||||
// The module explicitly rejected the command; log why instead of letting
|
||||
// it look like silence. The normal retry cadence still applies.
|
||||
this->handle_cmd_error(this->cmd_reply_.error);
|
||||
this->cmd_reply_.error = 0;
|
||||
}
|
||||
if (millis() - this->phase_start_ms_ <= CMD_ACK_TIMEOUT_MS) {
|
||||
return false;
|
||||
}
|
||||
if (this->startup_cmd_attempts_ < CMD_MAX_RETRIES) {
|
||||
this->startup_cmd_attempts_++;
|
||||
ESP_LOGV(TAG, "No reply to startup command %2X; resending", this->startup_cmd_);
|
||||
this->send_startup_cmd_();
|
||||
return false;
|
||||
}
|
||||
this->abort_startup_cmd_();
|
||||
if (this->startup_sequence_retries_ < STARTUP_SEQUENCE_MAX_RETRIES) {
|
||||
this->startup_sequence_retries_++;
|
||||
ESP_LOGW(TAG, "Module setup attempt %u failed; retrying", this->startup_sequence_retries_);
|
||||
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
|
||||
this->begin_listen_();
|
||||
return false;
|
||||
}
|
||||
this->abandon_startup_();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Gives up on configuration but keeps parsing the stream; a module that is
|
||||
// still streaming keeps publishing sensor data even without a config read.
|
||||
void LD2420Component::abandon_startup_() {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
if (ld2420::get_firmware_int(this->firmware_ver_) == 0) {
|
||||
// Old firmware streams text frames that are only parsed in simple mode;
|
||||
// without a version read the mode was never negotiated, so such a module
|
||||
// will not publish sensor data either.
|
||||
ESP_LOGE(TAG, "Firmware version and operating mode were never read");
|
||||
} else if (this->startup_state_ == StartupState::STARTUP_STATE_SET_MODE) {
|
||||
ESP_LOGE(TAG, "Operating mode write was not acknowledged; sensor data may not be parsed");
|
||||
}
|
||||
// Keep the editable config in sync with what was actually read so a later
|
||||
// Apply Config cannot write values that were never read from the module
|
||||
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_STRING);
|
||||
#ifdef USE_SELECT
|
||||
if (this->operating_selector_ != nullptr) {
|
||||
this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING);
|
||||
}
|
||||
#endif
|
||||
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_mode_(CMD_SYSTEM_MODE_ENERGY);
|
||||
#ifdef USE_SELECT
|
||||
if (this->operating_selector_ != nullptr) {
|
||||
this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#ifdef USE_NUMBER
|
||||
// Publish whatever was read before giving up so the number entities show
|
||||
// values next to the warning status instead of staying unknown forever
|
||||
this->init_gate_config_numbers();
|
||||
#endif
|
||||
this->set_system_mode(this->system_mode_);
|
||||
this->set_config_mode(false);
|
||||
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
|
||||
this->startup_state_ = StartupState::STARTUP_STATE_RUNNING;
|
||||
}
|
||||
|
||||
void LD2420Component::abort_startup_cmd_() {
|
||||
// If the module already acknowledged config mode it stops streaming until
|
||||
// config mode is exited, so send the exit command blind before abandoning
|
||||
// the sequence; otherwise the stream never resumes and neither passive
|
||||
// parsing nor the next listen phase would ever see data. This is also sent
|
||||
// when config mode was never acknowledged: the ack may merely have been
|
||||
// lost, and the frame is harmless to a module that is not in config mode.
|
||||
CmdFrameT frame;
|
||||
this->build_config_mode_frame_(frame, false);
|
||||
this->write_cmd_frame_(frame);
|
||||
}
|
||||
|
||||
void LD2420Component::loop_startup_(bool got_data) {
|
||||
switch (this->startup_state_) {
|
||||
case StartupState::STARTUP_STATE_LISTEN_SETTLE:
|
||||
// Bytes can already be in flight when the listen phase starts: the tail
|
||||
// of a frame the module was transmitting when it was told to restart,
|
||||
// stale data buffered before setup, or the ack to the blind config mode
|
||||
// exit. Discard everything received during this settle window so only
|
||||
// data the module sends afterwards counts as proof that it is up and
|
||||
// streaming. The state runs at least one drain pass even when the main
|
||||
// loop stalls past the whole window, so bytes that arrived before the
|
||||
// listen phase can never be mistaken for fresh data.
|
||||
this->drain_rx_();
|
||||
if (millis() - this->phase_start_ms_ >= STARTUP_LISTEN_SETTLE_MS) {
|
||||
this->phase_start_ms_ = millis();
|
||||
this->startup_state_ = StartupState::STARTUP_STATE_LISTEN;
|
||||
}
|
||||
return;
|
||||
|
||||
case StartupState::STARTUP_STATE_LISTEN:
|
||||
// The module locks up until power cycled if it receives data before it
|
||||
// has sent its first frame after powering on, so wait until it has
|
||||
// provably transmitted before sending anything. (A full-frame check
|
||||
// cannot serve as that proof: old-firmware text frames are only
|
||||
// recognized once the operating mode is known, which requires the very
|
||||
// handshake this phase gates.) A module stuck in some other state
|
||||
// stays quiet, so fall through after the listen window.
|
||||
if (!got_data) {
|
||||
const uint32_t listen_timeout_ms =
|
||||
this->startup_sequence_retries_ == 0 ? STARTUP_LISTEN_TIMEOUT_MS : STARTUP_RETRY_LISTEN_MS;
|
||||
if (millis() - this->phase_start_ms_ < listen_timeout_ms) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGW(TAG, "No data received from the module; attempting configuration anyway");
|
||||
}
|
||||
// Drop any partial frame so the ack parser starts clean
|
||||
this->drain_rx_();
|
||||
this->start_startup_cmd_(StartupState::STARTUP_STATE_ENTER_CONFIG);
|
||||
return;
|
||||
|
||||
case StartupState::STARTUP_STATE_ENTER_CONFIG:
|
||||
if (!this->startup_ack_check_()) {
|
||||
return;
|
||||
}
|
||||
this->start_startup_cmd_(StartupState::STARTUP_STATE_READ_LIMITS);
|
||||
return;
|
||||
|
||||
case StartupState::STARTUP_STATE_READ_LIMITS:
|
||||
if (!this->startup_ack_check_(REPLY_MIN_LEN_LIMITS)) {
|
||||
return;
|
||||
}
|
||||
this->current_config.min_gate = (uint16_t) this->cmd_reply_.data[0];
|
||||
this->current_config.max_gate = (uint16_t) this->cmd_reply_.data[1];
|
||||
this->current_config.timeout = (uint16_t) this->cmd_reply_.data[2];
|
||||
this->start_startup_cmd_(StartupState::STARTUP_STATE_READ_VERSION);
|
||||
return;
|
||||
|
||||
case StartupState::STARTUP_STATE_READ_VERSION: {
|
||||
if (!this->startup_ack_check_()) {
|
||||
return;
|
||||
}
|
||||
std::string fw_str(this->firmware_ver_);
|
||||
for (auto &listener : this->listeners_) {
|
||||
listener->on_fw_version(fw_str);
|
||||
}
|
||||
this->startup_gate_ = 0;
|
||||
this->start_startup_cmd_(StartupState::STARTUP_STATE_READ_GATES);
|
||||
return;
|
||||
}
|
||||
|
||||
case StartupState::STARTUP_STATE_READ_GATES:
|
||||
if (!this->startup_ack_check_(REPLY_MIN_LEN_GATE)) {
|
||||
return;
|
||||
}
|
||||
this->current_config.move_thresh[this->startup_gate_] = this->cmd_reply_.data[0];
|
||||
this->current_config.still_thresh[this->startup_gate_] = this->cmd_reply_.data[1];
|
||||
if (++this->startup_gate_ < TOTAL_GATES) {
|
||||
this->start_startup_cmd_(StartupState::STARTUP_STATE_READ_GATES);
|
||||
return;
|
||||
}
|
||||
this->config_read_complete_ = true;
|
||||
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_STRING);
|
||||
#ifdef USE_SELECT
|
||||
if (this->operating_selector_ != nullptr) {
|
||||
this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING);
|
||||
}
|
||||
#endif
|
||||
this->startup_target_mode_ = CMD_SYSTEM_MODE_SIMPLE;
|
||||
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
|
||||
} else {
|
||||
this->startup_target_mode_ = CMD_SYSTEM_MODE_ENERGY;
|
||||
#ifdef USE_SELECT
|
||||
if (this->operating_selector_ != nullptr) {
|
||||
this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#ifdef USE_NUMBER
|
||||
this->init_gate_config_numbers();
|
||||
#endif
|
||||
this->start_startup_cmd_(StartupState::STARTUP_STATE_SET_MODE);
|
||||
return;
|
||||
|
||||
case StartupState::STARTUP_STATE_SET_MODE:
|
||||
if (!this->startup_ack_check_()) {
|
||||
return;
|
||||
}
|
||||
// Switch the parser only after the module acknowledged the mode write,
|
||||
// so both sides stay in the same mode when the write is never acked
|
||||
this->set_mode_(this->startup_target_mode_);
|
||||
this->start_startup_cmd_(StartupState::STARTUP_STATE_EXIT_CONFIG);
|
||||
return;
|
||||
|
||||
case StartupState::STARTUP_STATE_EXIT_CONFIG:
|
||||
if (!this->startup_ack_check_()) {
|
||||
return;
|
||||
}
|
||||
this->status_clear_warning();
|
||||
this->startup_state_ = StartupState::STARTUP_STATE_RUNNING;
|
||||
ESP_LOGI(TAG, "Module setup complete; firmware %s", this->firmware_ver_);
|
||||
return;
|
||||
|
||||
case StartupState::STARTUP_STATE_RUNNING:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Common precondition for the button actions: the startup handshake must have
|
||||
// finished, and actions that write configuration additionally require that
|
||||
// every limit and gate threshold was actually read (setup may have given up
|
||||
// partway through; writing the unread config to the module's NVM would wipe
|
||||
// its stored thresholds).
|
||||
bool LD2420Component::action_allowed_(bool needs_config) {
|
||||
if (this->startup_state_ != StartupState::STARTUP_STATE_RUNNING) {
|
||||
ESP_LOGW(TAG, "Module is still starting up; ignoring");
|
||||
return false;
|
||||
}
|
||||
if (needs_config && !this->config_read_complete_) {
|
||||
ESP_LOGW(TAG, "Module configuration was never fully read; ignoring");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void LD2420Component::apply_config_action() {
|
||||
if (!this->action_allowed_(true)) {
|
||||
return;
|
||||
}
|
||||
const uint8_t checksum = calc_checksum(&this->new_config, sizeof(this->new_config));
|
||||
if (checksum == calc_checksum(&this->current_config, sizeof(this->current_config))) {
|
||||
ESP_LOGD(TAG, "No configuration change detected");
|
||||
@@ -272,31 +549,44 @@ void LD2420Component::apply_config_action() {
|
||||
ESP_LOGD(TAG, "Reconfiguring");
|
||||
if (this->set_config_mode(true) == LD2420_ERROR_TIMEOUT) {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
this->mark_failed();
|
||||
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
|
||||
return;
|
||||
}
|
||||
this->set_min_max_distances_timeout(this->new_config.max_gate, this->new_config.min_gate, this->new_config.timeout);
|
||||
uint8_t error = this->set_min_max_distances_timeout(this->new_config.max_gate, this->new_config.min_gate,
|
||||
this->new_config.timeout);
|
||||
for (uint8_t gate = 0; gate < TOTAL_GATES; gate++) {
|
||||
delay_microseconds_safe(125);
|
||||
this->set_gate_threshold(gate);
|
||||
error |= this->set_gate_threshold(gate);
|
||||
}
|
||||
if (error == LD2420_ERROR_NONE) {
|
||||
// Only adopt the new values as current once every write was acknowledged
|
||||
memcpy(¤t_config, &new_config, sizeof(new_config));
|
||||
}
|
||||
memcpy(¤t_config, &new_config, sizeof(new_config));
|
||||
#ifdef USE_NUMBER
|
||||
this->init_gate_config_numbers();
|
||||
#endif
|
||||
this->set_system_mode(this->system_mode_);
|
||||
this->set_config_mode(false); // Disable config mode to save new values in LD2420 nvm
|
||||
// Disable config mode to save the new values in the LD2420 nvm
|
||||
if (this->set_config_mode(false) == LD2420_ERROR_NONE && error == LD2420_ERROR_NONE) {
|
||||
this->status_clear_warning();
|
||||
} else {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
|
||||
}
|
||||
this->set_operating_mode(OP_NORMAL_MODE_STRING);
|
||||
}
|
||||
|
||||
void LD2420Component::factory_reset_action() {
|
||||
if (!this->action_allowed_(true)) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Setting factory defaults");
|
||||
if (this->set_config_mode(true) == LD2420_ERROR_TIMEOUT) {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
this->mark_failed();
|
||||
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
|
||||
return;
|
||||
}
|
||||
this->set_min_max_distances_timeout(FACTORY_MAX_GATE, FACTORY_MIN_GATE, FACTORY_TIMEOUT);
|
||||
uint8_t error = this->set_min_max_distances_timeout(FACTORY_MAX_GATE, FACTORY_MIN_GATE, FACTORY_TIMEOUT);
|
||||
#ifdef USE_NUMBER
|
||||
this->gate_timeout_number_->state = FACTORY_TIMEOUT;
|
||||
this->min_gate_distance_number_->state = FACTORY_MIN_GATE;
|
||||
@@ -306,11 +596,18 @@ void LD2420Component::factory_reset_action() {
|
||||
this->new_config.move_thresh[gate] = FACTORY_MOVE_THRESH[gate];
|
||||
this->new_config.still_thresh[gate] = FACTORY_STILL_THRESH[gate];
|
||||
delay_microseconds_safe(125);
|
||||
this->set_gate_threshold(gate);
|
||||
error |= this->set_gate_threshold(gate);
|
||||
}
|
||||
if (error == LD2420_ERROR_NONE) {
|
||||
memcpy(&this->current_config, &this->new_config, sizeof(this->new_config));
|
||||
}
|
||||
memcpy(&this->current_config, &this->new_config, sizeof(this->new_config));
|
||||
this->set_system_mode(this->system_mode_);
|
||||
this->set_config_mode(false);
|
||||
if (this->set_config_mode(false) == LD2420_ERROR_NONE && error == LD2420_ERROR_NONE) {
|
||||
this->status_clear_warning();
|
||||
} else {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
|
||||
}
|
||||
#ifdef USE_NUMBER
|
||||
this->init_gate_config_numbers();
|
||||
this->refresh_gate_config_numbers();
|
||||
@@ -318,16 +615,21 @@ void LD2420Component::factory_reset_action() {
|
||||
}
|
||||
|
||||
void LD2420Component::restart_module_action() {
|
||||
if (!this->action_allowed_(false)) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Restarting");
|
||||
this->send_module_restart();
|
||||
this->set_timeout(250, [this]() {
|
||||
this->set_config_mode(true);
|
||||
this->set_system_mode(this->system_mode_);
|
||||
this->set_config_mode(false);
|
||||
});
|
||||
// The module is silent while it boots and locks up if it receives data
|
||||
// before it has sent its first frame, so re-run the listen-first startup
|
||||
// sequence instead of transmitting into the boot window.
|
||||
this->begin_startup_();
|
||||
}
|
||||
|
||||
void LD2420Component::revert_config_action() {
|
||||
if (!this->action_allowed_(false)) {
|
||||
return;
|
||||
}
|
||||
memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
|
||||
#ifdef USE_NUMBER
|
||||
this->init_gate_config_numbers();
|
||||
@@ -340,7 +642,10 @@ void LD2420Component::loop() {
|
||||
if (this->cmd_active_) {
|
||||
return;
|
||||
}
|
||||
this->read_batch_(this->buffer_data_);
|
||||
const bool got_data = this->read_batch_(this->buffer_data_);
|
||||
if (this->startup_state_ != StartupState::STARTUP_STATE_RUNNING) {
|
||||
this->loop_startup_(got_data);
|
||||
}
|
||||
}
|
||||
|
||||
void LD2420Component::update_radar_data(uint16_t const *gate_energy, uint8_t sample_number) {
|
||||
@@ -547,9 +852,10 @@ void LD2420Component::handle_simple_mode_(const uint8_t *inbuf, int len) {
|
||||
}
|
||||
}
|
||||
|
||||
void LD2420Component::read_batch_(std::span<uint8_t, MAX_LINE_LENGTH> buffer) {
|
||||
bool LD2420Component::read_batch_(std::span<uint8_t, MAX_LINE_LENGTH> buffer) {
|
||||
// Read all available bytes in batches to reduce UART call overhead.
|
||||
size_t avail = this->available();
|
||||
const bool got_data = avail > 0;
|
||||
uint8_t buf[MAX_LINE_LENGTH];
|
||||
while (avail > 0) {
|
||||
size_t to_read = std::min(avail, sizeof(buf));
|
||||
@@ -562,6 +868,7 @@ void LD2420Component::read_batch_(std::span<uint8_t, MAX_LINE_LENGTH> buffer) {
|
||||
this->readline_(buf[i], buffer.data(), buffer.size());
|
||||
}
|
||||
}
|
||||
return got_data;
|
||||
}
|
||||
|
||||
void LD2420Component::handle_ack_data_(uint8_t *buffer, int len) {
|
||||
@@ -631,37 +938,39 @@ void LD2420Component::handle_ack_data_(uint8_t *buffer, int len) {
|
||||
}
|
||||
}
|
||||
|
||||
void LD2420Component::write_cmd_frame_(const CmdFrameT &frame) {
|
||||
uint8_t cmd_buffer[MAX_LINE_LENGTH];
|
||||
uint16_t length = 0;
|
||||
const uint16_t frame_data_bytes = frame.data_length + 2; // Always add two bytes for the cmd size
|
||||
|
||||
memcpy(&cmd_buffer[length], &frame.header, sizeof(frame.header));
|
||||
length += sizeof(frame.header);
|
||||
|
||||
memcpy(&cmd_buffer[length], &frame_data_bytes, sizeof(frame.data_length));
|
||||
length += sizeof(frame.data_length);
|
||||
|
||||
memcpy(&cmd_buffer[length], &frame.command, sizeof(frame.command));
|
||||
length += sizeof(frame.command);
|
||||
|
||||
memcpy(&cmd_buffer[length], frame.data, frame.data_length);
|
||||
length += frame.data_length;
|
||||
|
||||
memcpy(&cmd_buffer[length], &frame.footer, sizeof(frame.footer));
|
||||
length += sizeof(frame.footer);
|
||||
this->write_array(cmd_buffer, length);
|
||||
}
|
||||
|
||||
int LD2420Component::send_cmd_from_array(CmdFrameT frame) {
|
||||
uint32_t start_millis = millis();
|
||||
uint8_t error = 0;
|
||||
uint8_t ack_buffer[MAX_LINE_LENGTH];
|
||||
uint8_t cmd_buffer[MAX_LINE_LENGTH];
|
||||
this->cmd_reply_.ack = false;
|
||||
if (frame.command != CMD_RESTART) {
|
||||
this->cmd_active_ = true;
|
||||
} // Restart does not reply, thus no ack state required
|
||||
uint8_t retry = 3;
|
||||
uint8_t retry = CMD_MAX_RETRIES;
|
||||
while (retry) {
|
||||
frame.length = 0;
|
||||
uint16_t frame_data_bytes = frame.data_length + 2; // Always add two bytes for the cmd size
|
||||
|
||||
memcpy(&cmd_buffer[frame.length], &frame.header, sizeof(frame.header));
|
||||
frame.length += sizeof(frame.header);
|
||||
|
||||
memcpy(&cmd_buffer[frame.length], &frame_data_bytes, sizeof(frame.data_length));
|
||||
frame.length += sizeof(frame.data_length);
|
||||
|
||||
memcpy(&cmd_buffer[frame.length], &frame.command, sizeof(frame.command));
|
||||
frame.length += sizeof(frame.command);
|
||||
|
||||
for (uint16_t index = 0; index < frame.data_length; index++) {
|
||||
memcpy(&cmd_buffer[frame.length], &frame.data[index], sizeof(frame.data[index]));
|
||||
frame.length += sizeof(frame.data[index]);
|
||||
}
|
||||
|
||||
memcpy(cmd_buffer + frame.length, &frame.footer, sizeof(frame.footer));
|
||||
frame.length += sizeof(frame.footer);
|
||||
this->write_array(cmd_buffer, frame.length);
|
||||
this->write_cmd_frame_(frame);
|
||||
|
||||
error = 0;
|
||||
if (frame.command == CMD_RESTART) {
|
||||
@@ -674,7 +983,7 @@ int LD2420Component::send_cmd_from_array(CmdFrameT frame) {
|
||||
}
|
||||
delay_microseconds_safe(1450);
|
||||
// Wait on an Rx from the LD2420 for up to 3 1 second loops, otherwise it could trigger a WDT.
|
||||
if ((millis() - start_millis) > 1000) {
|
||||
if ((millis() - start_millis) > CMD_ACK_TIMEOUT_MS) {
|
||||
start_millis = millis();
|
||||
error = LD2420_ERROR_TIMEOUT;
|
||||
retry--;
|
||||
@@ -688,19 +997,26 @@ int LD2420Component::send_cmd_from_array(CmdFrameT frame) {
|
||||
this->handle_cmd_error(this->cmd_reply_.error);
|
||||
}
|
||||
}
|
||||
// On ack the reply parser already cleared this; clear it here as well so an
|
||||
// exhausted retry loop cannot leave loop() skipping all processing forever.
|
||||
this->cmd_active_ = false;
|
||||
return error;
|
||||
}
|
||||
|
||||
void LD2420Component::build_config_mode_frame_(CmdFrameT &frame, bool enable) {
|
||||
frame.data_length = 0;
|
||||
frame.header = CMD_FRAME_HEADER;
|
||||
frame.command = enable ? CMD_ENABLE_CONF : CMD_DISABLE_CONF;
|
||||
if (enable) {
|
||||
memcpy(&frame.data[0], &CMD_PROTOCOL_VER, sizeof(CMD_PROTOCOL_VER));
|
||||
frame.data_length += sizeof(CMD_PROTOCOL_VER);
|
||||
}
|
||||
frame.footer = CMD_FRAME_FOOTER;
|
||||
}
|
||||
|
||||
uint8_t LD2420Component::set_config_mode(bool enable) {
|
||||
CmdFrameT cmd_frame;
|
||||
cmd_frame.data_length = 0;
|
||||
cmd_frame.header = CMD_FRAME_HEADER;
|
||||
cmd_frame.command = enable ? CMD_ENABLE_CONF : CMD_DISABLE_CONF;
|
||||
if (enable) {
|
||||
memcpy(&cmd_frame.data[0], &CMD_PROTOCOL_VER, sizeof(CMD_PROTOCOL_VER));
|
||||
cmd_frame.data_length += sizeof(CMD_PROTOCOL_VER);
|
||||
}
|
||||
cmd_frame.footer = CMD_FRAME_FOOTER;
|
||||
this->build_config_mode_frame_(cmd_frame, enable);
|
||||
ESP_LOGV(TAG, "Sending set config %s command: %2X", enable ? "enable" : "disable", cmd_frame.command);
|
||||
return this->send_cmd_from_array(cmd_frame);
|
||||
}
|
||||
@@ -718,18 +1034,6 @@ void LD2420Component::ld2420_restart() {
|
||||
this->send_cmd_from_array(cmd_frame);
|
||||
}
|
||||
|
||||
void LD2420Component::get_reg_value_(uint16_t reg) {
|
||||
CmdFrameT cmd_frame;
|
||||
cmd_frame.data_length = 0;
|
||||
cmd_frame.header = CMD_FRAME_HEADER;
|
||||
cmd_frame.command = CMD_READ_REGISTER;
|
||||
cmd_frame.data[1] = reg;
|
||||
cmd_frame.data_length += 2;
|
||||
cmd_frame.footer = CMD_FRAME_FOOTER;
|
||||
ESP_LOGV(TAG, "Sending read register %4X command: %2X", reg, cmd_frame.command);
|
||||
this->send_cmd_from_array(cmd_frame);
|
||||
}
|
||||
|
||||
void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) {
|
||||
CmdFrameT cmd_frame;
|
||||
cmd_frame.data_length = 0;
|
||||
@@ -753,84 +1057,69 @@ void LD2420Component::handle_cmd_error(uint16_t error) {
|
||||
}
|
||||
}
|
||||
|
||||
int LD2420Component::get_gate_threshold_(uint8_t gate) {
|
||||
uint8_t error;
|
||||
CmdFrameT cmd_frame;
|
||||
cmd_frame.data_length = 0;
|
||||
cmd_frame.header = CMD_FRAME_HEADER;
|
||||
cmd_frame.command = CMD_READ_ABD_PARAM;
|
||||
memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_GATE_MOVE_THRESH[gate], sizeof(CMD_GATE_MOVE_THRESH[gate]));
|
||||
cmd_frame.data_length += 2;
|
||||
memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_GATE_STILL_THRESH[gate], sizeof(CMD_GATE_STILL_THRESH[gate]));
|
||||
cmd_frame.data_length += 2;
|
||||
cmd_frame.footer = CMD_FRAME_FOOTER;
|
||||
ESP_LOGV(TAG, "Sending read gate %d high/low threshold command: %2X", gate, cmd_frame.command);
|
||||
error = this->send_cmd_from_array(cmd_frame);
|
||||
if (error == 0) {
|
||||
this->current_config.move_thresh[gate] = cmd_reply_.data[0];
|
||||
this->current_config.still_thresh[gate] = cmd_reply_.data[1];
|
||||
}
|
||||
return error;
|
||||
void LD2420Component::build_gate_threshold_frame_(CmdFrameT &frame, uint8_t gate) {
|
||||
frame.data_length = 0;
|
||||
frame.header = CMD_FRAME_HEADER;
|
||||
frame.command = CMD_READ_ABD_PARAM;
|
||||
memcpy(&frame.data[frame.data_length], &CMD_GATE_MOVE_THRESH[gate], sizeof(CMD_GATE_MOVE_THRESH[gate]));
|
||||
frame.data_length += 2;
|
||||
memcpy(&frame.data[frame.data_length], &CMD_GATE_STILL_THRESH[gate], sizeof(CMD_GATE_STILL_THRESH[gate]));
|
||||
frame.data_length += 2;
|
||||
frame.footer = CMD_FRAME_FOOTER;
|
||||
ESP_LOGV(TAG, "Sending read gate %d high/low threshold command: %2X", gate, frame.command);
|
||||
}
|
||||
|
||||
int LD2420Component::get_min_max_distances_timeout_() {
|
||||
uint8_t error;
|
||||
CmdFrameT cmd_frame;
|
||||
cmd_frame.data_length = 0;
|
||||
cmd_frame.header = CMD_FRAME_HEADER;
|
||||
cmd_frame.command = CMD_READ_ABD_PARAM;
|
||||
memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_MIN_GATE_REG,
|
||||
void LD2420Component::build_min_max_timeout_frame_(CmdFrameT &frame) {
|
||||
frame.data_length = 0;
|
||||
frame.header = CMD_FRAME_HEADER;
|
||||
frame.command = CMD_READ_ABD_PARAM;
|
||||
memcpy(&frame.data[frame.data_length], &CMD_MIN_GATE_REG,
|
||||
sizeof(CMD_MIN_GATE_REG)); // Register: global min detect gate number
|
||||
cmd_frame.data_length += sizeof(CMD_MIN_GATE_REG);
|
||||
memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_MAX_GATE_REG,
|
||||
frame.data_length += sizeof(CMD_MIN_GATE_REG);
|
||||
memcpy(&frame.data[frame.data_length], &CMD_MAX_GATE_REG,
|
||||
sizeof(CMD_MAX_GATE_REG)); // Register: global max detect gate number
|
||||
cmd_frame.data_length += sizeof(CMD_MAX_GATE_REG);
|
||||
memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_TIMEOUT_REG,
|
||||
frame.data_length += sizeof(CMD_MAX_GATE_REG);
|
||||
memcpy(&frame.data[frame.data_length], &CMD_TIMEOUT_REG,
|
||||
sizeof(CMD_TIMEOUT_REG)); // Register: global delay time
|
||||
cmd_frame.data_length += sizeof(CMD_TIMEOUT_REG);
|
||||
cmd_frame.footer = CMD_FRAME_FOOTER;
|
||||
ESP_LOGV(TAG, "Sending read gate min max and timeout command: %2X", cmd_frame.command);
|
||||
error = this->send_cmd_from_array(cmd_frame);
|
||||
if (error == 0) {
|
||||
this->current_config.min_gate = (uint16_t) cmd_reply_.data[0];
|
||||
this->current_config.max_gate = (uint16_t) cmd_reply_.data[1];
|
||||
this->current_config.timeout = (uint16_t) cmd_reply_.data[2];
|
||||
}
|
||||
return error;
|
||||
frame.data_length += sizeof(CMD_TIMEOUT_REG);
|
||||
frame.footer = CMD_FRAME_FOOTER;
|
||||
ESP_LOGV(TAG, "Sending read gate min max and timeout command: %2X", frame.command);
|
||||
}
|
||||
|
||||
void LD2420Component::build_system_mode_frame_(CmdFrameT &frame, uint16_t mode) {
|
||||
uint16_t unknown_parm = 0x0000;
|
||||
frame.data_length = 0;
|
||||
frame.header = CMD_FRAME_HEADER;
|
||||
frame.command = CMD_WRITE_SYS_PARAM;
|
||||
memcpy(&frame.data[frame.data_length], &CMD_SYSTEM_MODE, sizeof(CMD_SYSTEM_MODE));
|
||||
frame.data_length += sizeof(CMD_SYSTEM_MODE);
|
||||
memcpy(&frame.data[frame.data_length], &mode, sizeof(mode));
|
||||
frame.data_length += sizeof(mode);
|
||||
memcpy(&frame.data[frame.data_length], &unknown_parm, sizeof(unknown_parm));
|
||||
frame.data_length += sizeof(unknown_parm);
|
||||
frame.footer = CMD_FRAME_FOOTER;
|
||||
ESP_LOGV(TAG, "Sending write system mode command: %2X", frame.command);
|
||||
}
|
||||
|
||||
void LD2420Component::set_system_mode(uint16_t mode) {
|
||||
CmdFrameT cmd_frame;
|
||||
uint16_t unknown_parm = 0x0000;
|
||||
cmd_frame.data_length = 0;
|
||||
cmd_frame.header = CMD_FRAME_HEADER;
|
||||
cmd_frame.command = CMD_WRITE_SYS_PARAM;
|
||||
memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_SYSTEM_MODE, sizeof(CMD_SYSTEM_MODE));
|
||||
cmd_frame.data_length += sizeof(CMD_SYSTEM_MODE);
|
||||
memcpy(&cmd_frame.data[cmd_frame.data_length], &mode, sizeof(mode));
|
||||
cmd_frame.data_length += sizeof(mode);
|
||||
memcpy(&cmd_frame.data[cmd_frame.data_length], &unknown_parm, sizeof(unknown_parm));
|
||||
cmd_frame.data_length += sizeof(unknown_parm);
|
||||
cmd_frame.footer = CMD_FRAME_FOOTER;
|
||||
ESP_LOGV(TAG, "Sending write system mode command: %2X", cmd_frame.command);
|
||||
this->build_system_mode_frame_(cmd_frame, mode);
|
||||
if (this->send_cmd_from_array(cmd_frame) == 0) {
|
||||
this->set_mode_(mode);
|
||||
}
|
||||
}
|
||||
|
||||
void LD2420Component::get_firmware_version_() {
|
||||
CmdFrameT cmd_frame;
|
||||
cmd_frame.data_length = 0;
|
||||
cmd_frame.header = CMD_FRAME_HEADER;
|
||||
cmd_frame.command = CMD_READ_VERSION;
|
||||
cmd_frame.footer = CMD_FRAME_FOOTER;
|
||||
|
||||
ESP_LOGV(TAG, "Sending read firmware version command: %2X", cmd_frame.command);
|
||||
this->send_cmd_from_array(cmd_frame);
|
||||
void LD2420Component::build_version_frame_(CmdFrameT &frame) {
|
||||
frame.data_length = 0;
|
||||
frame.header = CMD_FRAME_HEADER;
|
||||
frame.command = CMD_READ_VERSION;
|
||||
frame.footer = CMD_FRAME_FOOTER;
|
||||
ESP_LOGV(TAG, "Sending read firmware version command: %2X", frame.command);
|
||||
}
|
||||
|
||||
void LD2420Component::set_min_max_distances_timeout(uint32_t max_gate_distance, uint32_t min_gate_distance, // NOLINT
|
||||
uint32_t timeout) {
|
||||
uint8_t LD2420Component::set_min_max_distances_timeout(uint32_t max_gate_distance,
|
||||
uint32_t min_gate_distance, // NOLINT
|
||||
uint32_t timeout) {
|
||||
// Header H, Length L, Register R, Value V, Footer F
|
||||
// |Min Gate |Max Gate |Timeout |
|
||||
// HH HH HH HH LL LL CC CC RR RR VV VV VV VV RR RR VV VV VV VV RR RR VV VV VV VV FF FF FF FF
|
||||
@@ -859,10 +1148,10 @@ void LD2420Component::set_min_max_distances_timeout(uint32_t max_gate_distance,
|
||||
cmd_frame.footer = CMD_FRAME_FOOTER;
|
||||
|
||||
ESP_LOGV(TAG, "Sending write gate min max and timeout command: %2X", cmd_frame.command);
|
||||
this->send_cmd_from_array(cmd_frame);
|
||||
return this->send_cmd_from_array(cmd_frame);
|
||||
}
|
||||
|
||||
void LD2420Component::set_gate_threshold(uint8_t gate) {
|
||||
uint8_t LD2420Component::set_gate_threshold(uint8_t gate) {
|
||||
// Header H, Length L, Command C, Register R, Value V, Footer F
|
||||
// HH HH HH HH LL LL CC CC RR RR VV VV VV VV RR RR VV VV VV VV FF FF FF FF
|
||||
// FD FC FB FA 14 00 07 00 10 00 00 FF 00 00 00 01 00 0F 00 00 04 03 02 01
|
||||
@@ -885,7 +1174,7 @@ void LD2420Component::set_gate_threshold(uint8_t gate) {
|
||||
cmd_frame.data_length += sizeof(this->new_config.still_thresh[gate]);
|
||||
cmd_frame.footer = CMD_FRAME_FOOTER;
|
||||
ESP_LOGV(TAG, "Sending set gate %4X sensitivity command: %2X", gate, cmd_frame.command);
|
||||
this->send_cmd_from_array(cmd_frame);
|
||||
return this->send_cmd_from_array(cmd_frame);
|
||||
}
|
||||
|
||||
#ifdef USE_NUMBER
|
||||
|
||||
@@ -45,15 +45,14 @@ class LD2420Component final : public Component, public uart::UARTDevice {
|
||||
struct CmdFrameT {
|
||||
uint32_t header{0};
|
||||
uint32_t footer{0};
|
||||
uint16_t length{0};
|
||||
uint16_t command{0};
|
||||
uint16_t data_length{0};
|
||||
uint8_t data[18];
|
||||
};
|
||||
|
||||
struct RegConfigT {
|
||||
uint32_t move_thresh[TOTAL_GATES];
|
||||
uint32_t still_thresh[TOTAL_GATES];
|
||||
uint32_t move_thresh[TOTAL_GATES]{};
|
||||
uint32_t still_thresh[TOTAL_GATES]{};
|
||||
uint16_t min_gate{0};
|
||||
uint16_t max_gate{0};
|
||||
uint16_t timeout{0};
|
||||
@@ -112,8 +111,8 @@ class LD2420Component final : public Component, public uart::UARTDevice {
|
||||
void auto_calibrate_sensitivity();
|
||||
void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number);
|
||||
uint8_t set_config_mode(bool enable);
|
||||
void set_min_max_distances_timeout(uint32_t max_gate_distance, uint32_t min_gate_distance, uint32_t timeout);
|
||||
void set_gate_threshold(uint8_t gate);
|
||||
uint8_t set_min_max_distances_timeout(uint32_t max_gate_distance, uint32_t min_gate_distance, uint32_t timeout);
|
||||
uint8_t set_gate_threshold(uint8_t gate);
|
||||
void set_reg_value(uint16_t reg, uint16_t value);
|
||||
void set_system_mode(uint16_t mode);
|
||||
void ld2420_restart();
|
||||
@@ -153,10 +152,40 @@ class LD2420Component final : public Component, public uart::UARTDevice {
|
||||
volatile bool ack;
|
||||
};
|
||||
|
||||
void get_firmware_version_();
|
||||
int get_gate_threshold_(uint8_t gate);
|
||||
void get_reg_value_(uint16_t reg);
|
||||
int get_min_max_distances_timeout_();
|
||||
// Startup runs as a non-blocking state machine driven from loop(). The module
|
||||
// locks up until power cycled if it receives any data before it has sent its
|
||||
// first frame after powering on, so the state machine listens for data from
|
||||
// the module before transmitting anything.
|
||||
enum class StartupState : uint8_t {
|
||||
STARTUP_STATE_LISTEN_SETTLE = 0,
|
||||
STARTUP_STATE_LISTEN,
|
||||
STARTUP_STATE_ENTER_CONFIG,
|
||||
STARTUP_STATE_READ_LIMITS,
|
||||
STARTUP_STATE_READ_VERSION,
|
||||
STARTUP_STATE_READ_GATES,
|
||||
STARTUP_STATE_SET_MODE,
|
||||
STARTUP_STATE_EXIT_CONFIG,
|
||||
STARTUP_STATE_RUNNING,
|
||||
};
|
||||
|
||||
void begin_startup_();
|
||||
void begin_listen_();
|
||||
void loop_startup_(bool got_data);
|
||||
void start_startup_cmd_(StartupState state);
|
||||
void send_startup_cmd_();
|
||||
void abort_startup_cmd_();
|
||||
void abandon_startup_();
|
||||
bool startup_ack_check_(uint8_t min_data_len = 0);
|
||||
bool action_allowed_(bool needs_config);
|
||||
void drain_rx_();
|
||||
void write_cmd_frame_(const CmdFrameT &frame);
|
||||
bool build_startup_frame_(CmdFrameT &frame);
|
||||
void build_config_mode_frame_(CmdFrameT &frame, bool enable);
|
||||
void build_min_max_timeout_frame_(CmdFrameT &frame);
|
||||
void build_gate_threshold_frame_(CmdFrameT &frame, uint8_t gate);
|
||||
void build_version_frame_(CmdFrameT &frame);
|
||||
void build_system_mode_frame_(CmdFrameT &frame, uint16_t mode);
|
||||
|
||||
uint16_t get_mode_() { return this->system_mode_; };
|
||||
void set_mode_(uint16_t mode) { this->system_mode_ = mode; };
|
||||
bool get_presence_() { return this->presence_; };
|
||||
@@ -167,7 +196,7 @@ class LD2420Component final : public Component, public uart::UARTDevice {
|
||||
void handle_energy_mode_(uint8_t *buffer, int len);
|
||||
void handle_ack_data_(uint8_t *buffer, int len);
|
||||
void readline_(int rx_data, uint8_t *buffer, int len);
|
||||
void read_batch_(std::span<uint8_t, MAX_LINE_LENGTH> buffer);
|
||||
bool read_batch_(std::span<uint8_t, MAX_LINE_LENGTH> buffer);
|
||||
void set_calibration_(bool state) { this->calibration_ = state; };
|
||||
bool get_calibration_() { return this->calibration_; };
|
||||
|
||||
@@ -183,9 +212,17 @@ class LD2420Component final : public Component, public uart::UARTDevice {
|
||||
#endif
|
||||
|
||||
uint16_t distance_{0};
|
||||
uint16_t system_mode_;
|
||||
uint16_t system_mode_{0}; // Set to the energy mode default in begin_startup_()
|
||||
uint16_t startup_target_mode_{0}; // Mode the startup handshake writes; applied to system_mode_ once acked
|
||||
uint16_t gate_energy_[TOTAL_GATES];
|
||||
uint8_t buffer_pos_{0}; // where to resume processing/populating buffer
|
||||
uint32_t phase_start_ms_{0};
|
||||
StartupState startup_state_{StartupState::STARTUP_STATE_LISTEN_SETTLE};
|
||||
uint8_t startup_cmd_{0}; // Command byte of the in-flight startup command, for ack matching
|
||||
uint8_t startup_cmd_attempts_{0};
|
||||
uint8_t startup_sequence_retries_{0};
|
||||
uint8_t startup_gate_{0};
|
||||
bool config_read_complete_{false}; // All limits and gate thresholds were read from the module
|
||||
uint8_t buffer_pos_{0}; // where to resume processing/populating buffer
|
||||
uint8_t buffer_data_[MAX_LINE_LENGTH];
|
||||
char firmware_ver_[8]{"v0.0.0"};
|
||||
bool cmd_active_{false};
|
||||
|
||||
@@ -182,6 +182,9 @@ def get_download_types(storage_json: StorageJSON = None):
|
||||
the shape stable so the download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
types = [
|
||||
{
|
||||
"title": "UF2 package (recommended)",
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
import enum
|
||||
import logging
|
||||
|
||||
import esphome.automation as auto
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, power_supply, web_server
|
||||
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BLUE,
|
||||
@@ -26,7 +23,6 @@ from esphome.const import (
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_INITIAL_STATE,
|
||||
CONF_IS_RGBW,
|
||||
CONF_MQTT_ID,
|
||||
CONF_NAME,
|
||||
CONF_ON_STATE,
|
||||
@@ -36,7 +32,6 @@ from esphome.const import (
|
||||
CONF_POWER_SUPPLY,
|
||||
CONF_RED,
|
||||
CONF_RESTORE_MODE,
|
||||
CONF_RGB_ORDER,
|
||||
CONF_STATE,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_WARM_WHITE,
|
||||
@@ -66,7 +61,6 @@ from .effects import (
|
||||
from .types import ( # noqa: F401
|
||||
AddressableLight,
|
||||
AddressableLightState,
|
||||
ChannelColors,
|
||||
ColorMode,
|
||||
LightOutput,
|
||||
LightState,
|
||||
@@ -77,8 +71,6 @@ from .types import ( # noqa: F401
|
||||
light_ns,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
|
||||
@@ -173,105 +165,7 @@ def available_effects_str(effects: list) -> str:
|
||||
return ", ".join(f"'{name}'" for name in available) if available else "none"
|
||||
|
||||
|
||||
# Accepted values of the deprecated `rgb_order` key.
|
||||
RGB_ORDERS = ("RGB", "RBG", "GRB", "GBR", "BGR", "BRG")
|
||||
|
||||
_RGB_CHANNELS = frozenset("RGB")
|
||||
_RGBW_CHANNELS = frozenset("RGBW")
|
||||
|
||||
|
||||
def validate_channel_colors(value: str) -> str:
|
||||
"""Validate the channel order of an addressable strip, e.g. "GRB" or "WRGB"."""
|
||||
value = cv.string_strict(value).upper()
|
||||
channels = frozenset(value)
|
||||
if len(channels) != len(value) or channels not in (_RGB_CHANNELS, _RGBW_CHANNELS):
|
||||
raise cv.Invalid(
|
||||
f"'{value}' is not a valid channel order. List each of R, G and B exactly "
|
||||
"once, optionally with a single W, in the order the strip expects them "
|
||||
"(for example GRB, GRBW or WRGB)"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def channel_colors_struct(value: str) -> cg.StructInitializer:
|
||||
"""Build the C++ `light::ChannelColors` for a validated channel order string."""
|
||||
return cg.StructInitializer(
|
||||
ChannelColors,
|
||||
("r", value.index("R")),
|
||||
("g", value.index("G")),
|
||||
("b", value.index("B")),
|
||||
(
|
||||
"w",
|
||||
value.index("W")
|
||||
if "W" in value
|
||||
else cg.RawExpression(f"{ChannelColors}::NO_WHITE"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _quote_and_join(keys: list[str]) -> str:
|
||||
"""Quote each key and join them into a readable list, e.g. "'a', 'b' and 'c'"."""
|
||||
quoted = [f"'{key}'" for key in keys]
|
||||
if len(quoted) == 1:
|
||||
return quoted[0]
|
||||
return f"{', '.join(quoted[:-1])} and {quoted[-1]}"
|
||||
|
||||
|
||||
def migrate_channel_colors(
|
||||
*, removed_in: str, component: str
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Fold the deprecated `rgb_order`, `is_rgbw` and `is_wrgb` keys into `channel_colors`.
|
||||
|
||||
This also enforces that `channel_colors` is set, which the schema cannot do on its
|
||||
own while the deprecated keys are still accepted. After this runs, `to_code` only
|
||||
ever sees `channel_colors`.
|
||||
"""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
config = config.copy()
|
||||
deprecated = [
|
||||
key for key in (CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB) if key in config
|
||||
]
|
||||
if CONF_CHANNEL_COLORS in config:
|
||||
if deprecated:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_CHANNEL_COLORS}' cannot be combined with "
|
||||
f"{_quote_and_join(deprecated)}"
|
||||
)
|
||||
return config
|
||||
if CONF_RGB_ORDER not in config:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_CHANNEL_COLORS}' is required", path=[CONF_CHANNEL_COLORS]
|
||||
)
|
||||
rgb_order = config.pop(CONF_RGB_ORDER)
|
||||
is_rgbw = config.pop(CONF_IS_RGBW, False)
|
||||
is_wrgb = config.pop(CONF_IS_WRGB, False)
|
||||
if is_rgbw and is_wrgb:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_IS_RGBW}' and '{CONF_IS_WRGB}' cannot both be enabled"
|
||||
)
|
||||
if is_wrgb:
|
||||
channel_colors = f"W{rgb_order}"
|
||||
elif is_rgbw:
|
||||
channel_colors = f"{rgb_order}W"
|
||||
else:
|
||||
channel_colors = rgb_order
|
||||
_LOGGER.warning(
|
||||
"[%s] %s %s deprecated, use '%s: %s'. Will be removed in %s",
|
||||
component,
|
||||
_quote_and_join(deprecated),
|
||||
"are" if len(deprecated) > 1 else "is",
|
||||
CONF_CHANNEL_COLORS,
|
||||
channel_colors,
|
||||
removed_in,
|
||||
)
|
||||
config[CONF_CHANNEL_COLORS] = channel_colors
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
"""Validate all recorded effect name references against their target lights.
|
||||
|
||||
This runs once per light platform instance. If no light platform is configured,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::light {
|
||||
|
||||
/// Which byte of an addressable LED's data carries each colour.
|
||||
///
|
||||
/// Built from a configuration string such as "GRB" or "WRGB": every field holds the
|
||||
/// position that colour occupies in the bytes the strip expects. `w` is NO_WHITE when
|
||||
/// the strip has no separate white channel.
|
||||
struct ChannelColors {
|
||||
/// Value of `w` for a strip that only has red, green and blue channels.
|
||||
static constexpr uint8_t NO_WHITE = 0xFF;
|
||||
|
||||
uint8_t r;
|
||||
uint8_t g;
|
||||
uint8_t b;
|
||||
uint8_t w;
|
||||
|
||||
bool has_white() const { return this->w != NO_WHITE; }
|
||||
|
||||
uint8_t bytes_per_led() const { return this->has_white() ? 4 : 3; }
|
||||
|
||||
/// Write the order back out as text, e.g. "GRBW".
|
||||
///
|
||||
/// `buf` must have room for at least 5 characters. Returns `buf` so the result can be
|
||||
/// passed straight to a log call.
|
||||
const char *to_string(char *buf) const {
|
||||
buf[this->r] = 'R';
|
||||
buf[this->g] = 'G';
|
||||
buf[this->b] = 'B';
|
||||
if (this->has_white()) {
|
||||
buf[this->w] = 'W';
|
||||
}
|
||||
buf[this->bytes_per_led()] = '\0';
|
||||
return buf;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace esphome::light
|
||||
@@ -16,9 +16,6 @@ LightColorValues = light_ns.class_("LightColorValues")
|
||||
LightStateRTCState = light_ns.struct("LightStateRTCState")
|
||||
LightCall = light_ns.class_("LightCall")
|
||||
|
||||
# Addressable strips
|
||||
ChannelColors = light_ns.struct("ChannelColors")
|
||||
|
||||
# Color modes
|
||||
ColorMode = light_ns.enum("ColorMode", is_class=True)
|
||||
COLOR_MODES = {
|
||||
|
||||
@@ -444,7 +444,7 @@ LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_r
|
||||
lv_indev_set_type(this->drv_, LV_INDEV_TYPE_POINTER);
|
||||
lv_indev_set_disp(this->drv_, parent->get_disp());
|
||||
lv_indev_set_long_press_time(this->drv_, long_press_time);
|
||||
// long press repeat time TBD
|
||||
lv_indev_set_long_press_repeat_time(this->drv_, long_press_repeat_time);
|
||||
lv_indev_set_user_data(this->drv_, this);
|
||||
lv_indev_set_read_cb(this->drv_, [](lv_indev_t *d, lv_indev_data_t *data) {
|
||||
auto *l = static_cast<LVTouchListener *>(lv_indev_get_user_data(d));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from esphome.components.const import CONF_LABEL
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_TEXT
|
||||
|
||||
@@ -14,8 +15,6 @@ from ..schemas import TEXT_SCHEMA
|
||||
from ..types import LvText
|
||||
from . import Widget, WidgetType
|
||||
|
||||
CONF_LABEL = "label"
|
||||
|
||||
|
||||
class LabelType(WidgetType):
|
||||
def __init__(self):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import gpio_expander, i2c
|
||||
from esphome.components import i2c
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
@@ -25,7 +25,7 @@ CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(MCP23016),
|
||||
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
|
||||
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import gpio_expander
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ALLOW_OTHER_USES,
|
||||
CONF_ID,
|
||||
CONF_INPUT,
|
||||
CONF_INTERRUPT,
|
||||
@@ -32,10 +32,28 @@ MCP23XXX_INTERRUPT_MODES = {
|
||||
}
|
||||
|
||||
|
||||
def _validate_interrupt_pin(value):
|
||||
# The MCP component owns INT polarity (active-low, hardcoded falling-edge ISR)
|
||||
# and installs a single ISR per GPIO, so neither inversion nor sharing is supported.
|
||||
value = pins.internal_gpio_input_pin_schema(value)
|
||||
if value.get(CONF_INVERTED):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; "
|
||||
"the MCP23xxx INT line is fixed active-low"
|
||||
)
|
||||
if value.get(CONF_ALLOW_OTHER_USES):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; "
|
||||
"sharing the interrupt pin between multiple MCP23xxx (or other components) "
|
||||
"is not implemented. Remove the interrupt_pin to fall back to polling."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
MCP23XXX_CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean,
|
||||
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
|
||||
cv.Optional(CONF_INTERRUPT_PIN): _validate_interrupt_pin,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
@@ -219,25 +219,14 @@ void ModbusServerHub::parse_modbus_frames() {
|
||||
this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true);
|
||||
}
|
||||
|
||||
uint16_t Modbus::find_frame_end_by_crc_(uint16_t min_length) const {
|
||||
// Unknown-length functions (user-defined codes, unimplemented management codes, unassigned values)
|
||||
// could be any length - we have to rely on the CRC to determine completeness.
|
||||
uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const {
|
||||
// Custom functions could be any length - we have to rely on the CRC to determine completeness.
|
||||
// If a CRC match is never found, the buffer will eventually overflow and be cleared.
|
||||
const uint8_t *raw = &this->rx_buffer_[0];
|
||||
const size_t size = this->rx_buffer_.size();
|
||||
const auto max_len = static_cast<uint16_t>(std::min(size, size_t(MAX_FRAME_SIZE)));
|
||||
if (min_length > max_len)
|
||||
return 0;
|
||||
// The Modbus CRC (poly 0xa001, refin/refout false) keeps its running state in the returned value,
|
||||
// so we seed once over the first min_length bytes and extend one byte at a time instead of
|
||||
// recomputing the whole prefix for every candidate length.
|
||||
uint16_t crc = crc16(raw, min_length);
|
||||
if (crc == 0)
|
||||
return min_length;
|
||||
for (uint16_t len = min_length; len < max_len; len++) {
|
||||
crc = crc16(&raw[len], 1, crc);
|
||||
if (crc == 0)
|
||||
return len + 1;
|
||||
for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) {
|
||||
if (crc16(raw, len) == 0)
|
||||
return len;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -252,11 +241,11 @@ bool Modbus::parse_modbus_server_frame_() {
|
||||
uint8_t address = this->rx_buffer_[0];
|
||||
uint8_t function_code = this->rx_buffer_[1];
|
||||
|
||||
if (helpers::is_function_code_unknown_length(function_code)) {
|
||||
frame_length = this->find_frame_end_by_crc_(frame_length);
|
||||
if (helpers::is_function_code_custom(function_code)) {
|
||||
frame_length = this->find_custom_frame_end_(frame_length);
|
||||
if (frame_length == 0)
|
||||
return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size
|
||||
ESP_LOGD(TAG, "Unknown-length function %02X found", function_code);
|
||||
ESP_LOGD(TAG, "User-defined function %02X found", function_code);
|
||||
} else {
|
||||
if (crc16(&this->rx_buffer_[0], frame_length) != 0)
|
||||
return false;
|
||||
@@ -283,11 +272,11 @@ bool ModbusServerHub::parse_modbus_client_frame_() {
|
||||
uint8_t address = this->rx_buffer_[0];
|
||||
uint8_t function_code = this->rx_buffer_[1];
|
||||
|
||||
if (helpers::is_function_code_unknown_length(function_code)) {
|
||||
frame_length = this->find_frame_end_by_crc_(frame_length);
|
||||
if (helpers::is_function_code_custom(function_code)) {
|
||||
frame_length = this->find_custom_frame_end_(frame_length);
|
||||
if (frame_length == 0)
|
||||
return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size
|
||||
ESP_LOGD(TAG, "Unknown-length function %02X found", function_code);
|
||||
ESP_LOGD(TAG, "User-defined function %02X found", function_code);
|
||||
} else {
|
||||
if (crc16(&this->rx_buffer_[0], frame_length) != 0)
|
||||
return false;
|
||||
|
||||
@@ -82,7 +82,7 @@ class Modbus : public uart::UARTDevice, public Component {
|
||||
bool send_frame_(const ModbusFrame &frame);
|
||||
// Scans forward from min_length to find a frame boundary by CRC match for custom function codes.
|
||||
// Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE.
|
||||
uint16_t find_frame_end_by_crc_(uint16_t min_length) const;
|
||||
uint16_t find_custom_frame_end_(uint16_t min_length) const;
|
||||
|
||||
uint32_t last_modbus_byte_{0};
|
||||
uint32_t last_receive_check_{0};
|
||||
|
||||
@@ -55,38 +55,6 @@ inline bool is_function_code_custom(uint8_t function_code) {
|
||||
masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END);
|
||||
}
|
||||
|
||||
/// True for any function code whose frame length the parsers cannot predict - everything the
|
||||
/// server_pdu_length()/client_pdu_length() switches fall through to `default` on (keep the case list
|
||||
/// in step with those switches). Deliberately wider than is_function_code_custom(): the user-defined
|
||||
/// ranges are unknown to the parser too, but so are the assigned-but-unimplemented codes
|
||||
/// (READ_EXCEPTION_STATUS, DIAGNOSTICS, GET_COMM_EVENT_*, REPORT_SERVER_ID) and every unassigned value.
|
||||
/// The 0x80 exception flag is masked off first, so a frame with it set classifies by its base code -
|
||||
/// even though a spec exception reply has a known 2-byte PDU. That is deliberate, matching what
|
||||
/// is_function_code_custom() has always done: some vendors use codes with the 0x80 bit set as ordinary
|
||||
/// codes with longer payloads, so the response parser CRC-scans these rather than assuming the spec
|
||||
/// length. For an intact spec exception the scan matches at its first candidate, so only a corrupt one
|
||||
/// pays (recovery by timeout instead of an immediate CRC failure).
|
||||
inline bool is_function_code_unknown_length(uint8_t function_code) {
|
||||
switch (static_cast<FunctionCode>(function_code & FUNCTION_CODE_MASK)) {
|
||||
case FunctionCode::READ_COILS:
|
||||
case FunctionCode::READ_DISCRETE_INPUTS:
|
||||
case FunctionCode::READ_HOLDING_REGISTERS:
|
||||
case FunctionCode::READ_INPUT_REGISTERS:
|
||||
case FunctionCode::WRITE_SINGLE_COIL:
|
||||
case FunctionCode::WRITE_SINGLE_REGISTER:
|
||||
case FunctionCode::WRITE_MULTIPLE_COILS:
|
||||
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
|
||||
case FunctionCode::READ_FILE_RECORD:
|
||||
case FunctionCode::WRITE_FILE_RECORD:
|
||||
case FunctionCode::MASK_WRITE_REGISTER:
|
||||
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
|
||||
case FunctionCode::READ_FIFO_QUEUE:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the expected length of a server response PDU based on the function code.
|
||||
// If too few bytes have arrived to determine the length, returns the minimum length. `size` is the
|
||||
// number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC
|
||||
|
||||
@@ -473,6 +473,9 @@ def copy_files() -> None:
|
||||
|
||||
def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
|
||||
"""Get the download types for the firmware."""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
types = []
|
||||
UF2_PATH = "zephyr/zephyr.uf2"
|
||||
DFU_PATH = "firmware.zip"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import gpio_expander, i2c
|
||||
from esphome.components import i2c
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
@@ -29,7 +29,7 @@ CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent),
|
||||
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
|
||||
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import gpio_expander, i2c
|
||||
from esphome.components import i2c
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
@@ -30,7 +30,7 @@ CONFIG_SCHEMA = (
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(PCA9554Component),
|
||||
cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16),
|
||||
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
|
||||
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import gpio_expander, i2c
|
||||
from esphome.components import i2c
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
@@ -28,7 +28,7 @@ CONFIG_SCHEMA = (
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(PCF8574Component),
|
||||
cv.Optional(CONF_PCF8575, default=False): cv.boolean,
|
||||
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
|
||||
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import gpio_expander, i2c
|
||||
from esphome.components import i2c
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
@@ -34,7 +34,7 @@ CONFIG_SCHEMA = (
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component),
|
||||
cv.Optional(CONF_RESET, default=True): cv.boolean,
|
||||
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
|
||||
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
@@ -156,6 +156,9 @@ def get_download_types(storage_json):
|
||||
the shape stable so the download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"title": "UF2 factory format",
|
||||
|
||||
@@ -107,10 +107,10 @@ void RP2040PIOLEDStripLightOutput::setup() {
|
||||
pio_get_dreq(this->pio_, this->sm_, true)); // set the DREQ to the state machine's TX FIFO
|
||||
|
||||
dma_channel_configure(this->dma_chan_, &this->dma_config_,
|
||||
&this->pio_->txf[this->sm_], // write to the state machine's TX FIFO
|
||||
this->buf_, // read from memory
|
||||
this->get_buffer_size_(), // number of bytes to transfer
|
||||
false // don't start yet
|
||||
&this->pio_->txf[this->sm_], // write to the state machine's TX FIFO
|
||||
this->buf_, // read from memory
|
||||
this->is_rgbw_ ? num_leds_ * 4 : num_leds_ * 3, // number of bytes to transfer
|
||||
false // don't start yet
|
||||
);
|
||||
|
||||
// Initialize the semaphore for this DMA channel
|
||||
@@ -142,25 +142,58 @@ void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) {
|
||||
}
|
||||
|
||||
light::ESPColorView RP2040PIOLEDStripLightOutput::get_view_internal(int32_t index) const {
|
||||
const light::ChannelColors &colors = this->channel_colors_;
|
||||
uint8_t *led = this->buf_ + (index * colors.bytes_per_led());
|
||||
return {led + colors.r,
|
||||
led + colors.g,
|
||||
led + colors.b,
|
||||
colors.has_white() ? led + colors.w : nullptr,
|
||||
int32_t r = 0, g = 0, b = 0;
|
||||
switch (this->rgb_order_) {
|
||||
case ORDER_RGB:
|
||||
r = 0;
|
||||
g = 1;
|
||||
b = 2;
|
||||
break;
|
||||
case ORDER_RBG:
|
||||
r = 0;
|
||||
g = 2;
|
||||
b = 1;
|
||||
break;
|
||||
case ORDER_GRB:
|
||||
r = 1;
|
||||
g = 0;
|
||||
b = 2;
|
||||
break;
|
||||
case ORDER_GBR:
|
||||
r = 2;
|
||||
g = 0;
|
||||
b = 1;
|
||||
break;
|
||||
case ORDER_BGR:
|
||||
r = 2;
|
||||
g = 1;
|
||||
b = 0;
|
||||
break;
|
||||
case ORDER_BRG:
|
||||
r = 1;
|
||||
g = 2;
|
||||
b = 0;
|
||||
break;
|
||||
}
|
||||
uint8_t multiplier = this->is_rgbw_ ? 4 : 3;
|
||||
return {this->buf_ + (index * multiplier) + r,
|
||||
this->buf_ + (index * multiplier) + g,
|
||||
this->buf_ + (index * multiplier) + b,
|
||||
this->is_rgbw_ ? this->buf_ + (index * multiplier) + 3 : nullptr,
|
||||
&this->effect_data_[index],
|
||||
&this->correction_};
|
||||
}
|
||||
|
||||
void RP2040PIOLEDStripLightOutput::dump_config() {
|
||||
char channel_colors[5];
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"RP2040 PIO LED Strip Light Output:\n"
|
||||
" Pin: GPIO%d\n"
|
||||
" Number of LEDs: %d\n"
|
||||
" Channel colors: %s\n"
|
||||
" RGBW: %s\n"
|
||||
" RGB Order: %s\n"
|
||||
" Max Refresh Rate: %f Hz",
|
||||
this->pin_, this->num_leds_, this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_);
|
||||
this->pin_, this->num_leds_, YESNO(this->is_rgbw_), rgb_order_to_string(this->rgb_order_),
|
||||
this->max_refresh_rate_);
|
||||
}
|
||||
|
||||
float RP2040PIOLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include "esphome/components/light/addressable_light.h"
|
||||
#include "esphome/components/light/channel_colors.h"
|
||||
#include "esphome/components/light/light_output.h"
|
||||
|
||||
#include <hardware/dma.h>
|
||||
@@ -19,6 +18,15 @@
|
||||
|
||||
namespace esphome::rp2040_pio_led_strip {
|
||||
|
||||
enum RGBOrder : uint8_t {
|
||||
ORDER_RGB,
|
||||
ORDER_RBG,
|
||||
ORDER_GRB,
|
||||
ORDER_GBR,
|
||||
ORDER_BGR,
|
||||
ORDER_BRG,
|
||||
};
|
||||
|
||||
enum Chipset : uint8_t {
|
||||
CHIPSET_WS2812,
|
||||
CHIPSET_WS2812B,
|
||||
@@ -28,6 +36,25 @@ enum Chipset : uint8_t {
|
||||
CHIPSET_CUSTOM = 0xFF,
|
||||
};
|
||||
|
||||
inline const char *rgb_order_to_string(RGBOrder order) {
|
||||
switch (order) {
|
||||
case ORDER_RGB:
|
||||
return "RGB";
|
||||
case ORDER_RBG:
|
||||
return "RBG";
|
||||
case ORDER_GRB:
|
||||
return "GRB";
|
||||
case ORDER_GBR:
|
||||
return "GBR";
|
||||
case ORDER_BGR:
|
||||
return "BGR";
|
||||
case ORDER_BRG:
|
||||
return "BRG";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq);
|
||||
|
||||
class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
|
||||
@@ -39,14 +66,13 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
|
||||
int32_t size() const override { return this->num_leds_; }
|
||||
light::LightTraits get_traits() override {
|
||||
auto traits = light::LightTraits();
|
||||
this->channel_colors_.has_white()
|
||||
? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE})
|
||||
: traits.set_supported_color_modes({light::ColorMode::RGB});
|
||||
this->is_rgbw_ ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE})
|
||||
: traits.set_supported_color_modes({light::ColorMode::RGB});
|
||||
return traits;
|
||||
}
|
||||
void set_pin(uint8_t pin) { this->pin_ = pin; }
|
||||
void set_num_leds(uint32_t num_leds) { this->num_leds_ = num_leds; }
|
||||
void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; }
|
||||
void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; }
|
||||
|
||||
void set_max_refresh_rate(float interval_us) { this->max_refresh_rate_ = interval_us; }
|
||||
|
||||
@@ -55,6 +81,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
|
||||
void set_init_function(init_fn init) { this->init_ = init; }
|
||||
|
||||
void set_chipset(Chipset chipset) { this->chipset_ = chipset; };
|
||||
void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; }
|
||||
void clear_effect_data() override {
|
||||
for (int i = 0; i < this->size(); i++) {
|
||||
this->effect_data_[i] = 0;
|
||||
@@ -66,7 +93,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
|
||||
protected:
|
||||
light::ESPColorView get_view_internal(int32_t index) const override;
|
||||
|
||||
size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); }
|
||||
size_t get_buffer_size_() const { return this->num_leds_ * (3 + this->is_rgbw_); }
|
||||
|
||||
static void dma_write_complete_handler();
|
||||
|
||||
@@ -75,13 +102,14 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
|
||||
|
||||
uint8_t pin_;
|
||||
uint32_t num_leds_;
|
||||
bool is_rgbw_;
|
||||
|
||||
pio_hw_t *pio_;
|
||||
uint sm_;
|
||||
uint dma_chan_;
|
||||
dma_channel_config dma_config_;
|
||||
|
||||
light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE};
|
||||
RGBOrder rgb_order_{ORDER_RGB};
|
||||
Chipset chipset_{CHIPSET_CUSTOM};
|
||||
|
||||
uint32_t last_refresh_{0};
|
||||
|
||||
@@ -3,7 +3,6 @@ from dataclasses import dataclass
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import light, rp2
|
||||
from esphome.components.const import CONF_CHANNEL_COLORS
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CHIPSET,
|
||||
@@ -14,7 +13,6 @@ from esphome.const import (
|
||||
CONF_PIN,
|
||||
CONF_RGB_ORDER,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
from esphome.util import _LOGGER
|
||||
|
||||
|
||||
@@ -39,7 +37,7 @@ def get_nops(timing):
|
||||
return nops
|
||||
|
||||
|
||||
def generate_assembly_code(id, t0h, t0l, t1h, t1l):
|
||||
def generate_assembly_code(id, rgbw, t0h, t0l, t1h, t1l):
|
||||
"""
|
||||
Generate assembly code with the given timing values.
|
||||
"""
|
||||
@@ -141,6 +139,8 @@ RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_(
|
||||
"RP2040PIOLEDStripLightOutput", light.AddressableLight
|
||||
)
|
||||
|
||||
RGBOrder = rp2040_pio_led_strip_ns.enum("RGBOrder")
|
||||
|
||||
Chipset = rp2040_pio_led_strip_ns.enum("Chipset")
|
||||
|
||||
CHIPSETS = {
|
||||
@@ -159,6 +159,15 @@ class LEDStripTimings:
|
||||
T1L: int
|
||||
|
||||
|
||||
RGB_ORDERS = {
|
||||
"RGB": RGBOrder.ORDER_RGB,
|
||||
"RBG": RGBOrder.ORDER_RBG,
|
||||
"GRB": RGBOrder.ORDER_GRB,
|
||||
"GBR": RGBOrder.ORDER_GBR,
|
||||
"BGR": RGBOrder.ORDER_BGR,
|
||||
"BRG": RGBOrder.ORDER_BRG,
|
||||
}
|
||||
|
||||
CHIPSET_TIMINGS = {
|
||||
"WS2812": LEDStripTimings(20, 40, 46, 34),
|
||||
"WS2812B": LEDStripTimings(23, 49, 46, 26),
|
||||
@@ -190,12 +199,10 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(RP2040PIOLEDStripLightOutput),
|
||||
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number,
|
||||
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors,
|
||||
# Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0
|
||||
cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True),
|
||||
cv.Optional(CONF_IS_RGBW): cv.boolean,
|
||||
cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True),
|
||||
cv.Required(CONF_PIO): cv.one_of(0, 1, int=True),
|
||||
cv.Optional(CONF_CHIPSET): cv.enum(CHIPSETS, upper=True),
|
||||
cv.Optional(CONF_IS_RGBW, default=False): cv.boolean,
|
||||
cv.Inclusive(
|
||||
CONF_BIT0_HIGH,
|
||||
"custom",
|
||||
@@ -215,13 +222,10 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
),
|
||||
cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH),
|
||||
light.migrate_channel_colors(
|
||||
removed_in="2027.3.0", component="rp2040_pio_led_strip"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
|
||||
id = config[CONF_ID].id
|
||||
await light.register_light(var, config)
|
||||
@@ -230,9 +234,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_num_leds(config[CONF_NUM_LEDS]))
|
||||
cg.add(var.set_pin(config[CONF_PIN]))
|
||||
|
||||
cg.add(
|
||||
var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS]))
|
||||
)
|
||||
cg.add(var.set_rgb_order(config[CONF_RGB_ORDER]))
|
||||
cg.add(var.set_is_rgbw(config[CONF_IS_RGBW]))
|
||||
|
||||
cg.add(var.set_pio(config[CONF_PIO]))
|
||||
cg.add(var.set_program(cg.RawExpression(f"&rp2040_pio_led_strip_{id}_program")))
|
||||
@@ -252,6 +255,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
key,
|
||||
generate_assembly_code(
|
||||
id,
|
||||
config[CONF_IS_RGBW],
|
||||
CHIPSET_TIMINGS[chipset].T0H,
|
||||
CHIPSET_TIMINGS[chipset].T0L,
|
||||
CHIPSET_TIMINGS[chipset].T1H,
|
||||
@@ -266,6 +270,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
key,
|
||||
generate_assembly_code(
|
||||
id,
|
||||
config[CONF_IS_RGBW],
|
||||
time_to_cycles(config[CONF_BIT0_HIGH]),
|
||||
time_to_cycles(config[CONF_BIT0_LOW]),
|
||||
time_to_cycles(config[CONF_BIT1_HIGH]),
|
||||
|
||||
@@ -5,7 +5,6 @@ from esphome.components.const import CONF_BYTE_ORDER
|
||||
from esphome.components.image import (
|
||||
IMAGE_TYPE,
|
||||
Image_,
|
||||
validate_byte_order,
|
||||
validate_settings,
|
||||
validate_transparency,
|
||||
validate_type,
|
||||
@@ -129,7 +128,9 @@ def runtime_image_schema(image_class: cg.MockObjClass = RuntimeImage) -> cv.Sche
|
||||
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True),
|
||||
cv.Optional(CONF_RESIZE): cv.dimensions,
|
||||
cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE),
|
||||
cv.Optional(CONF_BYTE_ORDER): validate_byte_order,
|
||||
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
|
||||
"BIG_ENDIAN", "LITTLE_ENDIAN", upper=True
|
||||
),
|
||||
cv.Optional(CONF_TRANSPARENCY, default="OPAQUE"): validate_transparency(),
|
||||
cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_),
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import runtime_image
|
||||
from esphome.components.const import CONF_SLOT
|
||||
from esphome.components.image import CONF_TRANSPARENCY, Image_, add_metadata
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -45,7 +46,6 @@ MAX_IMAGE_DIMENSION = 32767
|
||||
MAX_DISPLAY_OFFSET = cv.TimePeriod(seconds=60)
|
||||
MIN_DISPLAY_OFFSET = cv.TimePeriod(seconds=-60)
|
||||
|
||||
CONF_SLOT = "slot"
|
||||
CONF_CURRENT_IMAGE = "current_image"
|
||||
CONF_TRANSITION_IMAGE = "transition_image"
|
||||
CONF_ON_IMAGE_DISPLAY = "on_image_display"
|
||||
|
||||
@@ -45,11 +45,6 @@ namespace esphome::socket {
|
||||
|
||||
static const char *const TAG = "socket.lwip";
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot.
|
||||
static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000;
|
||||
#endif
|
||||
|
||||
// set to 1 to enable verbose lwip logging
|
||||
#if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if)
|
||||
#define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__)
|
||||
@@ -540,14 +535,6 @@ ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) {
|
||||
}
|
||||
|
||||
ssize_t LWIPRawImpl::read(void *buf, size_t len) {
|
||||
#ifdef USE_ESP8266
|
||||
// Would block: yield to SYS so queued WiFi RX reaches lwip and this read
|
||||
// may succeed. Without this, inbound segments can sit unprocessed for
|
||||
// seconds while the main loop polls (CONT/SYS are cooperative on ESP8266).
|
||||
if (this->waiting_for_data_()) {
|
||||
optimistic_yield(ESP8266_YIELD_INTERVAL_US);
|
||||
}
|
||||
#endif
|
||||
// See waiting_for_data_() for safety of unlocked reads.
|
||||
if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) {
|
||||
this->wait_for_data_();
|
||||
@@ -558,8 +545,6 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) {
|
||||
}
|
||||
|
||||
ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) {
|
||||
// No ESP8266 SYS yield here: only read() needs it today. If a consumer
|
||||
// switches to scatter-gather reads, mirror the yield from read().
|
||||
// See waiting_for_data_() for safety of unlocked reads.
|
||||
if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) {
|
||||
this->wait_for_data_();
|
||||
@@ -624,24 +609,19 @@ int LWIPRawImpl::internal_output_() {
|
||||
}
|
||||
LWIP_LOG("tcp_output(%p)", this->pcb_);
|
||||
err_t err = tcp_output(this->pcb_);
|
||||
if (err == ERR_ABRT) {
|
||||
// sometimes lwip returns ERR_ABRT for no apparent reason
|
||||
// the connection works fine afterwards, and back with ESPAsyncTCP we
|
||||
// indirectly also ignored this error
|
||||
// FIXME: figure out where this is returned and what it means in this context
|
||||
LWIP_LOG(" -> err ERR_ABRT");
|
||||
return 0;
|
||||
}
|
||||
if (err != ERR_OK) {
|
||||
LWIP_LOG(" -> err %d", err);
|
||||
// ERR_ABRT: sometimes lwip returns it for no apparent reason; the
|
||||
// connection works fine afterwards, and back with ESPAsyncTCP we
|
||||
// indirectly also ignored this error, so treat it as success for
|
||||
// flush purposes too.
|
||||
// FIXME: figure out where this is returned and what it means in this context
|
||||
if (err != ERR_ABRT) {
|
||||
errno = ECONNRESET;
|
||||
return -1;
|
||||
}
|
||||
errno = ECONNRESET;
|
||||
return -1;
|
||||
}
|
||||
#ifdef USE_ESP8266
|
||||
// Flushed: yield to SYS so the queued segments reach the WiFi driver
|
||||
// instead of waiting seconds for an unrelated SYS slot. Callers only get
|
||||
// here after a successful tcp_write, so idle paths never yield.
|
||||
optimistic_yield(ESP8266_YIELD_INTERVAL_US);
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import gpio_expander, i2c
|
||||
from esphome.components import i2c
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
@@ -28,7 +28,7 @@ CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(TCA9555Component),
|
||||
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
|
||||
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
@@ -620,23 +620,6 @@ class LoadValidationStep(ConfigValidationStep):
|
||||
elif not isinstance(self.conf, list):
|
||||
result[self.domain] = self.conf = [self.conf]
|
||||
|
||||
# Permanent expansion hook: a platform-tagged entry may expand into
|
||||
# several (e.g. `image`'s `defaults:`/`files:`), for `platform:`-tagged dicts only.
|
||||
if (expand := component.expand_platform_config) is not None and all(
|
||||
isinstance(entry, dict) and CONF_PLATFORM in entry
|
||||
for entry in self.conf
|
||||
):
|
||||
with result.catch_error(path):
|
||||
expanded = expand(self.conf)
|
||||
if not isinstance(expanded, list):
|
||||
# A non-list return is a component bug (not a user error):
|
||||
# raise explicitly (survives -O/-OO) so it escapes catch_error.
|
||||
raise TypeError(
|
||||
f"{self.domain}: EXPAND_PLATFORM_CONFIG must "
|
||||
f"return a list, got {type(expanded).__name__}"
|
||||
)
|
||||
result[self.domain] = self.conf = expanded
|
||||
|
||||
# Process AUTO_LOAD
|
||||
_process_auto_load(result, component, path)
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.8.0"
|
||||
__version__ = "2026.9.0-dev"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
@@ -164,14 +164,6 @@ class ComponentManifest:
|
||||
"""
|
||||
return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None)
|
||||
|
||||
@property
|
||||
def expand_platform_config(
|
||||
self,
|
||||
) -> Callable[[list[ConfigType]], list[ConfigType]] | None:
|
||||
"""Optional `EXPAND_PLATFORM_CONFIG` callable; runs on the normalized `platform:`-tagged
|
||||
entry list before per-entry CONFIG_SCHEMA. Must return a list (raise `cv.Invalid` for user errors)."""
|
||||
return getattr(self.module, "EXPAND_PLATFORM_CONFIG", None)
|
||||
|
||||
@property
|
||||
def resources(self) -> list[FileResource]:
|
||||
"""Return a list of all file resources defined in the package of this component.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import shutil
|
||||
|
||||
# pylint: disable=E0602
|
||||
Import("env") # noqa
|
||||
@@ -8,17 +9,15 @@ Import("env") # noqa
|
||||
# esphome/platformio/toolchain.py); this script only supplies the SCons-level
|
||||
# mechanism.
|
||||
#
|
||||
# The binary comes pre-resolved in ESPHOME_CCACHE_PATH; _ccache_env() has
|
||||
# already stripped the Windows \\?\ prefix that cmd.exe cannot run.
|
||||
#
|
||||
# This is a "pre" script, so the platform's builder (which sets CC/CXX and
|
||||
# clones the construction environment for framework and library builds) runs
|
||||
# after it. Replacing CC/CXX here would be overwritten, and replacing them in
|
||||
# a "post" script would miss the already-cloned library environments. Wrapping
|
||||
# SPAWN instead is ordering-proof: clones copy the wrapper, and every compiler
|
||||
# invocation from every environment funnels through it at execution time.
|
||||
if os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" and (
|
||||
ccache_path := os.environ.get("ESPHOME_CCACHE_PATH")
|
||||
if (
|
||||
os.environ.get("ESPHOME_CCACHE_ENABLE") == "1"
|
||||
and (ccache_path := shutil.which("ccache")) is not None
|
||||
):
|
||||
original_spawn = env["SPAWN"]
|
||||
|
||||
|
||||
@@ -60,9 +60,6 @@ def _strip_win_long_path_prefix(path: str) -> str:
|
||||
"The system cannot find the path specified." Stripping the prefix early
|
||||
keeps the path shell-quotable.
|
||||
|
||||
Also applied to the ccache path exported by ``_ccache_env()``, which
|
||||
``shutil.which`` can return with the same prefix.
|
||||
|
||||
No-op on non-Windows platforms.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
@@ -238,8 +235,8 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None:
|
||||
_write_pio_stamp_python(stamp_file, current)
|
||||
|
||||
|
||||
def _ccache_runs(ccache: str) -> bool:
|
||||
"""Return True when the ``ccache`` found on PATH actually runs.
|
||||
def _ccache_usable() -> bool:
|
||||
"""Return True when the ``ccache`` on PATH actually runs.
|
||||
|
||||
``shutil.which`` proves existence, not runnability: on Windows it also
|
||||
matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose
|
||||
@@ -247,6 +244,9 @@ def _ccache_runs(ccache: str) -> bool:
|
||||
step with an opaque OS error, so probe once and fall back to compiling
|
||||
without ccache when the probe fails.
|
||||
"""
|
||||
ccache = shutil.which("ccache")
|
||||
if ccache is None:
|
||||
return False
|
||||
try:
|
||||
subprocess.run(
|
||||
[ccache, "--version"],
|
||||
@@ -265,29 +265,14 @@ def _ccache_runs(ccache: str) -> bool:
|
||||
|
||||
|
||||
def _ccache_env() -> dict[str, str]:
|
||||
r"""Return ccache settings for PlatformIO builds.
|
||||
"""Return ccache settings for PlatformIO builds.
|
||||
|
||||
Enabled by default whenever the ``ccache`` binary is on PATH; set
|
||||
``ESPHOME_CCACHE_ENABLE=0`` in the environment to opt out (or ``1`` to
|
||||
force it on without the runnability probe; a binary is still needed).
|
||||
The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` and the
|
||||
binary's location into ``ESPHOME_CCACHE_PATH`` so platform build scripts
|
||||
(the shared ``ccache.py`` extra script, which wraps compiler invocations
|
||||
inside SCons) only have to check for ``"1"`` and use the path as given
|
||||
instead of re-implementing the policy.
|
||||
|
||||
The path is exported rather than looked up again inside SCons because
|
||||
``shutil.which`` can return a Windows extended-length ``\\?\`` path
|
||||
(ESPHome Desktop puts its bundled ccache on PATH that way). Such a path
|
||||
runs fine through ``CreateProcess``, which is how ESP-IDF invokes it,
|
||||
but SCons runs every compile through ``cmd.exe``, which fails on it with
|
||||
"The system cannot find the path specified." (#18399), so the prefix is
|
||||
stripped here with ``_strip_win_long_path_prefix()`` before the
|
||||
runnability probe, which therefore validates the exact string the build
|
||||
will execute.
|
||||
``ESPHOME_CCACHE_PATH`` is an internal channel, not a user setting: the
|
||||
script only honours it together with ``ESPHOME_CCACHE_ENABLE=1``, and this
|
||||
function always sets both or neither.
|
||||
force it on). The decision is normalized into ``ESPHOME_CCACHE_ENABLE``
|
||||
so platform build scripts (e.g. the esp8266 ``ccache.py`` extra script,
|
||||
which wraps compiler invocations inside SCons) only have to check for
|
||||
``"1"`` instead of re-implementing the policy.
|
||||
|
||||
The returned values are merged into the environment of the PlatformIO
|
||||
subprocess only, never into ``os.environ``: a long-running process
|
||||
@@ -308,27 +293,13 @@ def _ccache_env() -> dict[str, str]:
|
||||
build dir. The other ``CCACHE_*`` values the user already set in the
|
||||
environment are respected.
|
||||
"""
|
||||
explicit = "ESPHOME_CCACHE_ENABLE" in os.environ
|
||||
if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"):
|
||||
return {"ESPHOME_CCACHE_ENABLE": "0"}
|
||||
ccache_path = shutil.which("ccache")
|
||||
if ccache_path is None:
|
||||
if explicit:
|
||||
_LOGGER.warning(
|
||||
"ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; "
|
||||
"compiling without ccache"
|
||||
)
|
||||
return {"ESPHOME_CCACHE_ENABLE": "0"}
|
||||
# Strip before probing so the probe validates (and the failure warning
|
||||
# names) the exact string the build will execute through cmd.exe.
|
||||
ccache_path = _strip_win_long_path_prefix(ccache_path)
|
||||
# An explicit opt-in skips the runnability probe.
|
||||
if not explicit and not _ccache_runs(ccache_path):
|
||||
return {"ESPHOME_CCACHE_ENABLE": "0"}
|
||||
env = {
|
||||
"ESPHOME_CCACHE_ENABLE": "1",
|
||||
"ESPHOME_CCACHE_PATH": ccache_path,
|
||||
}
|
||||
if "ESPHOME_CCACHE_ENABLE" in os.environ:
|
||||
enabled = get_bool_env("ESPHOME_CCACHE_ENABLE")
|
||||
else:
|
||||
enabled = _ccache_usable()
|
||||
env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"}
|
||||
if not enabled:
|
||||
return env
|
||||
# build_path is set during preload for every config-loading command, so it
|
||||
# being unset means a caller built the environment too early; fail loudly
|
||||
# rather than with an opaque TypeError from Path(None).
|
||||
|
||||
+48
-8
@@ -71,8 +71,11 @@ def archive_storage_path() -> Path:
|
||||
|
||||
|
||||
def _to_path_if_not_none(value: str | None) -> Path | None:
|
||||
"""Convert a string to Path if it's not None."""
|
||||
return Path(value) if value is not None else None
|
||||
"""Convert a string to Path; None and the legacy "None" both map to None.
|
||||
|
||||
Sidecars written before as_dict skipped unset paths hold str(None).
|
||||
"""
|
||||
return Path(value) if value is not None and value != "None" else None
|
||||
|
||||
|
||||
def _parse_framework_version(framework_version: str) -> Version:
|
||||
@@ -170,8 +173,10 @@ class StorageJSON:
|
||||
"address": self.address,
|
||||
"web_port": self.web_port,
|
||||
"esp_platform": self.target_platform,
|
||||
"build_path": str(self.build_path),
|
||||
"firmware_bin_path": str(self.firmware_bin_path),
|
||||
"build_path": str(self.build_path) if self.build_path else None,
|
||||
"firmware_bin_path": (
|
||||
str(self.firmware_bin_path) if self.firmware_bin_path else None
|
||||
),
|
||||
"loaded_integrations": sorted(self.loaded_integrations),
|
||||
"loaded_platforms": sorted(self.loaded_platforms),
|
||||
"no_mdns": self.no_mdns,
|
||||
@@ -189,7 +194,18 @@ class StorageJSON:
|
||||
write_file_if_changed(path, self.to_json())
|
||||
|
||||
@staticmethod
|
||||
def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON:
|
||||
def from_esphome_core(
|
||||
esph: CoreType, old: StorageJSON | None, *, claim_build: bool = True
|
||||
) -> StorageJSON:
|
||||
"""Build a sidecar from post-validation CORE state.
|
||||
|
||||
claim_build=False (the upload/logs fallback, which runs no build)
|
||||
carries the build-artifact fields (esphome_version,
|
||||
firmware_bin_path) from *old* instead of asserting this run built
|
||||
firmware. Validation-derived fields (platform, framework_version,
|
||||
toolchain, build_path) always stamp; storage_should_clean compares
|
||||
them against the next compile.
|
||||
"""
|
||||
hardware = esph.target_platform.upper()
|
||||
framework_version: str | None = None
|
||||
if esph.is_esp32:
|
||||
@@ -204,13 +220,21 @@ class StorageJSON:
|
||||
name=esph.name,
|
||||
friendly_name=esph.friendly_name,
|
||||
comment=esph.comment,
|
||||
esphome_version=const.__version__,
|
||||
esphome_version=(
|
||||
const.__version__
|
||||
if claim_build
|
||||
else (old.esphome_version if old else None)
|
||||
),
|
||||
src_version=1,
|
||||
address=esph.address,
|
||||
web_port=esph.web_port,
|
||||
target_platform=hardware,
|
||||
build_path=esph.build_path,
|
||||
firmware_bin_path=esph.firmware_bin,
|
||||
firmware_bin_path=(
|
||||
esph.firmware_bin
|
||||
if claim_build
|
||||
else (old.firmware_bin_path if old else None)
|
||||
),
|
||||
loaded_integrations=esph.loaded_integrations,
|
||||
loaded_platforms=esph.loaded_platforms,
|
||||
no_mdns=(
|
||||
@@ -302,11 +326,27 @@ class StorageJSON:
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def load_strict(path: Path) -> StorageJSON | None:
|
||||
"""Like load, but None only means missing; an unreadable file raises."""
|
||||
if not path.is_file():
|
||||
return None
|
||||
return StorageJSON._load_impl(path)
|
||||
|
||||
def can_apply_to_core(self) -> bool:
|
||||
"""True when the sidecar carries everything apply_to_core hands CORE.
|
||||
|
||||
Wizard-written sidecars leave build_path unset (older wizards also
|
||||
the platform fields) and can't drive upload/logs.
|
||||
"""
|
||||
return bool((self.core_platform or self.target_platform) and self.build_path)
|
||||
|
||||
def apply_to_core(self) -> None:
|
||||
"""Populate CORE with the metadata upload/logs read.
|
||||
|
||||
Inverse of :meth:`from_esphome_core`. Keep paired -- a new
|
||||
attribute upload/logs needs has to be captured there too.
|
||||
attribute upload/logs needs has to be captured there too and
|
||||
reflected in :meth:`can_apply_to_core`.
|
||||
Validator-only fields (loaded_integrations/platforms,
|
||||
friendly_name) are skipped; the fast path doesn't run
|
||||
validation and CORE.__init__ defaults them.
|
||||
|
||||
+2
-18
@@ -3,14 +3,12 @@ from __future__ import annotations
|
||||
from io import StringIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
from esphome.config import Config, _format_vol_invalid, validate_config
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import __version__ as ESPHOME_VERSION
|
||||
from esphome.core import CORE, DocumentRange, EsphomeError
|
||||
from esphome.core import CORE, DocumentRange
|
||||
from esphome.yaml_util import parse_yaml
|
||||
|
||||
|
||||
@@ -99,16 +97,6 @@ def _ace_loader(fname: Path) -> dict[str, Any]:
|
||||
return parse_yaml(fname, raw_yaml_stream)
|
||||
|
||||
|
||||
def _format_unexpected_error(err: Exception) -> str:
|
||||
"""Describe a crash inside validation with the frame it came from."""
|
||||
message = f"Unexpected error while validating: {type(err).__name__}: {err}"
|
||||
frames = traceback.extract_tb(err.__traceback__)
|
||||
if not frames:
|
||||
return message
|
||||
frame = frames[-1]
|
||||
return f"{message} ({frame.filename}:{frame.lineno} in {frame.name})"
|
||||
|
||||
|
||||
def _print_version():
|
||||
"""Print ESPHome version."""
|
||||
print(
|
||||
@@ -146,12 +134,8 @@ def read_config(args):
|
||||
try:
|
||||
config = loader(file_name)
|
||||
res = validate_config(config, command_line_substitutions)
|
||||
except (EsphomeError, cv.Invalid) as err:
|
||||
vs.add_yaml_error(str(err))
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# stdout carries the JSON protocol; the full chain goes to stderr.
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
vs.add_yaml_error(_format_unexpected_error(err))
|
||||
vs.add_yaml_error(str(err))
|
||||
else:
|
||||
for err in res.errors:
|
||||
try:
|
||||
|
||||
+3
-3
@@ -45,7 +45,7 @@ lib_deps_base =
|
||||
lib_deps =
|
||||
${common.lib_deps_base}
|
||||
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
|
||||
esphome/noise-c@0.1.21 ; api
|
||||
esphome/noise-c@0.1.11 ; api
|
||||
improv/Improv@1.2.6 ; improv_serial / esp32_improv
|
||||
kikuchan98/pngle@1.1.0 ; online_image
|
||||
; Using the repository directly, otherwise ESP-IDF can't use the library
|
||||
@@ -244,7 +244,7 @@ lib_deps =
|
||||
${common:idf-component-libs.lib_deps}
|
||||
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
|
||||
droscy/esp_wireguard@0.4.5 ; wireguard
|
||||
esphome/noise-c@0.1.21 ; api
|
||||
esphome/noise-c@0.1.11 ; api
|
||||
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
|
||||
DNSServer ; captive_portal
|
||||
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
|
||||
@@ -641,7 +641,7 @@ build_unflags =
|
||||
extends = common
|
||||
platform = platformio/native
|
||||
lib_deps =
|
||||
esphome/noise-c@0.1.21 ; used by api
|
||||
esphome/noise-c@0.1.11 ; used by api
|
||||
lvgl/lvgl@9.5.0 ; lvgl
|
||||
build_flags =
|
||||
${common.build_flags}
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@ pyserial==3.5
|
||||
platformio==6.1.19
|
||||
esptool==5.3.1
|
||||
click==8.3.3
|
||||
aioesphomeapi==45.10.3
|
||||
aioesphomeapi==45.10.2
|
||||
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
||||
zeroconf==0.150.0
|
||||
puremagic==2.2.0
|
||||
@@ -23,11 +23,11 @@ pillow==12.3.0
|
||||
resvg-py==0.3.4
|
||||
freetype-py==2.5.1
|
||||
jinja2==3.1.6
|
||||
bleak==2.1.1
|
||||
bleak==3.0.2
|
||||
smpclient==7.2.0
|
||||
requests==2.34.2
|
||||
py7zr==1.1.3
|
||||
platformdirs==4.11.1 # native esp-idf toolchain global cache dir
|
||||
platformdirs==4.11.2 # native esp-idf toolchain global cache dir
|
||||
filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
|
||||
|
||||
# esp-idf >= 5.0 requires this
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
pylint==4.0.6
|
||||
pylint==4.0.7
|
||||
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
|
||||
ruff==0.16.2 # also change in .pre-commit-config.yaml when updating
|
||||
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
|
||||
prek==0.4.12 # also change in .github/workflows/ci.yml when updating
|
||||
prek==0.4.13 # also change in .github/workflows/ci.yml when updating
|
||||
|
||||
# Unit tests
|
||||
pytest==9.1.1
|
||||
|
||||
@@ -250,16 +250,6 @@ def add_pin_validators():
|
||||
"modes": ["input"],
|
||||
}
|
||||
|
||||
from esphome.components import gpio_expander
|
||||
|
||||
# Wraps pins.internal_gpio_input_pin_schema, so the editor schema must keep
|
||||
# treating the config var as a pin
|
||||
pin_validators[repr(gpio_expander.validate_interrupt_pin)] = {
|
||||
"schema": True,
|
||||
"internal": True,
|
||||
"modes": ["input"],
|
||||
}
|
||||
|
||||
|
||||
def add_module_registries(domain, module):
|
||||
for attr_name in dir(module):
|
||||
|
||||
@@ -15,7 +15,6 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# components have hardware dependencies (BLE/UART/RMT); lightweight
|
||||
# stub headers in tests/benchmarks/stubs/ satisfy the includes.
|
||||
cg.add_define("USE_BLUETOOTH_PROXY")
|
||||
cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS")
|
||||
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3)
|
||||
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
|
||||
cg.add_define("USE_ZWAVE_PROXY")
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Tests for the esp32_hosted ESP-IDF version gate."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import KEY_IDF_VERSION
|
||||
from esphome.components.esp32_hosted import _final_validate
|
||||
from esphome.const import PlatformFramework
|
||||
|
||||
from ..types import SetCoreConfigCallable
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idf", ["5.3.0", "5.4.2", "5.5.5"])
|
||||
def test_final_validate_accepts_supported_idf(
|
||||
set_core_config: SetCoreConfigCallable, idf: str
|
||||
) -> None:
|
||||
"""ESP-IDF 5.3 and newer passes validation unchanged."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
|
||||
)
|
||||
assert _final_validate({}) == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"])
|
||||
def test_final_validate_rejects_old_idf(
|
||||
set_core_config: SetCoreConfigCallable, idf: str
|
||||
) -> None:
|
||||
"""ESP-IDF older than 5.3 is rejected with a clear error."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.3 or newer"):
|
||||
_final_validate({})
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Tests for the shared io expander interrupt_pin validator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
|
||||
from esphome.components.gpio_expander import validate_interrupt_pin
|
||||
from esphome.const import PlatformFramework
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stage_esp32(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
|
||||
def test_plain_pin_accepted(stage_esp32: None) -> None:
|
||||
value = validate_interrupt_pin(
|
||||
{"number": 16, "mode": {"input": True, "pullup": True}}
|
||||
)
|
||||
assert value["number"] == 16
|
||||
|
||||
|
||||
def test_inverted_rejected(stage_esp32: None) -> None:
|
||||
with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"):
|
||||
validate_interrupt_pin({"number": 16, "inverted": True})
|
||||
|
||||
|
||||
def test_allow_other_uses_rejected(stage_esp32: None) -> None:
|
||||
with pytest.raises(cv.Invalid, match="'allow_other_uses: true' is not supported"):
|
||||
validate_interrupt_pin({"number": 16, "allow_other_uses": True})
|
||||
|
||||
|
||||
# mcp23017 covers the shared mcp23xxx_base schema
|
||||
@pytest.mark.parametrize(
|
||||
"component",
|
||||
[
|
||||
"pcf8574",
|
||||
"pca9554",
|
||||
"tca9555",
|
||||
"pca6416a",
|
||||
"pi4ioe5v6408",
|
||||
"mcp23016",
|
||||
"mcp23017",
|
||||
],
|
||||
)
|
||||
def test_component_schemas_route_through_validator(
|
||||
stage_esp32: None, component: str
|
||||
) -> None:
|
||||
module = importlib.import_module(f"esphome.components.{component}")
|
||||
with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"):
|
||||
module.CONFIG_SCHEMA(
|
||||
{"id": "expander_hub", "interrupt_pin": {"number": 16, "inverted": True}}
|
||||
)
|
||||
@@ -21,20 +21,16 @@ from esphome.components.image import (
|
||||
CONF_OPAQUE,
|
||||
CONF_TRANSPARENCY,
|
||||
PLATFORM_FILE,
|
||||
_expand_platform_entry,
|
||||
_flatten_legacy_image_config,
|
||||
_is_legacy_image_format,
|
||||
_is_new_image_format,
|
||||
_migrate_legacy_image_config,
|
||||
expand_platform_config,
|
||||
get_all_image_metadata,
|
||||
get_image_metadata,
|
||||
)
|
||||
from esphome.const import (
|
||||
CONF_DEFAULTS,
|
||||
CONF_DITHER,
|
||||
CONF_FILE,
|
||||
CONF_FILES,
|
||||
CONF_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_RAW_DATA_ID,
|
||||
@@ -263,15 +259,6 @@ def test_flatten_keeps_byte_order_for_endian_type() -> None:
|
||||
assert out[0][CONF_BYTE_ORDER] == "little_endian"
|
||||
|
||||
|
||||
def test_flatten_drops_byte_order_written_directly_on_legacy_entry() -> None:
|
||||
"""The legacy flattener drops an incompatible byte_order even when written directly on the entry."""
|
||||
out = _flatten_legacy_image_config(
|
||||
{"binary": [{"id": "a", "file": "x.png", "byte_order": "little_endian"}]}
|
||||
)
|
||||
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
|
||||
assert CONF_BYTE_ORDER not in out[0]
|
||||
|
||||
|
||||
def test_flatten_skips_meta_and_unknown_keys() -> None:
|
||||
out = _flatten_legacy_image_config(
|
||||
{
|
||||
@@ -355,42 +342,6 @@ def test_migrate_legacy_warns_and_prepends_platform(
|
||||
),
|
||||
pytest.param({"foo": 1}, False, id="dict_unknown_keys"),
|
||||
pytest.param("a string", False, id="scalar"),
|
||||
# A `platform:`-tagged dict is the new format written without list brackets.
|
||||
pytest.param(
|
||||
{CONF_PLATFORM: "file", "id": "a", "file": "x.png"},
|
||||
False,
|
||||
id="platform_tagged_flat_dict",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
CONF_PLATFORM: "file",
|
||||
"defaults": {"type": "rgb565"},
|
||||
"files": [{"id": "a", "file": "x.png"}],
|
||||
},
|
||||
False,
|
||||
id="platform_tagged_defaults_files_dict",
|
||||
),
|
||||
# `files:` without `platform:` is not legacy either -- the flattener has no branch for it.
|
||||
pytest.param(
|
||||
{
|
||||
"defaults": {"type": "rgb565"},
|
||||
"files": [{"id": "a", "file": "x.png"}],
|
||||
},
|
||||
False,
|
||||
id="defaults_files_dict_without_platform",
|
||||
),
|
||||
# Same as above in a list -- without this exclusion it would be silently
|
||||
# migrated to a hard-coded `platform: file` instead of raising the error.
|
||||
pytest.param(
|
||||
[
|
||||
{
|
||||
"defaults": {"type": "rgb565"},
|
||||
"files": [{"id": "a", "file": "x.png"}],
|
||||
}
|
||||
],
|
||||
False,
|
||||
id="defaults_files_list_entry_without_platform",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_is_legacy_image_format(config: object, expected: bool) -> None:
|
||||
@@ -408,290 +359,17 @@ def test_is_legacy_image_format(config: object, expected: bool) -> None:
|
||||
def test_migrate_returns_none_for_invalid_legacy_shapes(
|
||||
config: object, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Unrecognised shapes are not migrated (and emit no warning), so normal platform validation reports them."""
|
||||
"""Unrecognised shapes are not migrated (and emit no warning) so normal
|
||||
platform validation surfaces a proper error instead of silently dropping
|
||||
the offending input."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert _migrate_legacy_image_config(config) is None
|
||||
assert "deprecated" not in caplog.text
|
||||
|
||||
|
||||
def test_migrate_returns_none_for_mapping_form_defaults_files() -> None:
|
||||
"""A `platform:`-tagged `defaults:`/`files:` mapping must not be swallowed by the legacy migrator."""
|
||||
config = {
|
||||
CONF_PLATFORM: "file",
|
||||
"defaults": {"type": "rgb565"},
|
||||
"files": [{"id": "a", "file": "a.png"}],
|
||||
}
|
||||
assert _migrate_legacy_image_config(config) is None
|
||||
|
||||
|
||||
def test_migrate_returns_none_for_defaults_files_dict_without_platform() -> None:
|
||||
"""`defaults:`/`files:` without `platform:` must not be swallowed either -- the flattener has
|
||||
no `files:` branch and would silently return `[]`."""
|
||||
config = {
|
||||
"defaults": {"type": "rgb565"},
|
||||
"files": [{"id": "a", "file": "a.png"}],
|
||||
}
|
||||
assert _migrate_legacy_image_config(config) is None
|
||||
|
||||
|
||||
def test_migrate_returns_none_for_defaults_files_list_entry_without_platform() -> None:
|
||||
"""Same, in a list -- previously the list branch migrated it to a hard-coded
|
||||
`platform: file` instead of raising a missing-platform error."""
|
||||
config = [
|
||||
{
|
||||
"defaults": {"type": "rgb565"},
|
||||
"files": [{"id": "a", "file": "a.png"}],
|
||||
}
|
||||
]
|
||||
assert _migrate_legacy_image_config(config) is None
|
||||
|
||||
|
||||
# --------------------------- end legacy migration --------------------------
|
||||
|
||||
|
||||
def test_expand_platform_entry_passes_through_plain_entry() -> None:
|
||||
entry = {CONF_PLATFORM: "file", "id": "a", "file": "x.png"}
|
||||
assert _expand_platform_entry(0, entry) == [entry]
|
||||
|
||||
|
||||
def test_expand_platform_entry_expands_files_with_defaults() -> None:
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "RGB565", "transparency": "opaque"},
|
||||
CONF_FILES: [
|
||||
{"id": "img1", "file": "foo.png"},
|
||||
{"id": "img2", "file": "bar.png", "type": "GRAYSCALE"},
|
||||
],
|
||||
}
|
||||
assert _expand_platform_entry(0, entry) == [
|
||||
{
|
||||
CONF_PLATFORM: "file",
|
||||
"id": "img1",
|
||||
"file": "foo.png",
|
||||
"type": "RGB565",
|
||||
"transparency": "opaque",
|
||||
},
|
||||
{
|
||||
CONF_PLATFORM: "file",
|
||||
"id": "img2",
|
||||
"file": "bar.png",
|
||||
"type": "GRAYSCALE",
|
||||
"transparency": "opaque",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_expand_platform_entry_files_without_defaults() -> None:
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_FILES: [{"id": "img1", "file": "foo.png"}],
|
||||
}
|
||||
assert _expand_platform_entry(0, entry) == [
|
||||
{CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"}
|
||||
]
|
||||
|
||||
|
||||
def test_expand_platform_entry_preserves_source_range() -> None:
|
||||
"""A merged entry keeps the source range of its `files:` item so whole-entry errors anchor there."""
|
||||
from esphome import yaml_util
|
||||
|
||||
file_entry = yaml_util.make_data_base({"id": "img1", "file": "foo.png"})
|
||||
file_entry._esp_range = "sentinel-range"
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "RGB565"},
|
||||
CONF_FILES: [file_entry],
|
||||
}
|
||||
[out] = _expand_platform_entry(0, entry)
|
||||
assert isinstance(out, yaml_util.ESPHomeDataBase)
|
||||
assert out.esp_range == "sentinel-range"
|
||||
|
||||
|
||||
def test_expand_platform_entry_plain_dict_file_entry_has_no_source_range() -> None:
|
||||
"""Plain-dict `files:` items must not crash -- `from_database` reads `.esp_range` unconditionally."""
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_FILES: [{"id": "img1", "file": "foo.png"}],
|
||||
}
|
||||
[out] = _expand_platform_entry(0, entry)
|
||||
assert out == {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"}
|
||||
|
||||
|
||||
def test_expand_platform_entry_per_file_overrides_win() -> None:
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "RGB565"},
|
||||
CONF_FILES: [{"id": "img1", "file": "foo.png", "type": "BINARY"}],
|
||||
}
|
||||
[out] = _expand_platform_entry(0, entry)
|
||||
assert out["type"] == "BINARY"
|
||||
|
||||
|
||||
def test_expand_platform_entry_drops_byte_order_for_non_endian_override() -> None:
|
||||
"""A `byte_order` default merged into a non-endian override is dropped, as the legacy flattener did."""
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_endian"},
|
||||
CONF_FILES: [
|
||||
{"id": "a", "file": "x.png"},
|
||||
{"id": "b", "file": "y.png", "type": "binary"},
|
||||
],
|
||||
}
|
||||
out = _expand_platform_entry(0, entry)
|
||||
assert out[0]["byte_order"] == "little_endian"
|
||||
assert "byte_order" not in out[1]
|
||||
|
||||
|
||||
def test_expand_platform_entry_invalid_byte_order_in_defaults_raises() -> None:
|
||||
"""A dropped `byte_order` inherited from `defaults:` is still validated, so a typo raises."""
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_andian"},
|
||||
CONF_FILES: [{"id": "a", "file": "x.png", "type": "binary"}],
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="did you mean") as excinfo:
|
||||
_expand_platform_entry(0, entry)
|
||||
assert excinfo.value.path == [0]
|
||||
|
||||
|
||||
def test_expand_platform_entry_keeps_byte_order_for_endian_override() -> None:
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "big_endian"},
|
||||
CONF_FILES: [{"id": "a", "file": "x.png", "type": "rgb565"}],
|
||||
}
|
||||
[out] = _expand_platform_entry(0, entry)
|
||||
assert out["byte_order"] == "big_endian"
|
||||
|
||||
|
||||
def test_expand_platform_entry_keeps_explicit_byte_order_conflict() -> None:
|
||||
"""A `byte_order` written directly on the entry is kept so validate_settings raises the normal error."""
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "rgb565"},
|
||||
CONF_FILES: [
|
||||
{
|
||||
"id": "a",
|
||||
"file": "x.png",
|
||||
"type": "binary",
|
||||
"byte_order": "little_endian",
|
||||
}
|
||||
],
|
||||
}
|
||||
[out] = _expand_platform_entry(0, entry)
|
||||
assert out["byte_order"] == "little_endian"
|
||||
|
||||
|
||||
def test_expand_platform_entry_defaults_without_files_raises() -> None:
|
||||
entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}}
|
||||
with pytest.raises(cv.Invalid, match="may only be used together with") as excinfo:
|
||||
_expand_platform_entry(0, entry)
|
||||
assert excinfo.value.path == [0]
|
||||
|
||||
|
||||
def test_expand_platform_entry_null_files_raises_not_empty() -> None:
|
||||
"""A `files:` key with no value parses to `None` and must be reported clearly."""
|
||||
entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}, CONF_FILES: None}
|
||||
with pytest.raises(cv.Invalid, match="must not be empty"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_empty_files_list_raises_not_empty() -> None:
|
||||
"""An explicit `files: []` must not silently drop the whole platform entry."""
|
||||
entry = {CONF_PLATFORM: "file", CONF_FILES: []}
|
||||
with pytest.raises(cv.Invalid, match="must not be empty"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_files_with_stray_key_raises() -> None:
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_FILES: [{"id": "a", "file": "x.png"}],
|
||||
"extra": 1,
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="cannot be combined with"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_id_in_defaults_raises() -> None:
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {CONF_ID: "a"},
|
||||
CONF_FILES: [{"file": "x.png"}],
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="not allowed inside"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_platform_in_defaults_raises() -> None:
|
||||
"""`platform:` inside `defaults:` would silently reassign every file's platform."""
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {CONF_PLATFORM: "animation"},
|
||||
CONF_FILES: [{"id": "a", "file": "x.png"}],
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="not allowed inside"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_platform_in_file_entry_raises() -> None:
|
||||
"""`platform:` on a `files:` item must not silently override the entry's platform."""
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_FILES: [{"id": "a", "file": "x.png", CONF_PLATFORM: "animation"}],
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="not allowed inside"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_files_not_list_raises() -> None:
|
||||
entry = {CONF_PLATFORM: "file", CONF_FILES: "not-a-list"}
|
||||
with pytest.raises(cv.Invalid, match="must be a list"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_defaults_not_mapping_raises() -> None:
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: "not-a-mapping",
|
||||
CONF_FILES: [{"id": "a", "file": "x.png"}],
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="must be a mapping"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_file_item_not_mapping_raises() -> None:
|
||||
entry = {CONF_PLATFORM: "file", CONF_FILES: [1, 2]}
|
||||
with pytest.raises(cv.Invalid, match="must be a mapping"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_config_mixes_plain_and_expanded_entries() -> None:
|
||||
config = [
|
||||
{
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "RGB565"},
|
||||
CONF_FILES: [
|
||||
{"id": "img1", "file": "foo.png"},
|
||||
{"id": "img2", "file": "bar.png"},
|
||||
],
|
||||
},
|
||||
{CONF_PLATFORM: "file", "id": "plain", "file": "baz.png", "type": "BINARY"},
|
||||
]
|
||||
out = expand_platform_config(config)
|
||||
assert [entry["id"] for entry in out] == ["img1", "img2", "plain"]
|
||||
|
||||
|
||||
def test_expand_platform_config_ignores_non_platform_entries() -> None:
|
||||
# Not expanded here -- legacy_config_migrate runs before this hook and is
|
||||
# responsible for tagging/flattening pre-platform shapes.
|
||||
config = ["not-a-platform-entry"]
|
||||
assert expand_platform_config(config) == config
|
||||
|
||||
|
||||
# --------------------- end defaults/files expansion -------------------------
|
||||
|
||||
|
||||
def test_validate_image_final_defaults_to_little_endian() -> None:
|
||||
out = validate_image_final({CONF_FILE: "x.png"})
|
||||
assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
|
||||
|
||||
@@ -3,7 +3,7 @@ light:
|
||||
id: led_matrix_32x8
|
||||
default_transition_length: 500ms
|
||||
chipset: ws2812
|
||||
channel_colors: GRB
|
||||
rgb_order: GRB
|
||||
num_leds: 256
|
||||
pin: ${pin}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ light:
|
||||
id: led_matrix_32x8
|
||||
default_transition_length: 500ms
|
||||
chipset: ws2812
|
||||
channel_colors: GRB
|
||||
rgb_order: GRB
|
||||
num_leds: 256
|
||||
pin: ${pin}
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# `platform: animation` entry exercising the shared `defaults:`/`files:` expansion.
|
||||
display:
|
||||
- platform: sdl
|
||||
id: animation_display
|
||||
auto_clear_enabled: false
|
||||
dimensions:
|
||||
width: 480
|
||||
height: 480
|
||||
|
||||
image:
|
||||
- platform: animation
|
||||
defaults:
|
||||
type: rgb565
|
||||
transparency: opaque
|
||||
resize: 50x50
|
||||
files:
|
||||
- id: platform_defaults_animation
|
||||
file: $component_dir/anim.gif
|
||||
- id: platform_defaults_animation_rgb
|
||||
file: $component_dir/anim.apng
|
||||
type: rgb
|
||||
@@ -1,6 +1,6 @@
|
||||
light:
|
||||
- platform: beken_spi_led_strip
|
||||
channel_colors: GRB
|
||||
rgb_order: GRB
|
||||
pin: P16
|
||||
num_leds: 30
|
||||
chipset: ws2812
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0.
|
||||
# Config-only, and only one strip because P16 is the sole supported pin.
|
||||
light:
|
||||
- platform: beken_spi_led_strip
|
||||
name: Legacy RGBW
|
||||
pin: P16
|
||||
num_leds: 30
|
||||
chipset: sk6812
|
||||
rgb_order: GRB
|
||||
is_rgbw: true # -> GRBW
|
||||
@@ -5,7 +5,7 @@ light:
|
||||
id: led_matrix_32x8
|
||||
default_transition_length: 500ms
|
||||
chipset: ws2812
|
||||
channel_colors: GRB
|
||||
rgb_order: GRB
|
||||
num_leds: 256
|
||||
pin: ${pin}
|
||||
effects:
|
||||
|
||||
@@ -5,7 +5,7 @@ light:
|
||||
id: led_matrix_32x8
|
||||
default_transition_length: 500ms
|
||||
chipset: ws2812
|
||||
channel_colors: GRB
|
||||
rgb_order: GRB
|
||||
num_leds: 256
|
||||
pin: ${pin}
|
||||
effects:
|
||||
|
||||
@@ -6,7 +6,7 @@ light:
|
||||
pin: 2
|
||||
pio: 0
|
||||
num_leds: 256
|
||||
channel_colors: GRB
|
||||
rgb_order: GRB
|
||||
chipset: WS2812
|
||||
effects:
|
||||
- e131:
|
||||
|
||||
@@ -3,13 +3,13 @@ light:
|
||||
id: led_strip1
|
||||
pin: ${pin1}
|
||||
num_leds: 60
|
||||
channel_colors: GRB
|
||||
rgb_order: GRB
|
||||
chipset: ws2812
|
||||
- platform: esp32_rmt_led_strip
|
||||
id: led_strip2
|
||||
pin: ${pin2}
|
||||
num_leds: 60
|
||||
channel_colors: RWGB
|
||||
rgbw_order: RWGB
|
||||
bit0_high: 100us
|
||||
bit0_low: 100us
|
||||
bit1_high: 100us
|
||||
|
||||
@@ -8,14 +8,14 @@ light:
|
||||
id: led_strip1
|
||||
pin: ${pin1}
|
||||
num_leds: 60
|
||||
channel_colors: GRB
|
||||
rgb_order: GRB
|
||||
chipset: ws2812
|
||||
use_dma: "true"
|
||||
- platform: esp32_rmt_led_strip
|
||||
id: led_strip2
|
||||
pin: ${pin2}
|
||||
num_leds: 60
|
||||
channel_colors: RGB
|
||||
rgb_order: RGB
|
||||
bit0_high: 100us
|
||||
bit0_low: 100us
|
||||
bit1_high: 100us
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0.
|
||||
# Config-only: each strip below must migrate to the channel_colors shown in the comment.
|
||||
light:
|
||||
- platform: esp32_rmt_led_strip
|
||||
id: legacy_rgb
|
||||
pin: GPIO13
|
||||
num_leds: 60
|
||||
chipset: ws2812
|
||||
rgb_order: GRB # -> GRB
|
||||
- platform: esp32_rmt_led_strip
|
||||
id: legacy_rgbw
|
||||
pin: GPIO14
|
||||
num_leds: 60
|
||||
chipset: sk6812
|
||||
rgb_order: GRB
|
||||
is_rgbw: true # -> GRBW
|
||||
- platform: esp32_rmt_led_strip
|
||||
id: legacy_wrgb
|
||||
pin: GPIO15
|
||||
num_leds: 60
|
||||
chipset: sk6812
|
||||
rgb_order: GRB
|
||||
is_wrgb: true # -> WGRB
|
||||
@@ -1,24 +0,0 @@
|
||||
# `platform: file` entry using the `defaults:`/`files:` shape, including the
|
||||
# per-type byte_order drop when an entry overrides to a non-endian type.
|
||||
display:
|
||||
- platform: sdl
|
||||
id: image_display
|
||||
auto_clear_enabled: false
|
||||
dimensions:
|
||||
width: 480
|
||||
height: 480
|
||||
|
||||
image:
|
||||
- platform: file
|
||||
defaults:
|
||||
type: rgb565
|
||||
transparency: opaque
|
||||
byte_order: little_endian
|
||||
resize: 50x50
|
||||
dither: FloydSteinberg
|
||||
files:
|
||||
- id: platform_defaults_image
|
||||
file: ../../pnglogo.png
|
||||
- id: platform_defaults_binary
|
||||
file: ../../pnglogo.png
|
||||
type: binary
|
||||
@@ -1,10 +1,7 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
#include "esphome/components/uart/uart_component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::modbus::testing {
|
||||
|
||||
@@ -33,37 +30,4 @@ class RecordingUART : public NullUART {
|
||||
std::vector<uint8_t> written;
|
||||
};
|
||||
|
||||
// A UART the test can inject received bytes into, so frames travel the full receive path
|
||||
// (receive_modbus_frames -> parse -> dispatch) through hub.loop(). Writes are recorded.
|
||||
class InjectableUART : public RecordingUART {
|
||||
public:
|
||||
bool peek_byte(uint8_t *data) override {
|
||||
if (this->rx_.empty())
|
||||
return false;
|
||||
*data = this->rx_.front();
|
||||
return true;
|
||||
}
|
||||
bool read_array(uint8_t *data, size_t len) override {
|
||||
if (len > this->rx_.size())
|
||||
return false;
|
||||
memcpy(data, this->rx_.data(), len);
|
||||
this->rx_.erase(this->rx_.begin(), this->rx_.begin() + len);
|
||||
return true;
|
||||
}
|
||||
size_t available() override { return this->rx_.size(); }
|
||||
|
||||
// Queues a complete wire frame: address + PDU + CRC16 (low byte first).
|
||||
void inject_frame(uint8_t address, std::span<const uint8_t> pdu) {
|
||||
size_t start = this->rx_.size();
|
||||
this->rx_.push_back(address);
|
||||
this->rx_.insert(this->rx_.end(), pdu.begin(), pdu.end());
|
||||
uint16_t crc = crc16(this->rx_.data() + start, this->rx_.size() - start);
|
||||
this->rx_.push_back(crc & 0xFF);
|
||||
this->rx_.push_back(crc >> 8);
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<uint8_t> rx_;
|
||||
};
|
||||
|
||||
} // namespace esphome::modbus::testing
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#include "common.h"
|
||||
#include "esphome/components/modbus/modbus.h"
|
||||
|
||||
namespace esphome::modbus::testing {
|
||||
|
||||
namespace {
|
||||
|
||||
// Records custom-response dispatches so tests can assert an unknown-length frame reached the device.
|
||||
class CustomRecordingDevice : public ModbusClientDevice {
|
||||
public:
|
||||
using ModbusClientDevice::ModbusClientDevice;
|
||||
void on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
|
||||
ResponseStatus status) override {
|
||||
this->requests.emplace_back(request_pdu.begin(), request_pdu.end());
|
||||
this->responses.emplace_back(response_pdu.begin(), response_pdu.end());
|
||||
this->statuses.push_back(status);
|
||||
}
|
||||
std::vector<std::vector<uint8_t>> requests;
|
||||
std::vector<std::vector<uint8_t>> responses;
|
||||
std::vector<ResponseStatus> statuses;
|
||||
};
|
||||
|
||||
// Every handler keeps its ILLEGAL_FUNCTION default; the hub's dispatch is what is under test.
|
||||
class SilentServerDevice : public ModbusServerDevice {};
|
||||
|
||||
// Drives full client frames through the server hub's receive path (same shape as the broadcast tests).
|
||||
class TestServerHub : public ModbusServerHub {
|
||||
public:
|
||||
bool tx_blocked() override { return false; }
|
||||
|
||||
// Builds a complete client frame (address + FC + data + CRC) and runs the full receive-side parser.
|
||||
// Returns true once the buffer has fully drained.
|
||||
bool run_receive_parser_for_test(uint8_t address, uint8_t function_code, std::span<const uint8_t> data) {
|
||||
this->rx_buffer_.clear();
|
||||
this->rx_buffer_.reserve(data.size() + 4);
|
||||
this->rx_buffer_.push_back(address);
|
||||
this->rx_buffer_.push_back(function_code);
|
||||
this->rx_buffer_.insert(this->rx_buffer_.end(), data.begin(), data.end());
|
||||
uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size());
|
||||
this->rx_buffer_.push_back(crc & 0xFF);
|
||||
this->rx_buffer_.push_back(crc >> 8);
|
||||
this->parse_modbus_frames();
|
||||
return this->rx_buffer_.empty();
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// The frame-length parsers have explicit cases for exactly these 13 codes; every other value - the
|
||||
// assigned-but-unimplemented management codes, both user-defined ranges, and all unassigned codes -
|
||||
// must classify as unknown length. The exception flag masks off first.
|
||||
TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
|
||||
for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x14, 0x15, 0x16, 0x17, 0x18}) {
|
||||
EXPECT_FALSE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc);
|
||||
}
|
||||
for (uint8_t fc : {0x07, 0x08, 0x0B, 0x0C, 0x11, 0x2A, 0x41, 0x48, 0x49, 0x64, 0x6E, 0x00, 0x7F}) {
|
||||
EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc);
|
||||
}
|
||||
// Exception replies classify by their base code.
|
||||
EXPECT_FALSE(helpers::is_function_code_unknown_length(0x83));
|
||||
EXPECT_TRUE(helpers::is_function_code_unknown_length(0x87));
|
||||
// Strictly wider than the user-defined ranges: every custom code is unknown-length, but not vice versa.
|
||||
for (int fc = 0; fc <= 0xFF; fc++) {
|
||||
if (helpers::is_function_code_custom(fc))
|
||||
EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << fc;
|
||||
}
|
||||
EXPECT_FALSE(helpers::is_function_code_custom(0x49));
|
||||
|
||||
// Derived contract check: the helper must say "unknown" exactly when both length parsers fall
|
||||
// through to default. With a zero-filled max-size PDU every explicit case returns at least 2
|
||||
// (file records bottom out at 2, FIFO at 3) and only default returns MIN_PDU_SIZE, so comparing
|
||||
// against MIN_PDU_SIZE detects a case added to either switch without updating the helper. The
|
||||
// loop stops at 0x7F: above it the helper masks the exception flag off while client_pdu_length()
|
||||
// switches on the unmasked byte and server_pdu_length() early-returns the exception length.
|
||||
for (int fc = 0; fc <= 0x7F; fc++) {
|
||||
const uint8_t pdu[MAX_PDU_SIZE] = {static_cast<uint8_t>(fc)}; // zero header fields
|
||||
EXPECT_EQ(helpers::is_function_code_unknown_length(fc),
|
||||
helpers::client_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE)
|
||||
<< "client_pdu_length disagrees for fc 0x" << std::hex << fc;
|
||||
EXPECT_EQ(helpers::is_function_code_unknown_length(fc),
|
||||
helpers::server_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE)
|
||||
<< "server_pdu_length disagrees for fc 0x" << std::hex << fc;
|
||||
}
|
||||
}
|
||||
|
||||
// A response with a function code outside the user-defined ranges (0x49) has no length case in
|
||||
// server_pdu_length(), so the parser must find the frame end by CRC scan - the same way it already
|
||||
// handles user-defined codes. Frame: address + FC 0x49 + 3 data bytes + CRC = 7 bytes. Without the
|
||||
// scan the parser assumes a 4-byte frame, fails the CRC, and the response never reaches the device.
|
||||
TEST(ModbusUnknownFunction, ClientParsesUnknownLengthResponse) {
|
||||
InjectableUART uart;
|
||||
ModbusClientHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup(); // computes frame timing from the baud rate
|
||||
CustomRecordingDevice device(&hub, 0x02);
|
||||
|
||||
const uint8_t request[] = {0x49, 0x01};
|
||||
ASSERT_TRUE(device.queue_pdu(request));
|
||||
hub.loop(); // transmit
|
||||
ASSERT_FALSE(uart.written.empty());
|
||||
|
||||
const uint8_t response_pdu[] = {0x49, 0x02, 0xAA, 0xBB};
|
||||
uart.inject_frame(0x02, response_pdu);
|
||||
hub.loop(); // receive + parse + match + dispatch
|
||||
|
||||
ASSERT_EQ(device.responses.size(), 1u);
|
||||
EXPECT_EQ(device.requests[0], std::vector<uint8_t>(request, request + sizeof(request)));
|
||||
EXPECT_EQ(device.responses[0], std::vector<uint8_t>(response_pdu, response_pdu + sizeof(response_pdu)));
|
||||
EXPECT_FALSE(device.statuses[0].has_value());
|
||||
}
|
||||
|
||||
// The server side of the same gap: a request with FC 0x49 for a registered device must parse (CRC
|
||||
// scan again) so the hub can answer ILLEGAL_FUNCTION per the spec. Without the scan the frame fails
|
||||
// to parse and the client gets silence instead of the exception.
|
||||
TEST(ModbusUnknownFunction, ServerRepliesIllegalFunctionToUnknownLengthRequest) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
|
||||
SilentServerDevice device;
|
||||
device.set_address(0x02);
|
||||
hub.register_device(&device);
|
||||
|
||||
const uint8_t data[] = {0x02, 0xAA, 0xBB};
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, 0x49, data));
|
||||
|
||||
// Expected reply: address + FC with exception flag + ILLEGAL_FUNCTION + CRC.
|
||||
std::vector<uint8_t> expected = {0x02, 0xC9, 0x01};
|
||||
uint16_t crc = crc16(expected.data(), expected.size());
|
||||
expected.push_back(crc & 0xFF);
|
||||
expected.push_back(crc >> 8);
|
||||
EXPECT_EQ(uart.written, expected);
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus::testing
|
||||
@@ -4,7 +4,7 @@ light:
|
||||
default_transition_length: 500ms
|
||||
chipset: ws2812
|
||||
num_leds: 256
|
||||
channel_colors: GRB
|
||||
rgb_order: GRB
|
||||
pin: ${pin}
|
||||
- platform: partition
|
||||
name: Partition Light
|
||||
|
||||
@@ -4,7 +4,7 @@ light:
|
||||
default_transition_length: 500ms
|
||||
chipset: ws2812
|
||||
num_leds: 256
|
||||
channel_colors: GRB
|
||||
rgb_order: GRB
|
||||
pin: ${pin}
|
||||
- platform: partition
|
||||
name: Partition Light
|
||||
|
||||
@@ -4,14 +4,14 @@ light:
|
||||
pin: 4
|
||||
num_leds: 60
|
||||
pio: 0
|
||||
channel_colors: GRB
|
||||
rgb_order: GRB
|
||||
chipset: WS2812
|
||||
- platform: rp2040_pio_led_strip
|
||||
id: led_strip_custom_timings
|
||||
pin: 5
|
||||
num_leds: 60
|
||||
pio: 1
|
||||
channel_colors: GRB
|
||||
rgb_order: GRB
|
||||
bit0_high: .1us
|
||||
bit0_low: 1.2us
|
||||
bit1_high: .69us
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# The deprecated rgb_order / is_rgbw keys, kept working until 2027.3.0.
|
||||
# Config-only: each strip below must migrate to the channel_colors shown in the comment.
|
||||
light:
|
||||
- platform: rp2040_pio_led_strip
|
||||
id: legacy_rgb
|
||||
pin: 4
|
||||
num_leds: 60
|
||||
pio: 0
|
||||
chipset: WS2812
|
||||
rgb_order: GRB # -> GRB
|
||||
- platform: rp2040_pio_led_strip
|
||||
id: legacy_rgbw
|
||||
pin: 5
|
||||
num_leds: 60
|
||||
pio: 1
|
||||
chipset: SK6812
|
||||
rgb_order: GRB
|
||||
is_rgbw: true # -> GRBW
|
||||
@@ -9,7 +9,7 @@ light:
|
||||
id: led_matrix_32x8
|
||||
default_transition_length: 500ms
|
||||
chipset: ws2812
|
||||
channel_colors: GRB
|
||||
rgb_order: GRB
|
||||
num_leds: 256
|
||||
pin: 2
|
||||
effects:
|
||||
|
||||
@@ -60,11 +60,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env["PLATFORMIO_CORE_DIR"] = str(cache_dir)
|
||||
env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache")
|
||||
# libdeps is keyed only by env name (the device name), and fixtures share
|
||||
# names; two xdist workers first-compiling the same name race pio pkg
|
||||
# install in the same directory. Keep libdeps per worker.
|
||||
worker = os.environ.get("PYTEST_XDIST_WORKER", "master")
|
||||
env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker)
|
||||
env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps")
|
||||
# Prevent cache cleaning during integration tests
|
||||
env["ESPHOME_SKIP_CLEAN_BUILD"] = "1"
|
||||
# Compile with THIS tree's esphome sources, not wherever the venv's editable
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
esphome:
|
||||
name: host-pref-key-stability
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
switch:
|
||||
- platform: template
|
||||
id: test_switch_restore
|
||||
name: Test Switch
|
||||
optimistic: true
|
||||
restore_mode: RESTORE_DEFAULT_OFF
|
||||
|
||||
number:
|
||||
- platform: template
|
||||
id: test_number_restore
|
||||
name: Test Number
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
initial_value: 1.0
|
||||
min_value: 0
|
||||
max_value: 100
|
||||
step: 0.5
|
||||
|
||||
text:
|
||||
- platform: template
|
||||
id: test_text_restore
|
||||
name: Test Text
|
||||
mode: text
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
initial_value: fallback
|
||||
min_length: 0
|
||||
max_length: 20
|
||||
@@ -50,19 +50,10 @@ uart_mock:
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Catch-all response: match any command footer (04 03 02 01).
|
||||
# Returns a generic ACK with cmd=0xFF (CMD_ENABLE_CONF case in switch).
|
||||
# All commands get unblocked via cmd_reply_.ack = true.
|
||||
# Data fields stay zeroed (min_gate=0, max_gate=0, timeout=0, thresholds=0).
|
||||
#
|
||||
# Response layout:
|
||||
# [0-3] FD FC FB FA = header
|
||||
# [4-5] 04 00 = length 4
|
||||
# [6] FF = cmd (handled as CMD_ENABLE_CONF)
|
||||
# [7] 01 = status (ACK)
|
||||
# [8-9] 00 00 = error = 0
|
||||
# [10-13] 04 03 02 01 = footer
|
||||
- expect_tx: [0x04, 0x03, 0x02, 0x01]
|
||||
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
|
||||
# TX = FD FC FB FA 04 00 FF 00 02 00 04 03 02 01
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
@@ -72,8 +63,58 @@ uart_mock:
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0x12, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
|
||||
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFE, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Catch-all for the remaining commands, which are all CMD_READ_ABD_PARAM
|
||||
# (0x0008) reads: min/max/timeout limits and the 16 gate threshold reads.
|
||||
# The reply carries three zeroed uint32 values (data length 16 = 4 + 12),
|
||||
# so limits and thresholds all read as 0.
|
||||
#
|
||||
# Response layout:
|
||||
# [0-3] FD FC FB FA = header
|
||||
# [4-5] 10 00 = length 16
|
||||
# [6] 08 = cmd (CMD_READ_ABD_PARAM)
|
||||
# [7] 01 = status (ACK)
|
||||
# [8-9] 00 00 = error = 0
|
||||
# [10-21] 00 x12 = three zeroed uint32 data values
|
||||
# [22-25] 04 03 02 01 = footer
|
||||
- expect_tx: [0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x10, 0x00,
|
||||
0x08, 0x01,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
injections:
|
||||
# Phase 1 (t=100ms): Valid LD2420 energy mode data frame - happy path
|
||||
# Phase 1 (t=700ms): Valid LD2420 energy mode data frame - happy path
|
||||
# Delay=700ms keeps it outside the component's 500ms listen settle window,
|
||||
# which is measured from boot and ignores earlier reception; this frame is
|
||||
# both the happy path data and the wake-up that starts the setup handshake.
|
||||
# Buffer is clean (buffer_pos_=0). This frame should parse correctly.
|
||||
# Presence: 1 (target detected), Distance: 100cm, Gate energies: all 0
|
||||
#
|
||||
@@ -84,7 +125,7 @@ uart_mock:
|
||||
# [7-8] 64 00 = distance 100 (uint16_t LE)
|
||||
# [9-40] 00 00 x16 = 16 gate energies (uint16_t LE each)
|
||||
# [41-44] F8 F7 F6 F5 = energy frame footer
|
||||
- delay: 100ms
|
||||
- delay: 700ms
|
||||
inject_rx:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
@@ -98,13 +139,15 @@ uart_mock:
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
# Phase 2 (t=300ms): Garbage bytes
|
||||
# Phase 2 (t=1600ms): Garbage bytes
|
||||
# LD2420's readline_ does NOT check frame headers at position 0 (unlike LD2412),
|
||||
# so these bytes accumulate in the buffer. buffer_pos_ goes from 0 to 7.
|
||||
- delay: 200ms
|
||||
# Delay=900ms leaves time for the setup handshake (triggered by Phase 1,
|
||||
# the first data seen from the module) to finish first.
|
||||
- delay: 900ms
|
||||
inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22]
|
||||
|
||||
# Phase 3 (t=400ms): Truncated energy frame WITH footer (13 bytes)
|
||||
# Phase 3 (t=1700ms): Truncated energy frame WITH footer (13 bytes)
|
||||
# This tests PR #14458 bug #3: missing length validation in handle_energy_mode_.
|
||||
# The 7 garbage bytes from Phase 2 are still in the buffer (buffer_pos_=7).
|
||||
# These 13 bytes are appended at positions 7-19 (buffer_pos_=20).
|
||||
@@ -126,7 +169,7 @@ uart_mock:
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
# Phase 4 (t=600ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
|
||||
# Phase 4 (t=1900ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
|
||||
# After Phase 3, buffer_pos_=0 (reset after energy footer detection).
|
||||
# 49 bytes fill positions 0-48 (buffer_pos_=49), 50th byte triggers overflow.
|
||||
# Logs "Max command length exceeded; ignoring", buffer_pos_=0.
|
||||
@@ -140,11 +183,11 @@ uart_mock:
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
]
|
||||
|
||||
# Phase 5 (t=1500ms): Valid frame after overflow - recovery test
|
||||
# Phase 5 (t=2300ms): Valid frame after overflow - recovery test
|
||||
# Buffer was reset by overflow. This valid frame should parse correctly.
|
||||
# Presence: 1 (target), Distance: 50cm
|
||||
# Delay=900ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
|
||||
- delay: 900ms
|
||||
# Delay=400ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
|
||||
- delay: 400ms
|
||||
inject_rx:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
esphome:
|
||||
name: uart-mock-ld2420-retry-test
|
||||
|
||||
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"]
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Exercises the per-command retry path: the module ignores the first config
|
||||
# mode enable command and only answers the resend, so the startup handshake
|
||||
# must time out once, resend, and then complete normally.
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 115200
|
||||
auto_start: true
|
||||
|
||||
injections:
|
||||
# Wake-up frame (t=700ms): energy frame (presence=1, distance=100).
|
||||
# Delay=700ms keeps it outside the component's 500ms listen settle window.
|
||||
- delay: 700ms
|
||||
inject_rx:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
0x23, 0x00,
|
||||
0x01,
|
||||
0x64, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
# The config mode enable command is matched by an empty responder so the
|
||||
# catch-all cannot answer it (responders match on the TX suffix and every
|
||||
# command ends with the frame footer); the on_tx hook below acks it from
|
||||
# the second attempt on, so the first attempt is genuine silence.
|
||||
responses:
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx: []
|
||||
|
||||
# Version response: returns "v2.0.0" → 200 >= 154 → energy mode
|
||||
- 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,
|
||||
]
|
||||
|
||||
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0x12, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
|
||||
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFE, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
|
||||
# gate threshold reads. Three zeroed uint32 data values.
|
||||
- expect_tx: [0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x10, 0x00,
|
||||
0x08, 0x01,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Ignore the first config mode enable command; ack every one after it
|
||||
on_tx:
|
||||
- lambda: |-
|
||||
static int enable_count = 0;
|
||||
if (data.size() == 14 && data[6] == 0xFF) {
|
||||
enable_count++;
|
||||
if (enable_count >= 2) {
|
||||
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>{
|
||||
0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01});
|
||||
}
|
||||
}
|
||||
|
||||
ld2420:
|
||||
id: ld2420_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
sensor:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
moving_distance:
|
||||
name: "Moving Distance"
|
||||
filters:
|
||||
- timeout:
|
||||
timeout: 50ms
|
||||
value: last
|
||||
- throttle_with_priority: 50ms
|
||||
|
||||
binary_sensor:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
has_target:
|
||||
name: "Has Target"
|
||||
filters:
|
||||
- settle: 50ms
|
||||
@@ -0,0 +1,168 @@
|
||||
esphome:
|
||||
name: uart-mock-ld2420-boot-test
|
||||
|
||||
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"]
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Simulates a cold boot where the LD2420 module boots slower than the ESP:
|
||||
# the module is silent for 2 seconds and then sends its first energy frame.
|
||||
# The module locks up until power cycled if it receives any data before it
|
||||
# has sent its first frame, so the component must stay quiet for the full
|
||||
# 2 seconds and only start its setup handshake after the first frame.
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 115200
|
||||
auto_start: true
|
||||
|
||||
injections:
|
||||
# Module boot finished (t=2000ms): first energy frame
|
||||
# (presence=1, distance=100). Any TX from the component before this
|
||||
# point would have locked up real hardware.
|
||||
- delay: 2000ms
|
||||
inject_rx:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
0x23, 0x00,
|
||||
0x01,
|
||||
0x64, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
# Repeat frame (t=3100ms): distance=100 again. If the API client happens
|
||||
# to subscribe after the first frame, the first published state is
|
||||
# swallowed as the entity's initial state; repeating the value makes the
|
||||
# test's first collected state deterministic. Delay=1100ms keeps >1000ms
|
||||
# publish throttle gap from the first frame.
|
||||
- delay: 1100ms
|
||||
inject_rx:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
0x23, 0x00,
|
||||
0x01,
|
||||
0x64, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
# Post-setup frame (t=4200ms): distance=50 proves streaming still works
|
||||
# after the setup handshake. Delay=1100ms keeps >1000ms publish throttle
|
||||
# gap from the repeat frame.
|
||||
- delay: 1100ms
|
||||
inject_rx:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
0x23, 0x00,
|
||||
0x01,
|
||||
0x32, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
responses:
|
||||
# Version response: returns "v2.0.0" → 200 >= 154 → energy mode
|
||||
- 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,
|
||||
]
|
||||
|
||||
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFF, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0x12, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
|
||||
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFE, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
|
||||
# gate threshold reads. Three zeroed uint32 data values.
|
||||
- expect_tx: [0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x10, 0x00,
|
||||
0x08, 0x01,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
ld2420:
|
||||
id: ld2420_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
sensor:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
moving_distance:
|
||||
name: "Moving Distance"
|
||||
filters:
|
||||
- timeout:
|
||||
timeout: 50ms
|
||||
value: last
|
||||
- throttle_with_priority: 50ms
|
||||
|
||||
binary_sensor:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
has_target:
|
||||
name: "Has Target"
|
||||
filters:
|
||||
- settle: 50ms
|
||||
@@ -0,0 +1,128 @@
|
||||
esphome:
|
||||
name: uart-mock-ld2420-giveup-test
|
||||
|
||||
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"]
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Exercises the sequence retry and give-up path: the module streams energy
|
||||
# frames and answers every command except the firmware version read. The
|
||||
# startup handshake must retry the whole sequence, eventually give up with a
|
||||
# warning instead of marking the component failed, and keep parsing the
|
||||
# stream afterwards. Runs for roughly 16 seconds of retry cadence.
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 115200
|
||||
auto_start: true
|
||||
|
||||
# Module streams a valid energy frame (presence=1, distance=100) continuously
|
||||
periodic_rx:
|
||||
- interval: 250ms
|
||||
data:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
0x23, 0x00,
|
||||
0x01,
|
||||
0x64, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
injections:
|
||||
# Post-give-up parser probe (t=22s): 50 bytes of 0xFF overflow the frame
|
||||
# buffer, which the parser answers with a "Max command length exceeded"
|
||||
# warning. The give-up happens around t=16s, so seeing that warning after
|
||||
# the give-up proves the stream parser is still running in the degraded
|
||||
# state. (A distinct sensor value cannot serve as the probe: the 1s
|
||||
# publish throttle races the constant periodic stream, and the API
|
||||
# deduplicates repeated identical states.)
|
||||
- delay: 22000ms
|
||||
inject_rx:
|
||||
[
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
]
|
||||
|
||||
responses:
|
||||
# Version read: matched so the catch-all cannot answer it, but never
|
||||
# replied to; this is the command the handshake gives up on
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx: []
|
||||
|
||||
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFF, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Config mode disable: CMD_DISABLE_CONF (0x00FE), sent blind before each
|
||||
# sequence retry and on the final give-up
|
||||
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFE, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads
|
||||
- expect_tx: [0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x10, 0x00,
|
||||
0x08, 0x01,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
ld2420:
|
||||
id: ld2420_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
sensor:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
moving_distance:
|
||||
name: "Moving Distance"
|
||||
filters:
|
||||
- timeout:
|
||||
timeout: 50ms
|
||||
value: last
|
||||
- throttle_with_priority: 50ms
|
||||
|
||||
binary_sensor:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
has_target:
|
||||
name: "Has Target"
|
||||
filters:
|
||||
- settle: 50ms
|
||||
@@ -0,0 +1,167 @@
|
||||
esphome:
|
||||
name: uart-mock-ld2420-restart-test
|
||||
|
||||
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"]
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Exercises the module restart path. The restart command hits the module while
|
||||
# it is mid transmission, so a few tail bytes of the in-flight frame arrive
|
||||
# right after the restart. The module is then silent for 2 seconds while it
|
||||
# boots, and it locks up until power cycled if it receives any data in that
|
||||
# window. The component must not treat the tail bytes as proof the module is
|
||||
# up, and must only re-run its setup handshake after the module's first
|
||||
# post-boot frame.
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 115200
|
||||
auto_start: true
|
||||
|
||||
injections:
|
||||
# Initial wake-up frame (t=700ms): energy frame (presence=1, distance=100).
|
||||
# Delay=700ms keeps it outside the component's 500ms listen settle window,
|
||||
# which is measured from boot and ignores earlier reception.
|
||||
- delay: 700ms
|
||||
inject_rx:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
0x23, 0x00,
|
||||
0x01,
|
||||
0x64, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
responses:
|
||||
# Version response: returns "v2.0.0" → 200 >= 154 → energy mode
|
||||
- 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,
|
||||
]
|
||||
|
||||
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFF, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0x12, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
|
||||
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFE, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# The module restart command (CMD_RESTART, 0x0068) gets no reply; a real
|
||||
# module goes silent and reboots. Matching it here prevents the catch-all
|
||||
# below from answering it.
|
||||
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x68, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx: []
|
||||
|
||||
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
|
||||
# gate threshold reads. Three zeroed uint32 data values.
|
||||
- expect_tx: [0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x10, 0x00,
|
||||
0x08, 0x01,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Restart Module"
|
||||
on_press:
|
||||
- lambda: 'id(ld2420_dev).restart_module_action();'
|
||||
# Tail of the energy frame the module was transmitting when the restart
|
||||
# command hit it; must not count as proof the module is up
|
||||
- uart_mock.inject_rx:
|
||||
id: mock_uart
|
||||
data: [0x00, 0x00, 0x00, 0xF8, 0xF7, 0xF6, 0xF5]
|
||||
# The module's first frame after its ~2s boot (presence=1, distance=100)
|
||||
- uart_mock.inject_rx:
|
||||
id: mock_uart
|
||||
delay: 2000ms
|
||||
data:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
0x23, 0x00,
|
||||
0x01,
|
||||
0x64, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
ld2420:
|
||||
id: ld2420_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
sensor:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
moving_distance:
|
||||
name: "Moving Distance"
|
||||
filters:
|
||||
- timeout:
|
||||
timeout: 50ms
|
||||
value: last
|
||||
- throttle_with_priority: 50ms
|
||||
|
||||
binary_sensor:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
has_target:
|
||||
name: "Has Target"
|
||||
filters:
|
||||
- settle: 50ms
|
||||
@@ -22,10 +22,24 @@ uart_mock:
|
||||
baud_rate: 115200
|
||||
auto_start: false
|
||||
responses:
|
||||
# Catch-all response only (no version-specific response).
|
||||
# Without a version response, firmware_ver_ stays at default "v0.0.0".
|
||||
# get_firmware_int("v0.0.0") = 0 < 154 → simple mode (CMD_SYSTEM_MODE_SIMPLE).
|
||||
- expect_tx: [0x04, 0x03, 0x02, 0x01]
|
||||
# Version response with an old firmware version "v1.5.3".
|
||||
# get_firmware_int("v1.5.3") = 153 < 154 → simple mode (CMD_SYSTEM_MODE_SIMPLE).
|
||||
- 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, 0x31, 0x2E, 0x35, 0x2E, 0x33,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
@@ -35,11 +49,50 @@ uart_mock:
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = simple (0x0064)
|
||||
- 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,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
|
||||
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFE, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
|
||||
# gate threshold reads. Three zeroed uint32 data values.
|
||||
- expect_tx: [0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x10, 0x00,
|
||||
0x08, 0x01,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
injections:
|
||||
# Phase 1 (t=100ms): Valid simple mode text frame - happy path
|
||||
# "ON Range 0100\r\n" → presence=true, distance=100
|
||||
# Simple mode frames end with \r\n (0x0D 0x0A), triggering handle_simple_mode_.
|
||||
- delay: 100ms
|
||||
# Phase 0 (t=700ms): Wake-up frame. The component listens for data from the
|
||||
# module before transmitting anything, so this frame starts the setup
|
||||
# handshake. It is not parsed as simple mode because the component's system
|
||||
# mode is only switched to simple after the firmware version is read.
|
||||
# Delay=700ms keeps it outside the component's 500ms listen settle window,
|
||||
# which is measured from boot and ignores earlier reception.
|
||||
- delay: 700ms
|
||||
inject_rx:
|
||||
[
|
||||
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
|
||||
@@ -47,12 +100,24 @@ uart_mock:
|
||||
0x0D, 0x0A,
|
||||
]
|
||||
|
||||
# Phase 2 (t=300ms): Garbage bytes
|
||||
# Phase 1 (t=1600ms): Valid simple mode text frame - happy path
|
||||
# "ON Range 0100\r\n" → presence=true, distance=100
|
||||
# Simple mode frames end with \r\n (0x0D 0x0A), triggering handle_simple_mode_.
|
||||
# Delay=900ms leaves time for the setup handshake to finish first.
|
||||
- delay: 900ms
|
||||
inject_rx:
|
||||
[
|
||||
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
|
||||
0x30, 0x31, 0x30, 0x30,
|
||||
0x0D, 0x0A,
|
||||
]
|
||||
|
||||
# Phase 2 (t=1800ms): Garbage bytes
|
||||
# LD2420's readline_ stores all bytes regardless of header. buffer_pos_ = 7.
|
||||
- delay: 200ms
|
||||
inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22]
|
||||
|
||||
# Phase 3 (t=500ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
|
||||
# Phase 3 (t=2000ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
|
||||
# buffer_pos_ starts at 7 (from Phase 2 garbage).
|
||||
# Positions 7-48 fill (42 bytes), byte 43 triggers overflow (buffer_pos_=49).
|
||||
# After overflow: buffer_pos_=0, remaining 7 bytes fill positions 0-6.
|
||||
@@ -67,13 +132,13 @@ uart_mock:
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
]
|
||||
|
||||
# Phase 4 (t=1400ms): Recovery after overflow
|
||||
# Phase 4 (t=2700ms): Recovery after overflow
|
||||
# buffer_pos_ = 7 (from overflow remainder). These 15 bytes fill positions 7-21.
|
||||
# At position 21 (0x0A), \r\n detected → handle_simple_mode_(buffer, 22).
|
||||
# Parser skips 0xFF bytes at positions 0-6, finds "ON" at positions 7-8,
|
||||
# parses digits "0050" → distance=50.
|
||||
# Delay=900ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
|
||||
- delay: 900ms
|
||||
# Delay=700ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
|
||||
- delay: 700ms
|
||||
inject_rx:
|
||||
[
|
||||
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
|
||||
@@ -81,7 +146,7 @@ uart_mock:
|
||||
0x0D, 0x0A,
|
||||
]
|
||||
|
||||
# Phase 5 (t=2500ms): 16-digit distance - tests PR #14458 bug #1
|
||||
# Phase 5 (t=3800ms): 16-digit distance - tests PR #14458 bug #1
|
||||
# "ON Range 0000000000000000\r\n" has 16 digit characters.
|
||||
# handle_simple_mode_ outbuf is 16 bytes, can hold 15 digits (index 0-14).
|
||||
#
|
||||
@@ -100,7 +165,7 @@ uart_mock:
|
||||
0x0D, 0x0A,
|
||||
]
|
||||
|
||||
# Phase 6 (t=3700ms): Post-bug-trigger recovery
|
||||
# Phase 6 (t=5000ms): Post-bug-trigger recovery
|
||||
# If Phase 5 didn't hang, this frame should parse correctly.
|
||||
# "ON Range 0025\r\n" → distance=25
|
||||
# Delay=1200ms ensures >1000ms gap from Phase 5 for throttle.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
esphome:
|
||||
name: uart-mock-ld2420-warm-test
|
||||
|
||||
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"]
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Simulates a warm restart: the ESP rebooted but the LD2420 module stayed
|
||||
# powered and keeps streaming energy frames from the moment the firmware
|
||||
# starts. The component must not transmit anything until it has seen data
|
||||
# from the module, then run its setup handshake against the live stream.
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 115200
|
||||
auto_start: true
|
||||
|
||||
# Module streams valid energy frames continuously. Two alternating frames
|
||||
# are used (presence=1/distance=100 and presence=0/distance=75) so states
|
||||
# keep changing: with a constant frame the API deduplicates the repeated
|
||||
# identical states, and a client that subscribes after the first publish
|
||||
# would swallow the only transition as the initial state and never see an
|
||||
# update.
|
||||
periodic_rx:
|
||||
- interval: 250ms
|
||||
data:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
0x23, 0x00,
|
||||
0x01,
|
||||
0x64, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
- interval: 1050ms
|
||||
data:
|
||||
[
|
||||
0xF4, 0xF3, 0xF2, 0xF1,
|
||||
0x23, 0x00,
|
||||
0x00,
|
||||
0x4B, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xF8, 0xF7, 0xF6, 0xF5,
|
||||
]
|
||||
|
||||
responses:
|
||||
# Version response: returns "v2.0.0" → 200 >= 154 → energy mode
|
||||
- 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,
|
||||
]
|
||||
|
||||
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFF, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0x12, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
|
||||
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFE, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
|
||||
# gate threshold reads. Three zeroed uint32 data values.
|
||||
- expect_tx: [0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x10, 0x00,
|
||||
0x08, 0x01,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
ld2420:
|
||||
id: ld2420_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
sensor:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
moving_distance:
|
||||
name: "Moving Distance"
|
||||
filters:
|
||||
- timeout:
|
||||
timeout: 50ms
|
||||
value: last
|
||||
- throttle_with_priority: 50ms
|
||||
|
||||
binary_sensor:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
has_target:
|
||||
name: "Has Target"
|
||||
filters:
|
||||
- settle: 50ms
|
||||
@@ -25,15 +25,25 @@ def clear_host_prefs(device_name: str) -> None:
|
||||
host_prefs_path(device_name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path:
|
||||
"""Write preference entries, replacing the file's contents.
|
||||
|
||||
Returns the path that was written.
|
||||
"""
|
||||
payload = b""
|
||||
for key, data in entries.items():
|
||||
if len(data) > 255:
|
||||
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
|
||||
payload += struct.pack("<IB", key, len(data)) + data
|
||||
path = host_prefs_path(device_name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(payload)
|
||||
return path
|
||||
|
||||
|
||||
def write_host_pref(device_name: str, key: int, data: bytes) -> Path:
|
||||
"""Write a single preference entry, replacing the file's contents.
|
||||
|
||||
Returns the path that was written.
|
||||
"""
|
||||
if len(data) > 255:
|
||||
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
|
||||
path = host_prefs_path(device_name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = struct.pack("<IB", key, len(data)) + data
|
||||
path.write_bytes(payload)
|
||||
return path
|
||||
return write_host_prefs(device_name, {key: data})
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Integration test for entity preference key stability.
|
||||
|
||||
Entity preferences are stored under keys derived from the sanitized object_id
|
||||
hash. This test seeds the host preferences file the way existing firmware
|
||||
wrote it and verifies the state is restored, proving the key scheme has not
|
||||
drifted; a save and reload round trip cannot catch drift because it writes
|
||||
and reads with the same code.
|
||||
|
||||
The second run also seeds the raw-name-hash entries a 2026.8 beta device left
|
||||
behind (see https://github.com/esphome/esphome/pull/18361) and proves they are
|
||||
ignored: the object_id entries win and the beta leftovers are inert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import struct
|
||||
|
||||
from aioesphomeapi import (
|
||||
NumberInfo,
|
||||
NumberState,
|
||||
SwitchInfo,
|
||||
SwitchState,
|
||||
TextInfo,
|
||||
TextState,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id
|
||||
|
||||
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
|
||||
from .host_prefs import clear_host_prefs, write_host_prefs
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import CompileFunction, ConfigWriter
|
||||
|
||||
DEVICE_NAME = "host-pref-key-stability"
|
||||
|
||||
# All entities are on the main device (device_id 0) and their preferences use
|
||||
# no version salt, so the key is just the object_id hash.
|
||||
SWITCH_KEY = fnv1_hash_object_id("Test Switch")
|
||||
NUMBER_KEY = fnv1_hash_object_id("Test Number")
|
||||
|
||||
# Raw-name-hash keys as written by 2026.8 beta firmware; never read by this build
|
||||
SWITCH_BETA_KEY = fnv1_hash_name("Test Switch")
|
||||
NUMBER_BETA_KEY = fnv1_hash_name("Test Number")
|
||||
|
||||
# template_text salts its key with the length limits and pattern hash; this must
|
||||
# match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20,
|
||||
# no pattern configured)
|
||||
TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6)
|
||||
TEXT_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
|
||||
TEXT_BETA_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
|
||||
|
||||
# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes
|
||||
TEXT_MAX_LENGTH = 20
|
||||
|
||||
|
||||
def text_pref_payload(value: str) -> bytes:
|
||||
"""Build the length-prefixed buffer TextSaver stores for a value."""
|
||||
data = value.encode("utf-8")
|
||||
assert len(data) <= TEXT_MAX_LENGTH
|
||||
return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preference_key_stability(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> None:
|
||||
"""Test that preferences stored by earlier firmware are restored."""
|
||||
port, port_socket = reserved_tcp_port
|
||||
|
||||
assert SWITCH_KEY != SWITCH_BETA_KEY
|
||||
assert NUMBER_KEY != NUMBER_BETA_KEY
|
||||
assert TEXT_KEY != TEXT_BETA_KEY
|
||||
|
||||
# Write and compile once
|
||||
config_path = await write_yaml_config(yaml_config)
|
||||
binary_path = await compile_esphome(config_path)
|
||||
|
||||
# Release the reserved port so the binary can bind to it
|
||||
port_socket.close()
|
||||
|
||||
async def boot_and_get_initial_states() -> tuple[
|
||||
SwitchState, NumberState, TextState
|
||||
]:
|
||||
"""Boot the binary and return the restored entity states."""
|
||||
async with (
|
||||
run_binary_and_wait_for_port(binary_path, "127.0.0.1", port),
|
||||
wait_and_connect_api_client(port=port) as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info.name == DEVICE_NAME
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
switch_entity = require_entity(
|
||||
entities, "test_switch", SwitchInfo, "Test Switch"
|
||||
)
|
||||
number_entity = require_entity(
|
||||
entities, "test_number", NumberInfo, "Test Number"
|
||||
)
|
||||
text_entity = require_entity(entities, "test_text", TextInfo, "Test Text")
|
||||
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(lambda s: None)
|
||||
)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
switch_state = initial_state_helper.initial_states[switch_entity.key]
|
||||
number_state = initial_state_helper.initial_states[number_entity.key]
|
||||
text_state = initial_state_helper.initial_states[text_entity.key]
|
||||
assert isinstance(switch_state, SwitchState)
|
||||
assert isinstance(number_state, NumberState)
|
||||
assert isinstance(text_state, TextState)
|
||||
return switch_state, number_state, text_state
|
||||
|
||||
try:
|
||||
# --- Run 1: entries under the object_id-hash keys, exactly as any
|
||||
# earlier firmware wrote them. The restored states prove the key
|
||||
# scheme has not drifted.
|
||||
write_host_prefs(
|
||||
DEVICE_NAME,
|
||||
{
|
||||
SWITCH_KEY: b"\x01", # bool: switch was ON
|
||||
NUMBER_KEY: struct.pack("<f", 42.5),
|
||||
TEXT_KEY: text_pref_payload("hello"),
|
||||
},
|
||||
)
|
||||
switch_state, number_state, text_state = await boot_and_get_initial_states()
|
||||
assert switch_state.state is True, (
|
||||
"Switch state stored under the object_id preference key was lost"
|
||||
)
|
||||
assert number_state.state == 42.5, (
|
||||
"Number value stored under the object_id preference key was lost"
|
||||
)
|
||||
assert text_state.state == "hello", (
|
||||
"Text value stored under the object_id preference key was lost"
|
||||
)
|
||||
|
||||
# --- Run 2: raw-name-hash entries from a 2026.8 beta device present
|
||||
# alongside the object_id entries. The object_id data must win; the
|
||||
# beta entries are never read.
|
||||
write_host_prefs(
|
||||
DEVICE_NAME,
|
||||
{
|
||||
SWITCH_KEY: b"\x01", # current: ON
|
||||
SWITCH_BETA_KEY: b"\x00", # beta leftover: OFF
|
||||
NUMBER_KEY: struct.pack("<f", 13.5), # current
|
||||
NUMBER_BETA_KEY: struct.pack("<f", 99.5), # beta leftover
|
||||
TEXT_KEY: text_pref_payload("world"), # current
|
||||
TEXT_BETA_KEY: text_pref_payload("ignored"), # beta leftover
|
||||
},
|
||||
)
|
||||
switch_state, number_state, text_state = await boot_and_get_initial_states()
|
||||
assert switch_state.state is True, (
|
||||
"Beta raw-name-key data overrode the object_id switch state"
|
||||
)
|
||||
assert number_state.state == 13.5, (
|
||||
"Beta raw-name-key data overrode the object_id number value"
|
||||
)
|
||||
assert text_state.state == "world", (
|
||||
"Beta raw-name-key data overrode the object_id text value"
|
||||
)
|
||||
finally:
|
||||
clear_host_prefs(DEVICE_NAME)
|
||||
@@ -15,6 +15,36 @@ test_uart_mock_ld2420_simple (simple mode):
|
||||
3. Buffer overflow recovery
|
||||
4. 16-digit distance triggers infinite loop pre-fix (PR #14458 bug #1)
|
||||
5. Post-bug-trigger recovery proves the parser survived
|
||||
|
||||
test_uart_mock_ld2420_warm_restart (module streaming at boot):
|
||||
Simulates a warm restart where the module stayed powered and streams energy
|
||||
frames from the moment the firmware starts. Asserts the component never
|
||||
transmits before receiving data from the module, completes setup against
|
||||
the live stream, and publishes sensor data.
|
||||
|
||||
test_uart_mock_ld2420_delayed_boot (module boots slower than the ESP):
|
||||
Simulates a cold boot where the module is silent for 2 seconds. The module
|
||||
locks up until power cycled if it receives data before sending its first
|
||||
frame, so the component must stay quiet until the module talks, then
|
||||
complete setup and keep parsing the stream.
|
||||
|
||||
test_uart_mock_ld2420_restart_button (module restart action):
|
||||
Presses the restart button after setup. The restart hits the module mid
|
||||
transmission, so a few tail bytes of the in-flight frame arrive right after
|
||||
the restart command, then the module is silent for 2 seconds while it
|
||||
boots. The component must not treat the tail bytes as proof the module is
|
||||
up and must only re-run its handshake after the module's first post-boot
|
||||
frame; transmitting into the boot window locks up real hardware.
|
||||
|
||||
test_uart_mock_ld2420_cmd_retry (per-command resend):
|
||||
The module ignores the first config mode enable command and only answers
|
||||
the resend. The handshake must time out once, resend, and complete.
|
||||
|
||||
test_uart_mock_ld2420_give_up (sequence retry and give-up):
|
||||
The module streams and answers everything except the firmware version
|
||||
read. The handshake must retry the whole sequence, eventually give up with
|
||||
a warning instead of marking the component failed, and keep publishing
|
||||
sensor data from the stream afterwards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,7 +55,12 @@ from pathlib import Path
|
||||
from aioesphomeapi import ButtonInfo
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper, SensorStateCollector, find_entity
|
||||
from .state_utils import (
|
||||
InitialStateHelper,
|
||||
SensorStateCollector,
|
||||
find_entity,
|
||||
require_entity,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -160,6 +195,400 @@ async def test_uart_mock_ld2420(
|
||||
)
|
||||
|
||||
|
||||
SETUP_COMPLETE_LOG = "Module setup complete; firmware v2.0.0"
|
||||
|
||||
|
||||
class _LogWatcher:
|
||||
"""Resolves futures when watched substrings appear in device log lines.
|
||||
|
||||
Use as the run_compiled line_callback. watch() returns a future that
|
||||
resolves once a line containing all given substrings has been seen `count`
|
||||
times; `after` gates matching on another future being done, and `until`
|
||||
stops matching once another future is done. collect() gathers every line
|
||||
containing any of the given substrings into `self.collected`.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._watches: list[dict] = []
|
||||
self._collect_substrings: tuple[str, ...] = ()
|
||||
self.collected: list[str] = []
|
||||
|
||||
def watch(
|
||||
self,
|
||||
substrings: str | list[str],
|
||||
*,
|
||||
count: int = 1,
|
||||
after: asyncio.Future | None = None,
|
||||
until: asyncio.Future | None = None,
|
||||
) -> asyncio.Future:
|
||||
subs = [substrings] if isinstance(substrings, str) else substrings
|
||||
watch = {
|
||||
"subs": subs,
|
||||
"count": count,
|
||||
"after": after,
|
||||
"until": until,
|
||||
"future": self._loop.create_future(),
|
||||
"seen": 0,
|
||||
}
|
||||
self._watches.append(watch)
|
||||
return watch["future"]
|
||||
|
||||
def collect(self, *substrings: str) -> None:
|
||||
self._collect_substrings = substrings
|
||||
|
||||
def __call__(self, line: str) -> None:
|
||||
for watch in self._watches:
|
||||
if watch["future"].done():
|
||||
continue
|
||||
if watch["after"] is not None and not watch["after"].done():
|
||||
continue
|
||||
if watch["until"] is not None and watch["until"].done():
|
||||
continue
|
||||
if all(s in line for s in watch["subs"]):
|
||||
watch["seen"] += 1
|
||||
if watch["seen"] >= watch["count"]:
|
||||
watch["future"].set_result(True)
|
||||
if any(s in line for s in self._collect_substrings):
|
||||
self.collected.append(line)
|
||||
|
||||
|
||||
async def _wait_or_fail(awaitable, timeout: float, message) -> None:
|
||||
"""Await with a timeout, translating TimeoutError into pytest.fail.
|
||||
|
||||
`message` may be a string or a zero-argument callable evaluated at
|
||||
failure time (for messages that embed the current collector state).
|
||||
"""
|
||||
try:
|
||||
await asyncio.wait_for(awaitable, timeout=timeout)
|
||||
except TimeoutError:
|
||||
pytest.fail(message() if callable(message) else message)
|
||||
|
||||
|
||||
async def _subscribe_and_wait(client, collector: SensorStateCollector | None = None):
|
||||
"""List entities, subscribe states, and wait for the initial state flood."""
|
||||
entities, _ = await client.list_entities_services()
|
||||
if collector is not None:
|
||||
collector.build_key_mapping(entities)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
on_state = collector.on_state if collector is not None else (lambda s: None)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await _wait_or_fail(
|
||||
initial_state_helper.wait_for_initial_states(),
|
||||
11.0,
|
||||
"Timeout waiting for initial states",
|
||||
)
|
||||
return entities
|
||||
|
||||
|
||||
async def _run_listen_first_test(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
*,
|
||||
post_setup_distance: float | None = None,
|
||||
strict_first: bool = True,
|
||||
) -> None:
|
||||
"""Shared body for the listen-first startup tests.
|
||||
|
||||
Asserts the component never transmits before the module has sent data
|
||||
(real hardware locks up until power cycled if it does), that the setup
|
||||
handshake completes, and that sensor data publishes. When
|
||||
post_setup_distance is given, additionally waits for that value to prove
|
||||
streaming still works after the handshake. strict_first asserts on the
|
||||
first collected state; pass False for fixtures whose stream alternates
|
||||
values, where the first collected state depends on subscribe timing.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
setup_complete = loop.create_future()
|
||||
rx_seen = False
|
||||
tx_before_rx = False
|
||||
failure_lines: list[str] = []
|
||||
|
||||
def line_callback(line: str) -> None:
|
||||
nonlocal rx_seen, tx_before_rx
|
||||
if "uart_mock" in line:
|
||||
if "RX inject" in line or "Injecting" in line:
|
||||
rx_seen = True
|
||||
elif "TX " in line and not rx_seen:
|
||||
tx_before_rx = True
|
||||
if SETUP_COMPLETE_LOG in line and not setup_complete.done():
|
||||
setup_complete.set_result(True)
|
||||
if (
|
||||
"marked FAILED" in line
|
||||
or "was marked as failed" in line
|
||||
or "Communication failed" in line
|
||||
or "No data received from the module" in line
|
||||
):
|
||||
failure_lines.append(line)
|
||||
|
||||
collector = SensorStateCollector(
|
||||
sensor_names=["moving_distance"],
|
||||
binary_sensor_names=["has_target"],
|
||||
)
|
||||
|
||||
post_setup_received = None
|
||||
if post_setup_distance is not None:
|
||||
post_setup_received = collector.add_waiter(
|
||||
lambda: (
|
||||
pytest.approx(post_setup_distance)
|
||||
in collector.sensor_states["moving_distance"]
|
||||
)
|
||||
)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=line_callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
await _subscribe_and_wait(client, collector)
|
||||
|
||||
# Setup handshake must complete once the module has talked
|
||||
await _wait_or_fail(
|
||||
setup_complete,
|
||||
10.0,
|
||||
"Timeout waiting for 'Module setup complete' log line. "
|
||||
"The startup state machine did not finish its handshake.",
|
||||
)
|
||||
|
||||
# Sensor data must flow from the stream
|
||||
await _wait_or_fail(
|
||||
collector.wait_for_all(timeout=5.0),
|
||||
6.0,
|
||||
lambda: (
|
||||
f"Timeout waiting for sensor data. Received:\n"
|
||||
f" sensor_states: {collector.sensor_states}\n"
|
||||
f" binary_states: {collector.binary_states}"
|
||||
),
|
||||
)
|
||||
|
||||
if strict_first:
|
||||
assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0)
|
||||
assert collector.binary_states["has_target"][0] is True
|
||||
else:
|
||||
assert pytest.approx(100.0) in collector.sensor_states["moving_distance"]
|
||||
assert True in collector.binary_states["has_target"]
|
||||
|
||||
if post_setup_received is not None:
|
||||
await _wait_or_fail(
|
||||
post_setup_received,
|
||||
5.0,
|
||||
lambda: (
|
||||
f"Timeout waiting for post-setup frame "
|
||||
f"(distance={post_setup_distance}). Received:\n"
|
||||
f" moving_distance: {collector.sensor_states['moving_distance']}"
|
||||
),
|
||||
)
|
||||
|
||||
# The component must never transmit before the module has talked;
|
||||
# real hardware locks up until power cycled if it does.
|
||||
assert not tx_before_rx, (
|
||||
"Component transmitted on the UART before receiving any data "
|
||||
"from the module; this locks up real LD2420 hardware"
|
||||
)
|
||||
|
||||
assert not failure_lines, f"Unexpected failure log lines: {failure_lines}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2420_warm_restart(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Module streams from boot; component must listen first, then set up."""
|
||||
await _run_listen_first_test(
|
||||
yaml_config, run_compiled, api_client_connected, strict_first=False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2420_delayed_boot(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Module silent for 2 s; component must not transmit into the boot window."""
|
||||
await _run_listen_first_test(
|
||||
yaml_config, run_compiled, api_client_connected, post_setup_distance=50.0
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2420_cmd_retry(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""First config command gets no reply; the resend must recover."""
|
||||
watcher = _LogWatcher()
|
||||
resend_seen = watcher.watch("No reply to startup command")
|
||||
setup_complete = watcher.watch(SETUP_COMPLETE_LOG)
|
||||
watcher.collect(
|
||||
"marked FAILED",
|
||||
"was marked as failed",
|
||||
"Communication failed",
|
||||
"Module setup attempt",
|
||||
)
|
||||
|
||||
collector = SensorStateCollector(
|
||||
sensor_names=["moving_distance"],
|
||||
binary_sensor_names=["has_target"],
|
||||
)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=watcher),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
await _subscribe_and_wait(client, collector)
|
||||
|
||||
# The first enable command is ignored, so a resend must happen
|
||||
await _wait_or_fail(
|
||||
resend_seen, 10.0, "Timeout waiting for the startup command resend log line"
|
||||
)
|
||||
|
||||
# The resend gets an ack and the handshake completes normally
|
||||
await _wait_or_fail(
|
||||
setup_complete,
|
||||
10.0,
|
||||
"Timeout waiting for 'Module setup complete' after the resend",
|
||||
)
|
||||
|
||||
await _wait_or_fail(
|
||||
collector.wait_for_all(timeout=5.0),
|
||||
6.0,
|
||||
lambda: (
|
||||
f"Timeout waiting for sensor data. Received:\n"
|
||||
f" sensor_states: {collector.sensor_states}"
|
||||
),
|
||||
)
|
||||
|
||||
assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0)
|
||||
|
||||
# A single command resend must not burn a whole sequence retry or
|
||||
# produce any failure log line
|
||||
assert not watcher.collected, (
|
||||
f"Unexpected failure log lines: {watcher.collected}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2420_give_up(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Version read never answers; retries then give-up, stream keeps working."""
|
||||
watcher = _LogWatcher()
|
||||
sequence_retry_seen = watcher.watch("Module setup attempt 1 failed; retrying")
|
||||
give_up_seen = watcher.watch("Firmware version and operating mode were never read")
|
||||
# The overflow probe injected at t=22s (after the give-up) makes the
|
||||
# parser log this warning only if it is still running
|
||||
parser_alive_after_give_up = watcher.watch(
|
||||
"Max command length exceeded", after=give_up_seen
|
||||
)
|
||||
watcher.collect("marked FAILED", "was marked as failed")
|
||||
|
||||
collector = SensorStateCollector(
|
||||
sensor_names=["moving_distance"],
|
||||
binary_sensor_names=["has_target"],
|
||||
)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=watcher),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
await _subscribe_and_wait(client, collector)
|
||||
|
||||
# The version read times out three times, then the sequence retries
|
||||
await _wait_or_fail(
|
||||
sequence_retry_seen, 15.0, "Timeout waiting for the sequence retry log line"
|
||||
)
|
||||
|
||||
# After all sequence retries the component gives up with a warning
|
||||
await _wait_or_fail(
|
||||
give_up_seen, 30.0, "Timeout waiting for the give-up log line"
|
||||
)
|
||||
|
||||
# The stream must still be parsed after giving up
|
||||
await _wait_or_fail(
|
||||
parser_alive_after_give_up,
|
||||
20.0,
|
||||
"No parser activity after the give-up; the stream parser "
|
||||
"must keep running in the degraded state",
|
||||
)
|
||||
|
||||
# The stream published sensor data while the handshake was failing
|
||||
assert pytest.approx(100.0) in collector.sensor_states["moving_distance"], (
|
||||
f"Expected the stream to publish distance=100, "
|
||||
f"got: {collector.sensor_states['moving_distance']}"
|
||||
)
|
||||
|
||||
# The whole point of the degraded state: the component keeps running
|
||||
assert not watcher.collected, (
|
||||
f"Component was marked failed: {watcher.collected}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2420_restart_button(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Restart action must not transmit into the module's boot window."""
|
||||
watcher = _LogWatcher()
|
||||
first_setup_complete = watcher.watch(SETUP_COMPLETE_LOG)
|
||||
second_setup_complete = watcher.watch(SETUP_COMPLETE_LOG, count=2)
|
||||
restart_seen = watcher.watch(["[ld2420", "Restarting"])
|
||||
# The module's first frame after its simulated 2 s boot
|
||||
module_frame_after_restart = watcher.watch("RX inject 45 bytes", after=restart_seen)
|
||||
# Config mode enable transmitted before the module's first post-boot
|
||||
# frame; on real hardware this locks the module up
|
||||
tx_into_boot_window = watcher.watch(
|
||||
["uart_mock", "TX ", "FF:00:02:00"],
|
||||
after=restart_seen,
|
||||
until=module_frame_after_restart,
|
||||
)
|
||||
watcher.collect("marked FAILED", "was marked as failed", "Communication failed")
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=watcher),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities = await _subscribe_and_wait(client)
|
||||
|
||||
# Wait for the initial startup handshake to finish
|
||||
await _wait_or_fail(
|
||||
first_setup_complete,
|
||||
10.0,
|
||||
"Timeout waiting for the initial 'Module setup complete'",
|
||||
)
|
||||
|
||||
# Restart the module; the button automation also injects the in-flight
|
||||
# frame tail immediately and the module's first frame 2 s later
|
||||
restart_btn = require_entity(entities, "restart_module", ButtonInfo)
|
||||
client.button_command(restart_btn.key)
|
||||
|
||||
# The handshake must complete again after the module comes back
|
||||
await _wait_or_fail(
|
||||
second_setup_complete,
|
||||
15.0,
|
||||
"Timeout waiting for 'Module setup complete' after the restart. "
|
||||
"The component did not recover from the module restart.",
|
||||
)
|
||||
|
||||
assert not tx_into_boot_window.done(), (
|
||||
"Component transmitted the config handshake into the module's "
|
||||
"boot window after a restart; the in-flight frame tail bytes must "
|
||||
"not count as proof the module is up"
|
||||
)
|
||||
|
||||
assert not watcher.collected, (
|
||||
f"Unexpected failure log lines: {watcher.collected}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2420_simple(
|
||||
yaml_config: str,
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
"""Tests for the shared addressable-strip channel order helpers."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB
|
||||
from esphome.components.light import (
|
||||
channel_colors_struct,
|
||||
migrate_channel_colors,
|
||||
validate_channel_colors,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_IS_RGBW, CONF_RGB_ORDER
|
||||
from esphome.types import ConfigType
|
||||
|
||||
NO_WHITE = "light::ChannelColors::NO_WHITE"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("RGB", "RGB"),
|
||||
("grb", "GRB"),
|
||||
("BRG", "BRG"),
|
||||
("rgbw", "RGBW"),
|
||||
("WRGB", "WRGB"),
|
||||
("GWRB", "GWRB"),
|
||||
],
|
||||
)
|
||||
def test_validate_channel_colors(value: str, expected: str) -> None:
|
||||
assert validate_channel_colors(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"RG", # missing a channel
|
||||
"RGBB", # duplicate channel
|
||||
"RRGB", # duplicate channel, correct length
|
||||
"RGBWW", # two white channels
|
||||
"RGBX", # unknown channel
|
||||
"RGBWX", # unknown channel, correct length
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_validate_channel_colors_rejects_invalid(value: str) -> None:
|
||||
with pytest.raises(cv.Invalid, match="is not a valid channel order"):
|
||||
validate_channel_colors(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("RGB", (0, 1, 2, NO_WHITE)),
|
||||
("GRB", (1, 0, 2, NO_WHITE)),
|
||||
("BRG", (1, 2, 0, NO_WHITE)),
|
||||
("RGBW", (0, 1, 2, 3)),
|
||||
("GRBW", (1, 0, 2, 3)),
|
||||
("WRGB", (1, 2, 3, 0)),
|
||||
("GWRB", (2, 0, 3, 1)),
|
||||
],
|
||||
)
|
||||
def test_channel_colors_struct(value: str, expected: tuple[int, int, int, int]) -> None:
|
||||
struct = channel_colors_struct(value)
|
||||
assert str(struct.base) == "light::ChannelColors"
|
||||
assert tuple(str(arg) for arg in struct.args.values()) == tuple(
|
||||
str(field) for field in expected
|
||||
)
|
||||
|
||||
|
||||
def _migrate(config: ConfigType) -> ConfigType:
|
||||
return migrate_channel_colors(removed_in="2027.3.0", component="test_strip")(config)
|
||||
|
||||
|
||||
def test_migrate_passes_through_channel_colors() -> None:
|
||||
config = {CONF_CHANNEL_COLORS: "GRBW"}
|
||||
assert _migrate(config) == {CONF_CHANNEL_COLORS: "GRBW"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("deprecated", "expected", "named"),
|
||||
[
|
||||
({}, "GRB", "'rgb_order' is"),
|
||||
(
|
||||
{CONF_IS_RGBW: False, CONF_IS_WRGB: False},
|
||||
"GRB",
|
||||
"'rgb_order', 'is_rgbw' and 'is_wrgb' are",
|
||||
),
|
||||
({CONF_IS_RGBW: True}, "GRBW", "'rgb_order' and 'is_rgbw' are"),
|
||||
({CONF_IS_WRGB: True}, "WGRB", "'rgb_order' and 'is_wrgb' are"),
|
||||
],
|
||||
)
|
||||
def test_migrate_folds_deprecated_keys(
|
||||
deprecated: ConfigType,
|
||||
expected: str,
|
||||
named: str,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
config = {CONF_RGB_ORDER: "GRB", "num_leds": 1, **deprecated}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _migrate(config)
|
||||
|
||||
assert result == {CONF_CHANNEL_COLORS: expected, "num_leds": 1}
|
||||
assert f"[test_strip] {named} deprecated" in caplog.text
|
||||
assert f"'{CONF_CHANNEL_COLORS}: {expected}'" in caplog.text
|
||||
assert "2027.3.0" in caplog.text
|
||||
|
||||
|
||||
def test_migrate_does_not_mutate_input() -> None:
|
||||
config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True}
|
||||
_migrate(config)
|
||||
assert config == {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("deprecated", [CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB])
|
||||
def test_migrate_rejects_mixing_old_and_new(deprecated: str) -> None:
|
||||
config = {CONF_CHANNEL_COLORS: "GRBW", deprecated: "GRB"}
|
||||
with pytest.raises(cv.Invalid, match=f"cannot be combined with '{deprecated}'"):
|
||||
_migrate(config)
|
||||
|
||||
|
||||
def test_migrate_reports_every_conflicting_key() -> None:
|
||||
config = {
|
||||
CONF_CHANNEL_COLORS: "GRBW",
|
||||
CONF_RGB_ORDER: "GRB",
|
||||
CONF_IS_RGBW: True,
|
||||
CONF_IS_WRGB: False,
|
||||
}
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="cannot be combined with 'rgb_order', 'is_rgbw' and 'is_wrgb'"
|
||||
):
|
||||
_migrate(config)
|
||||
|
||||
|
||||
def test_migrate_requires_channel_colors() -> None:
|
||||
with pytest.raises(cv.Invalid, match=f"'{CONF_CHANNEL_COLORS}' is required"):
|
||||
_migrate({"num_leds": 1})
|
||||
|
||||
|
||||
def test_migrate_rejects_is_rgbw_with_is_wrgb() -> None:
|
||||
config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True, CONF_IS_WRGB: True}
|
||||
with pytest.raises(cv.Invalid, match="cannot both be enabled"):
|
||||
_migrate(config)
|
||||
@@ -0,0 +1,57 @@
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32_rmt_led_strip.light import (
|
||||
CONF_IS_WRGB,
|
||||
CONF_RGBW_ORDER,
|
||||
_split_rgbw_order,
|
||||
_validate_rgbw_order,
|
||||
_validate_rgbw_order_exclusivity,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_IS_RGBW
|
||||
|
||||
|
||||
def test_validate_rgbw_order() -> None:
|
||||
assert _validate_rgbw_order("rwgb") == "RWGB"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rgbw_order", ["RGB", "RRGB", "RGBWW"])
|
||||
def test_validate_rgbw_order_rejects_invalid_order(rgbw_order: str) -> None:
|
||||
with pytest.raises(cv.Invalid, match="permutation of RGBW"):
|
||||
_validate_rgbw_order(rgbw_order)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rgbw_order", "expected"),
|
||||
[
|
||||
("WRGB", ("RGB", 0)),
|
||||
("RWGB", ("RGB", 1)),
|
||||
("GWRB", ("GRB", 1)),
|
||||
("RGBW", ("RGB", 3)),
|
||||
],
|
||||
)
|
||||
def test_split_rgbw_order(rgbw_order: str, expected: tuple[str, int]) -> None:
|
||||
assert _split_rgbw_order(rgbw_order) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("conflict", [CONF_IS_RGBW, CONF_IS_WRGB])
|
||||
def test_rgbw_order_is_mutually_exclusive(conflict: str) -> None:
|
||||
with pytest.raises(cv.Invalid, match="cannot be used with"):
|
||||
_validate_rgbw_order_exclusivity(
|
||||
{
|
||||
CONF_RGBW_ORDER: "RGBW",
|
||||
CONF_IS_RGBW: conflict == CONF_IS_RGBW,
|
||||
CONF_IS_WRGB: conflict == CONF_IS_WRGB,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_option", [CONF_IS_RGBW, CONF_IS_WRGB])
|
||||
def test_rgbw_order_allows_disabled_legacy_options(legacy_option: str) -> None:
|
||||
config = {
|
||||
CONF_RGBW_ORDER: "RGBW",
|
||||
CONF_IS_RGBW: False,
|
||||
CONF_IS_WRGB: False,
|
||||
}
|
||||
config[legacy_option] = False
|
||||
assert _validate_rgbw_order_exclusivity(config) is config
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user