Compare commits

..
Author SHA1 Message Date
J. Nick Koston 6a0e5ffce8 [ld2420] Address fourth review round
Sync new_config with current_config on the give-up path and gate the
config writing actions on an explicit config read complete flag so a
partial read can never be written to the module's NVM, raise the retry
listen window above the module boot silence, treat replies shorter
than the expected data length as silence, apply the parser mode only
after the mode write is acknowledged, propagate write errors in the
apply and factory reset actions before adopting the new config or
clearing the warning, log drain failures and unbuildable startup
frames, watch the runtime marked as failed log string in the tests,
give the first enable command in the retry fixture genuine silence,
and alternate the warm restart stream values so state deduplication
cannot starve a late subscriber.
2026-08-16 21:59:17 -05:00
J. Nick Koston 713b3b2bc9 [ld2420] Simplify after review rounds
Model the listen settle window as its own state instead of two flags,
pass reception into the state machine as a parameter instead of the
rx_seen_ member, consolidate the duplicated button action guards into
a shared precondition helper, extract the give-up path from the retry
ladder, batch the receive drain reads, rename the command attempt
counter, and deduplicate the newest integration tests with a shared
log watcher and subscription helpers.
2026-08-16 21:13:54 -05:00
J. Nick Koston d6179b6d56 [ld2420] Address third review round
Guarantee a drain pass in every listen phase so bytes that arrived
before it can never satisfy the settle window even when the main loop
stalls, publish the number entities on the give-up path, zero the
reply data before each startup send so short acks cannot store stale
values, zero initialize the config threshold arrays, refuse config
writes when the configuration was never read, guard the revert button
during startup, gate the apply and factory reset warning clear on the
final ack, report the firmware version as unknown in dump_config until
it is read, and add tests for the command resend and the sequence
retry and give-up paths.
2026-08-16 21:12:58 -05:00
J. Nick Koston 73a95c411d [ld2420] Add module restart regression test
Presses the restart button while the module is mid transmission: the
in-flight frame tail bytes arriving right after the restart command
must not count as proof the module is up, and the setup handshake must
only run again after the module's first post-boot frame.
2026-08-16 21:12:58 -05:00
J. Nick Koston de7af3865b [ld2420] Address second review round
Ignore reception during a settle window at the start of each listen
phase so in-flight bytes from a restarting module cannot count as
proof it is up, drain stale replies before each startup send so acks
cannot be matched to the wrong command, always send the blind config
mode exit when abandoning a sequence, log module error replies and an
unknown firmware version on the give-up path, skip transmitting for
states with no command frame, and clear the warning status when a
later apply or factory reset succeeds. Fixture timings adjusted for
the settle window.
2026-08-16 21:12:58 -05:00
J. Nick Koston 338e498960 [ld2420] Address review findings
Clear cmd_active_ when the blocking engine exhausts retries so loop()
does not stay skipped forever, send config mode exit blind before
abandoning a startup sequence so the module resumes streaming, and
repeat the first frame in the delayed boot fixture so the initial
state swallow cannot make the test flaky.
2026-08-16 21:12:58 -05:00
J. Nick Koston 15e1ac2500 [ld2420] Simplify startup state machine and tests
Rebuild startup frames on demand instead of keeping a frame member,
share the ack timeout and retry constants with the blocking engine,
guard the restart button during startup, drop dead code, and factor
the duplicated integration test bodies into a shared helper.
2026-08-16 21:12:58 -05:00
J. Nick Koston 3402ee8bb1 [ld2420] Wait for data from the module before configuring it
The LD2420 locks up until power cycled if it receives any data before
it has sent its first frame after powering on. Setup previously
transmitted immediately, blocking for up to 3 seconds and marking the
component failed on the first timeout. Setup is now a non blocking
state machine that listens for the module before transmitting, retries
the handshake, and degrades to a warning instead of failing.
2026-08-16 21:12:58 -05:00
J. Nick Koston 61d2632851 [ld2420] Extract command frame builders from the blocking senders
Behavior free refactor: split the frame serialization out of
send_cmd_from_array, split the frame construction out of the blocking
command functions into build helpers, name the ack timeout and retry
constants, remove the unused CmdFrameT length field and the unused
get_reg_value_ method. No functional change.
2026-08-16 21:12:28 -05:00
J. Nick Koston e9d940aca4 [ld2420] Drop the setup priority override so setup runs after the UART bus
The component declared setup_priority::BUS, the same tier as the UART
bus itself. At a tie the setup order falls back to registration order,
so on ESP-IDF the component could run setup before uart_driver_install
and the failed write marked the UART bus failed for the whole boot.
Delete the override so the component uses the default DATA priority
like ld2410 and ld2450.
2026-08-16 21:00:05 -05:00
J. Nick Koston 3f01f9895f [esp32_hosted] Require ESP-IDF 5.3 or newer (#18417) 2026-08-16 12:44:49 -07:00
J. Nick Koston 801a1817b5 [esp32] Split crash handler addr2line hint per core (#18418) 2026-08-16 12:44:00 -07:00
J. Nick Koston 32c76ae828 [core] Skip redundant ESP8266 main loop wake posts from ISR context (#18416) 2026-08-16 12:43:27 -07:00
J. Nick Koston c664f5fc95 [bk72xx_ble] Fail early with a clear error on non BLE 5.x SoCs (#18406) 2026-08-16 11:06:22 -07:00
luar123 2bc4681fd6 [zigbee] bump esp-zigbee-sdk to 2.0.4 (#18415) 2026-08-16 13:33:25 -04:00
Jonathan Swoboda 646501b0ef [sensor] Pass NaN through the delta filter again (#18400) 2026-08-16 12:36:51 -04:00
J. Nick Koston de3e657d8b [platformio] Skip ccache when the binary on PATH fails to run (#18407) 2026-08-16 08:42:47 -07:00
esphome[bot] 1add726892 Bump bundled esphome-device-builder to 1.11.0 (#18403) 2026-08-15 13:36:52 -07:00
Samuel Sieb 5a000cf5e4 [rotary_encoder] account for min and max value when resetting (#18197) 2026-08-15 11:16:04 -07:00
Joppy Furr 6ed676fe32 [lvgl] Restore long_press_repeat_time functionality (#18393) 2026-08-14 23:14:53 -07:00
esphome[bot] 7cceddb8a3 Bump bundled esphome-device-builder to 1.10.0 (#18389) 2026-08-15 01:48:27 +00:00
J. Nick Koston 039b897e7b [ethernet] Defer clk_mode removal to 2026.11.0 (#18380) 2026-08-14 18:45:36 -07:00
J. Nick Koston b178f74e5d [core] Save the validated config cache on the first upload or logs run (#18367) 2026-08-14 18:45:23 -07:00
Clyde Stubbs e5224e22ae [ci] Compare merge-branch base ref against the default branch (#18385) 2026-08-15 06:18:22 +10:00
Clyde Stubbs be66e8b99c [ci] Disable CodSpeed benchmarks job outside esphome/esphome (#18372) 2026-08-15 04:33:47 +10:00
Jesse Hills 4b91c8aff4 Merge branch 'beta' into dev 2026-08-14 23:15:57 +12:00
Karl Beecken 617e2ec1e0 [core] fix PYTHONPATH leak (#18360) 2026-08-14 17:23:34 +12:00
J. Nick Koston 1afac0312d [core] Restore cv.parse_esphome_version as a deprecated helper (#18366) 2026-08-14 17:22:22 +12:00
J. Nick Koston b05465145f [core] Add preference key stability integration test (#18364) 2026-08-13 21:14:30 -05:00
dependabot[bot] 990fc402fd Bump bleak from 2.1.1 to 3.0.2 (#16246)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-13 20:24:02 -05:00
J. Nick Koston dd51624fbb [core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361) 2026-08-13 20:06:48 -05:00
dependabot[bot] 45a056e337 Bump platformdirs from 4.11.1 to 4.11.2 (#18363)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-13 18:54:51 -05:00
dependabot[bot] 37782f7206 Bump prek from 0.4.12 to 0.4.13 (#18362)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-13 18:54:37 -05:00
J. Nick Koston 945c2458b3 [core] Load component aliases from a generated registry (#18335) 2026-08-13 13:31:19 -05:00
J. Nick Koston 7420d23867 [ota] Retry uploads that fail from network errors (#18332) 2026-08-13 13:31:06 -05:00
J. Nick Koston 191686c5b3 [wifi] Fix ESP8266 crash in cnx_node_search when lwIP transmits after disconnect (#18333) 2026-08-13 13:30:53 -05:00
J. Nick Koston 137351fa8d [esp32_ble] Silence spurious warnings for local key GAP events (#18359) 2026-08-13 13:27:01 -05:00
J. Nick Koston db5173697a [esp32_ble_tracker] Fix missed BLE advertisements with WiFi on ESP-IDF 5.5.5 (#18356) 2026-08-13 13:22:48 -05:00
Oliver KleineckeOliver Kleineckepre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
c7940382a9 [const] move CONF_LABEL to components/const (#18354)
Co-authored-by: Oliver Kleinecke <kleinecke.oliver@googlemail.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-08-13 14:18:22 -04:00
esphome[bot] 87045ab9c0 Bump aioesphomeapi from 45.10.1 to 45.10.2 (#18357) 2026-08-13 11:14:14 -05:00
Oliver KleineckeandOliver Kleinecke f1c4086778 [const] Move CONF_SLOT to components/const (#18350)
Co-authored-by: Oliver Kleinecke <p1ngb4ck@users.noreply.github.com>
2026-08-13 23:36:11 +12:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Jonathan Swoboda
f337d0acff Bump pylint from 4.0.6 to 4.0.7 (#18320)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-13 02:13:29 +00:00
J. Nick Koston 9cc05b30d4 [web_server_base] Stop deleting the web server on captive portal teardown (#18324) 2026-08-12 21:09:01 -05:00
Jesse Hills e0b68c4d6d Merge branch 'beta' into dev 2026-08-13 14:06:46 +12:00
J. Nick Koston 8e624b4117 [core] Retry framework downloads on transient network errors (#18330) 2026-08-13 12:14:50 +12:00
J. Nick Koston 787a909aa4 [core] Don't block logs startup on MQTT IP discovery when addresses are known (#18313) 2026-08-13 12:11:32 +12:00
J. Nick Koston 905485b673 [ld2420] Fix out-of-bounds read when device reports unknown command error (#18322) 2026-08-13 11:49:34 +12:00
esphome[bot] 99677390e0 Bump bundled esphome-device-builder to 1.9.6 (#18328) 2026-08-12 16:27:14 -05:00
Kevin Ahrendt f2121130f9 [sendspin] Bump sendspin-cpp to v0.7.2 (#18316) 2026-08-12 12:49:54 -04:00
esphome[bot] 1a01c34ec4 Bump aioesphomeapi from 45.10.0 to 45.10.1 (#18318) 2026-08-12 16:33:45 +00:00
J. Nick Koston 3c46cc9c35 [usb_uart] Fix uint32_t format specifier warning in pl2303 (#18310) 2026-08-12 10:59:24 -04:00
Jesse Hills e192ec8fee Merge branch 'beta' into dev 2026-08-13 00:29:08 +12:00
Jesse Hills 8a1aa5753d Bump version to 2026.9.0-dev 2026-08-12 22:53:42 +12:00
191 changed files with 3634 additions and 4819 deletions
+2 -2
View File
@@ -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 .
+2 -1
View File
@@ -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']);
});
});
// ---------------------------------------------------------------------------
+3 -25
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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.2
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
View File
@@ -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.13.1
RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0
RUN \
platformio settings set enable_telemetry No \
+16 -18
View File
@@ -762,11 +762,9 @@ def _wrap_to_code(name, comp, yaml_util):
async def wrapped(conf):
cg.add(cg.LineComment(f"{name}:"))
if comp.config_schema is not None:
# sort_keys: voluptuous fills defaults in set order, so an
# unsorted dump would churn main.cpp and relink every run
conf_str = yaml_util.dump(conf, sort_keys=True)
conf_str = yaml_util.dump(conf)
conf_str = conf_str.replace("//", "")
# remove trailing \ to avoid multi-line comment warning
# remove tailing \ to avoid multi-line comment warning
conf_str = conf_str.replace("\\\n", "\n")
cg.add(cg.LineComment(indent(conf_str)))
await coro(conf)
@@ -2734,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(
@@ -2743,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
+69 -8
View File
@@ -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
+1 -1
View File
@@ -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")
+8 -8
View File
@@ -417,15 +417,15 @@ void APIConnection::finalize_iterator_sync_() {
}
void APIConnection::process_iterator_batch_(ComponentIterator &iterator) {
// Budget by remaining batch capacity so a pass cannot overfill the batch;
// stops early on a refused send and resumes next loop pass
size_t batch_size = this->deferred_batch_.size();
if (batch_size < MAX_INITIAL_BATCH_SIZE)
iterator.try_advance(MAX_INITIAL_BATCH_SIZE - batch_size);
size_t initial_size = this->deferred_batch_.size();
size_t max_batch = MAX_INITIAL_PER_BATCH;
while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) {
iterator.advance();
}
// Flush immediately once enough is queued (not guaranteed every pass);
// partial batches go out via the batch timer or finalize_iterator_sync_()
if (this->deferred_batch_.size() >= MAX_INITIAL_BATCH_SIZE) {
// If the batch is full, process it immediately
// Note: iterator.advance() already calls schedule_batch_() via schedule_message_()
if (this->deferred_batch_.size() >= max_batch) {
this->process_batch_();
}
}
+4 -4
View File
@@ -53,11 +53,11 @@ void log_dropped_message(const char *tag, int line, const LogString *what);
// Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Deferred batch size cap during initial state/info sync
static constexpr size_t MAX_INITIAL_BATCH_SIZE = 34;
// Maximum number of entities to process in a single batch during initial state/info sending
static constexpr size_t MAX_INITIAL_PER_BATCH = 34;
// Verify MAX_MESSAGES_PER_BATCH (defined in api_frame_helper.h) can hold the initial batch
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_BATCH_SIZE,
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_BATCH_SIZE");
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH,
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH");
#ifdef USE_BENCHMARK
class APIConnection;
+1 -1
View File
@@ -36,7 +36,7 @@ static constexpr uint16_t MAX_MESSAGE_SIZE = 32768; // 32 KiB for ESP32 and oth
static constexpr uint16_t RX_BUF_NULL_TERMINATOR = 1;
// Maximum number of messages to batch in a single write operation
// Must be >= MAX_INITIAL_BATCH_SIZE in api_connection.h (enforced by static_assert there)
// Must be >= MAX_INITIAL_PER_BATCH in api_connection.h (enforced by static_assert there)
static constexpr size_t MAX_MESSAGES_PER_BATCH = 34;
// Max client name length (e.g., "Home Assistant 2026.1.0.dev0" = 28 chars)
+1 -9
View File
@@ -95,17 +95,9 @@ bool ListEntitiesIterator::on_end() { return this->client_->send_list_info_done(
ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(client) {}
#ifdef USE_API_USER_DEFINED_ACTIONS
// Yield after every Nth service; bounds direct (non-batched) writes per loop pass
static constexpr uint8_t SERVICE_YIELD_INTERVAL = 3;
bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) {
auto resp = service->encode_list_service_response();
if (!this->client_->send_message(resp))
return false;
// at_ is this service's index
if ((this->at_ + 1) % SERVICE_YIELD_INTERVAL == 0)
this->yield_after_step_();
return true;
return this->client_->send_message(resp);
}
#endif
@@ -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_{};
+24 -17
View File
@@ -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]))
+15 -15
View File
@@ -4,12 +4,9 @@ The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack
bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
on this component and contain no SDK calls of their own.
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7252N/BK7253 (BLE 5.2),
and any future BLE-5.x SoC. BK7238 (BLE 5.2) is blocked for now: with BLE
compiled in, the Beken SDK erases the bootloader flash sector at boot because
LibreTiny's partition table has no BLE bonding entry (esphome#18646,
libretiny-eu/libretiny#408). Known non-5.x families and BK7238 are rejected in
to_code. Unknown families are capability-checked at compile time via
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253
(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in
to_code; unknown families are capability-checked at compile time via
`__has_include("app_ble.h")`, a header only on the BLE 5.x include path
(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build
fails with a clear #error.
@@ -68,14 +65,6 @@ def _unsupported_family_message(family: str) -> str | None:
)
if family == FAMILY_BK7231Q:
return "bk72xx_ble does not support BK7231Q: this SoC has no BLE"
if family == FAMILY_BK7238:
return (
"bk72xx_ble is disabled on BK7238: with BLE compiled in, the Beken SDK "
"erases the bootloader flash sector at boot and the device can no longer "
"start (see https://github.com/esphome/esphome/issues/18646); support "
"returns once the LibreTiny partition table fix "
"(libretiny-eu/libretiny#408) is released"
)
return None
@@ -125,7 +114,18 @@ async def to_code(config: ConfigType) -> None:
# BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is
# derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++
# which path is available so it doesn't reference a missing symbol.
if libretiny.get_libretiny_family() == FAMILY_BK7231N:
family = libretiny.get_libretiny_family()
if family == FAMILY_BK7231N:
cg.add_define("BK72XX_BLE_HAS_COMMON_BDADDR")
elif family == FAMILY_BK7238:
# ESPHome's LibreTiny disables BLE on BK7238 because the SDK can hang at
# WiFi STA startup when BLE init runs. This component re-enables BLE, so
# warn loudly: BK7238 is accepted but not hardware-verified and may be
# WiFi-unstable with BLE on.
_LOGGER.warning(
"bk72xx_ble on BK7238: enabling BLE is known to risk a WiFi STA startup "
"hang on this family and is not yet hardware-verified. Expect possible "
"instability."
)
cg.add_define("USE_BK72XX_BLE")
+16 -27
View File
@@ -206,36 +206,32 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
interval = config[CONF_INTERVAL]
window = config[CONF_WINDOW]
# Labels are reused in every error below; the optional one names its key.
windows = [("Scan window", window)]
if (connection_window := config.get(CONF_CONNECTION_SCAN_WINDOW)) is not None:
windows.append((CONF_CONNECTION_SCAN_WINDOW, connection_window))
for name, value in windows:
if value > interval:
raise cv.Invalid(
f"{name} ({value}) needs to be smaller than scan interval ({interval})"
)
if window > interval:
raise cv.Invalid(
f"Scan window ({window}) needs to be smaller than scan interval ({interval})"
)
# BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the
# controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range
# values here instead of letting the unit conversion silently overflow.
for name, value in (("Scan interval", interval), *windows):
for name, value in (("interval", interval), ("window", window)):
if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000:
raise cv.Invalid(f"{name} ({value}) must be between 2.5 ms and 10240 ms")
raise cv.Invalid(
f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms"
)
# Validate what actually reaches the controller: both values are truncated to
# whole 0.625 ms units, so a window/interval pair that differs by less than one
# unit collapses to the same value — silently programming a 100 % duty cycle
# (radio permanently on) from a config that asked for less.
interval_units = to_ble_units(interval)
for name, value in windows:
if to_ble_units(value) == interval_units and value < interval:
raise cv.Invalid(
f"{name} ({value}) and interval ({interval}) both truncate to "
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
f"cycle. Separate them by at least 0.625 ms."
)
window_units = to_ble_units(window)
if window_units == interval_units and window < interval:
raise cv.Invalid(
f"Scan window ({window}) and interval ({interval}) both truncate to "
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
f"cycle. Separate them by at least 0.625 ms."
)
if interval.total_microseconds * 3 > duration.total_microseconds:
raise cv.Invalid(
@@ -251,14 +247,11 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
# their own; also the fallback for esp32's conditional default.
DEFAULT_SCAN_WINDOW = "30ms"
CONF_CONNECTION_SCAN_WINDOW = "connection_scan_window"
def scan_parameters_schema(
interval_default: str,
*,
window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW,
connection_window: bool = False,
) -> cv.All:
"""Build the scan_parameters value schema shared by all BLE trackers.
@@ -270,9 +263,7 @@ def scan_parameters_schema(
can adjust it once sibling keys are resolved). The `active` option
(default on) is unconditional: active scanning is part of the tracker
contract — every current proxy client assumes it, so a passive-only
tracker must not share this schema. connection_window opts in to the
`connection_scan_window` option for trackers that can fall back to a
smaller window while a GATT connection is active.
tracker must not share this schema.
"""
schema = {
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
@@ -281,8 +272,6 @@ def scan_parameters_schema(
cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
}
if connection_window:
schema[cv.Optional(CONF_CONNECTION_SCAN_WINDOW)] = cv.positive_time_period
return cv.All(cv.Schema(schema), validate_scan_parameters)
+2 -2
View File
@@ -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"
+26 -37
View File
@@ -68,7 +68,6 @@ PATTERN_CONFIGS = {
"PULSE": {
CONF_UNIT_OF_MEASUREMENT: UNIT_PULSES,
CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY,
CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
CONF_ACCURACY_DECIMALS: 0,
},
"PF": {
@@ -79,13 +78,12 @@ PATTERN_CONFIGS = {
},
}
# BASE_SCHEMA intentionally omits state_class and accuracy_decimals defaults.
# Passing them to sensor_schema() would register them via cv.Optional(key, default=...),
# making them always present in the validated config dict and preventing
# apply_tag_defaults from overriding them with the correct per-prefix values.
# They are injected by apply_tag_defaults below, after running through
# sensor.validate_state_class() so the value is code-generation-ready.
BASE_SCHEMA = sensor.sensor_schema(EmonTxSensor).extend(
# Create a base schema that's flexible for any tag
BASE_SCHEMA = sensor.sensor_schema(
EmonTxSensor,
state_class=STATE_CLASS_MEASUREMENT,
accuracy_decimals=0,
).extend(
{
cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx),
cv.Required(CONF_TAG_NAME): cv.string,
@@ -93,43 +91,34 @@ BASE_SCHEMA = sensor.sensor_schema(EmonTxSensor).extend(
)
def _apply_defaults(config: ConfigType, defaults: dict) -> None:
"""Inject defaults into config, skipping keys already set by the user.
state_class values are run through validate_state_class so they are
code-generation-ready, matching what sensor_schema() would normally do."""
for key, value in defaults.items():
if key not in config:
if key == CONF_STATE_CLASS:
value = sensor.validate_state_class(value)
config[key] = value
def apply_tag_defaults(config: ConfigType) -> ConfigType:
"""Apply defaults based on tag prefix if applicable, but don't restrict any tags."""
tag = config[CONF_TAG_NAME]
if len(tag) >= 2:
tag_upper = tag.upper()
# Skip if tag is too short
if len(tag) < 2:
return config
for pattern, pattern_config in PATTERN_CONFIGS.items():
if tag_upper.startswith(pattern):
_apply_defaults(config, pattern_config)
return config
# Check if this tag starts with a known prefix
tag_upper = tag.upper()
# Only apply defaults for known prefixes with numeric indices (e.g. E1, V2, T3)
prefix = tag_upper[0]
if prefix in SENSOR_CONFIGS and tag[1:].isdigit():
_apply_defaults(config, SENSOR_CONFIGS[prefix])
for pattern, pattern_config in PATTERN_CONFIGS.items():
if tag_upper.startswith(pattern):
# Apply pattern defaults if not overridden by user
for key, value in pattern_config.items():
if key not in config:
config[key] = value
return config
# Fall back to generic defaults for tags with no known prefix
_apply_defaults(
config,
{
CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
CONF_ACCURACY_DECIMALS: 0,
},
)
# Only apply defaults for known prefixes with numeric indices
prefix = tag_upper[0]
if prefix in SENSOR_CONFIGS and len(tag) > 1 and tag[1:].isdigit():
# Apply defaults for known tag types, but only if not overridden by user
defaults = SENSOR_CONFIGS[prefix]
for key, value in defaults.items():
if key not in config:
config[key] = value
return config
+40 -41
View File
@@ -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,
+7 -54
View File
@@ -124,15 +124,6 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou
// Version is uint32_t because it would be padded to 4 bytes anyway before the next
// uint32_t field, so we use the full width rather than wasting 3 bytes of padding.
static constexpr uint32_t CRASH_DATA_VERSION = 4;
#if CONFIG_IDF_TARGET_ARCH_XTENSA
// EXCCAUSE is a 6-bit register; larger recorded values mean the frame's
// cause/vaddr slots were never written (not a real exception frame).
static constexpr uint32_t XTENSA_EXCCAUSE_COUNT = XCHAL_EXCCAUSE_NUM;
#elif CONFIG_IDF_TARGET_ARCH_RISCV
// Synchronous mcause exception codes are small and have no interrupt bit;
// anything else in a non-pseudo record is a stale slot.
static constexpr uint32_t RISCV_EXCEPTION_CAUSE_COUNT = 32;
#endif
struct RawCrashData {
uint32_t version;
uint32_t magic;
@@ -207,28 +198,10 @@ void crash_handler_clear() {
s_raw_crash_data.magic = 0;
}
// Whether the cause slot was written by a real exception frame.
static bool cause_slot_was_written() {
#if CONFIG_IDF_TARGET_ARCH_XTENSA
return s_raw_crash_data.cause < XTENSA_EXCCAUSE_COUNT;
#else
return s_raw_crash_data.cause < RISCV_EXCEPTION_CAUSE_COUNT;
#endif
}
// Look up the exception cause as a human-readable string.
// Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays
// not exposed via any public API.
static const char *get_exception_reason() {
uint8_t exception = s_raw_crash_data.exception;
if (exception == PANIC_EXCEPTION_ABORT || exception == PANIC_EXCEPTION_TWDT) {
// Abort-class panics carry no cause register
return nullptr;
}
if (!cause_slot_was_written()) {
// Garbage from old-build or corrupt records; report just the type
return nullptr;
}
#if CONFIG_IDF_TARGET_ARCH_XTENSA
if (s_raw_crash_data.pseudo_excause) {
// SoC-level panic: watchdog, cache error, etc.
@@ -381,11 +354,10 @@ static const char *const FAULT_ADDR_REG = "MTVAL";
static const char *const FAULT_ADDR_REG_LOWER = "mtval";
#endif
// Whether the fault address is meaningful: real CPU faults with a validly
// written frame only.
// Whether the fault address is meaningful real CPU faults only, not
// aborts/watchdogs or SoC-level pseudo exceptions.
static bool has_fault_addr() {
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause &&
cause_slot_was_written();
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause;
}
// The record was captured by a different firmware build (it survives soft
@@ -486,10 +458,6 @@ void crash_handler_log() {
// into NOINIT memory before the normal panic handler runs.
//
extern "C" {
// Set by IDF's task watchdog (task_wdt.c, no header) before it simulates an
// abort; weak so builds without the task watchdog still link.
extern bool g_twdt_isr __attribute__((weak));
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
// Names are mandated by the --wrap linker mechanism
extern void __real_esp_panic_handler(panic_info_t *info);
@@ -502,14 +470,6 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
s_raw_crash_data.exception = (uint8_t) info->exception;
s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0;
s_raw_crash_data.crashed_core = (uint8_t) info->core;
if (g_panic_abort) {
// IDF reclassifies to ABORT only inside esp_panic_handler(), after this
// wrapper captured info->exception; correct it here. TWDT is our own
// distinction (IDF never assigns PANIC_EXCEPTION_TWDT). The abort text is
// not stored; the symbolized backtrace already identifies the site.
bool is_twdt = &g_twdt_isr != nullptr && g_twdt_isr;
s_raw_crash_data.exception = (uint8_t) (is_twdt ? PANIC_EXCEPTION_TWDT : PANIC_EXCEPTION_ABORT);
}
// Zero unconditionally so a null frame doesn't leave stale .noinit data from a previous boot
s_raw_crash_data.cause = 0;
s_raw_crash_data.fault_addr = 0;
@@ -527,12 +487,8 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
// Xtensa: walk the backtrace using the public API
if (info->frame != nullptr) {
auto *xt_frame = (XtExcFrame *) info->frame;
if (!g_panic_abort) {
// Abort-class frames carry no useful cause/vaddr: TWDT task snapshots
// never wrote them and abort() traps describe only the synthetic trap.
s_raw_crash_data.cause = xt_frame->exccause;
s_raw_crash_data.fault_addr = xt_frame->excvaddr;
}
s_raw_crash_data.cause = xt_frame->exccause;
s_raw_crash_data.fault_addr = xt_frame->excvaddr;
s_raw_crash_data.backtrace_count = walk_xtensa_backtrace(xt_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE);
}
@@ -554,11 +510,8 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
// RISC-V: capture MEPC + RA, then scan stack for code addresses
if (info->frame != nullptr) {
auto *rv_frame = (RvExcFrame *) info->frame;
if (!g_panic_abort) {
// See the Xtensa branch: abort-class frames carry no valid cause/vaddr.
s_raw_crash_data.cause = rv_frame->mcause;
s_raw_crash_data.fault_addr = rv_frame->mtval;
}
s_raw_crash_data.cause = rv_frame->mcause;
s_raw_crash_data.fault_addr = rv_frame->mtval;
s_raw_crash_data.backtrace_count =
capture_riscv_backtrace(rv_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE, &s_raw_crash_data.reg_frame_count);
}
+1 -21
View File
@@ -643,28 +643,8 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa
App.wake_loop_threadsafe();
return;
// Log the result of connection parameter updates: a peer can reject or
// never answer an update, and without this the link silently stays on the
// old parameters (visible only as unexplained supervision timeouts).
case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: {
if (param->update_conn_params.status != ESP_BT_STATUS_SUCCESS) {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(param->update_conn_params.bda, mac_s);
ESP_LOGW(TAG, "[%s] Conn param update failed, status=%d", mac_s, param->update_conn_params.status);
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
else {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(param->update_conn_params.bda, mac_s);
ESP_LOGV(TAG, "[%s] Conn params updated: interval=%u (x1.25ms) latency=%u timeout=%u (x10ms)", mac_s,
param->update_conn_params.conn_int, param->update_conn_params.latency,
param->update_conn_params.timeout);
}
#endif
return;
}
// Ignore these GAP events as they are not relevant for our use case
case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT:
case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT:
case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete
case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm
@@ -7,7 +7,6 @@ import logging
from esphome import automation
import esphome.codegen as cg
from esphome.components import ble_device_base, esp32_ble, ota
from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.esp32 import (
add_idf_sdkconfig_option,
@@ -73,9 +72,8 @@ def _get_required_features() -> set[BLEFeatures]:
# Slot counters sizing the tracker's StaticVector storage; one request per
# registered listener or client.
CLIENT_COUNT_DEFINE = "ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT"
_request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT")
_request_client_slot = cg.slot_counter(CLIENT_COUNT_DEFINE)
_request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT")
def register_ble_features(features: set[BLEFeatures]) -> None:
@@ -148,7 +146,6 @@ class TrackerData:
"""Per-run validation state, namespaced under DOMAIN in CORE.data."""
scan_window_defaulted: bool = False
connection_window_injected: bool = False
def _get_data() -> TrackerData:
@@ -177,34 +174,17 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
honors the window strictly (>= 5.5.5); without the arbiter a full-duty
scan would starve wifi outright, and a user-set window is never touched.
Raising to the interval cannot invalidate the already-validated
parameters, so no re-validation is needed. The connection window is
checked against the window here, after the raise.
parameters, so no re-validation is needed.
"""
params = config[CONF_SCAN_PARAMETERS]
if (
_get_data().scan_window_defaulted
and config.get(CONF_SOFTWARE_COEXISTENCE)
and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION
):
params = config[CONF_SCAN_PARAMETERS]
# Copy so the config dump shows a plain value instead of a YAML
# anchor/alias pair pointing at the interval.
params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL])
# Arm the connection-time fallback unless the user set one. Injected
# after validation; safe because it equals the validated window default.
if CONF_CONNECTION_SCAN_WINDOW not in params:
params[CONF_CONNECTION_SCAN_WINDOW] = cv.positive_time_period(
ble_device_base.DEFAULT_SCAN_WINDOW
)
_get_data().connection_window_injected = True
if (
connection_window := params.get(CONF_CONNECTION_SCAN_WINDOW)
) is not None and connection_window > params[CONF_WINDOW]:
# A larger value would widen the scan during connections.
raise cv.Invalid(
f"{CONF_CONNECTION_SCAN_WINDOW} ({connection_window}) needs to be "
f"smaller than the scan window ({params[CONF_WINDOW]})",
path=[CONF_SCAN_PARAMETERS, CONF_CONNECTION_SCAN_WINDOW],
)
return config
@@ -213,7 +193,7 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
# window/interval pairs that collapse to the same 0.625 ms unit count.
# The window default is conditional (see _scan_window_default above).
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
"320ms", window_default=_scan_window_default, connection_window=True
"320ms", window_default=_scan_window_default
)
# Codegen helpers are owned by ble_device_base; kept under the historical names
@@ -307,25 +287,6 @@ async def to_code(config):
cg.add(var.set_scan_duration(params[CONF_DURATION]))
cg.add(var.set_scan_interval(ble_device_base.to_ble_units(params[CONF_INTERVAL])))
cg.add(var.set_scan_window(ble_device_base.to_ble_units(params[CONF_WINDOW])))
if (connection_window := params.get(CONF_CONNECTION_SCAN_WINDOW)) is not None:
# Emitted at FINAL so a scan-only build, where the guarded C++ path
# compiles out, skips the call entirely.
window_units = ble_device_base.to_ble_units(connection_window)
@coroutine_with_priority(CoroPriority.FINAL)
async def _emit_connection_scan_window() -> None:
if cg.get_slot_count(CLIENT_COUNT_DEFINE):
cg.add(var.set_connection_scan_window(window_units))
elif not _get_data().connection_window_injected:
# Warn only for a user-set value; the injected default drops silently.
_LOGGER.warning(
"'%s' has no effect because this build has no BLE client "
"components (for example bluetooth_proxy with active "
"connections, or ble_client)",
CONF_CONNECTION_SCAN_WINDOW,
)
CORE.add_job(_emit_connection_scan_window)
cg.add(var.set_scan_active(params[CONF_ACTIVE]))
cg.add(var.set_scan_continuous(params[CONF_CONTINUOUS]))
@@ -122,9 +122,6 @@ void ESP32BLETracker::loop() {
// - start_scan_(): scanner_state_ becomes IDLE via set_scanner_state_() in cleanup_scan_state_()
// - try_promote_discovered_clients_(): client enters DISCOVERED via set_state(), or
// connecting client finishes (state change), or scanner reaches RUNNING/IDLE
// - connection-window restart: scan_params_ is only written in start_scan_()
// (which changes scanner state via set_scanner_state_()), and
// counts.active/disconnecting only change on client state changes
//
// All conditions that affect the logic below are tied to state changes that increment
// state_version_, so the fast path is safe.
@@ -147,19 +144,6 @@ void ESP32BLETracker::loop() {
(this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) {
this->handle_scanner_failure_();
}
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// The programmed window no longer matches the connection state (typically
// the last connection dropped): restart so the right window applies now
// instead of at the end of the scan period. Continuous only (a user-started
// scan would not restart); !disconnecting matches the restart gate below.
if (this->scanner_state_ == ScannerState::RUNNING && this->scan_continuous_ && !counts.disconnecting &&
this->scan_params_.scan_window != this->desired_scan_window_(counts.active)) {
// Same logical scan period continues: no on_scan_end sweeps for this
// restart. Only armed when the stop was issued.
this->skip_next_scan_end_ = this->stop_scan_();
}
#endif
/*
Avoid starting the scanner if:
@@ -211,23 +195,19 @@ void ESP32BLETracker::stop_scan() {
// reason at D themselves, and the user-facing stop action is deliberate.
ESP_LOGV(TAG, "Stopping scan.");
this->scan_continuous_ = false;
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// The window-change restart is abandoned with continuous scanning.
this->skip_next_scan_end_ = false;
#endif
this->stop_scan_();
}
void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); }
bool ESP32BLETracker::stop_scan_() {
void ESP32BLETracker::stop_scan_() {
if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) {
// IDLE means there is nothing to stop; STOPPING means a stop is already in
// flight and will finish on its own. Neither is an error.
if (this->scanner_state_ != ScannerState::IDLE && this->scanner_state_ != ScannerState::STOPPING) {
ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_));
}
return false;
return;
}
// Reset timeout state machine when stopping scan
this->scan_timeout_state_ = ScanTimeoutState::INACTIVE;
@@ -235,9 +215,8 @@ bool ESP32BLETracker::stop_scan_() {
esp_err_t err = esp_ble_gap_stop_scanning();
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ble_gap_stop_scanning failed: %d", err);
return false;
return;
}
return true;
}
void ESP32BLETracker::start_scan_(bool first) {
@@ -251,11 +230,16 @@ void ESP32BLETracker::start_scan_(bool first) {
}
this->set_scanner_state_(ScannerState::STARTING);
ESP_LOGV(TAG, "Starting scan, set scanner state to STARTING.");
if (!first)
this->notify_scan_end_();
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
this->skip_next_scan_end_ = false;
if (!first) {
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
#endif
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
for (auto *listener : this->neutral_listeners_)
listener->on_scan_end();
#endif
}
#ifdef USE_ESP32_BLE_DEVICE
this->discovered_log_.clear();
#endif
@@ -263,17 +247,7 @@ void ESP32BLETracker::start_scan_(bool first) {
this->scan_params_.own_addr_type = BLE_ADDR_TYPE_PUBLIC;
this->scan_params_.scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL;
this->scan_params_.scan_interval = this->scan_interval_;
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// Count fresh: an automation can start a scan before loop() refreshes the counts.
const uint32_t window = this->desired_scan_window_(this->count_client_states_().active);
if (window != this->scan_window_) {
// Guarantee the connection airtime instead of scanning wall to wall.
ESP_LOGV(TAG, "Connection active, using %" PRIu32 " unit scan window", window);
}
#else
const uint32_t window = this->scan_window_;
#endif
this->scan_params_.scan_window = window;
this->scan_params_.scan_window = this->scan_window_;
// Start timeout monitoring in loop() instead of using scheduler
// This prevents false reboots when the loop is blocked
@@ -434,11 +408,6 @@ void ESP32BLETracker::dump_config() {
" Continuous Scanning: %s",
this->scan_duration_, this->scan_interval_ * 0.625f, this->scan_window_ * 0.625f,
this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_));
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
if (this->connection_scan_window_ != 0) {
ESP_LOGCONFIG(TAG, " Connection Scan Window: %.1f ms", this->connection_scan_window_ * 0.625f);
}
#endif
ESP_LOGCONFIG(TAG,
" Scanner State: %s\n"
" Connecting: %d, discovered: %d, disconnecting: %d, active: %d",
@@ -518,18 +487,6 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) {
// Reset timeout state machine instead of cancelling scheduler timeout
this->scan_timeout_state_ = ScanTimeoutState::INACTIVE;
this->notify_scan_end_();
this->set_scanner_state_(ScannerState::IDLE);
}
void ESP32BLETracker::notify_scan_end_() {
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// Window-change restart continues the same scan period; the flag stays set
// across the stop and is cleared by the restart in start_scan_.
if (this->skip_next_scan_end_)
return;
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
@@ -538,6 +495,8 @@ void ESP32BLETracker::notify_scan_end_() {
for (auto *listener : this->neutral_listeners_)
listener->on_scan_end();
#endif
this->set_scanner_state_(ScannerState::IDLE);
}
void ESP32BLETracker::handle_scanner_failure_() {
@@ -575,8 +534,6 @@ void ESP32BLETracker::try_promote_discovered_clients_() {
}
ESP_LOGD(TAG, "Promoting client to connect");
// A connect ends the scan period a window-change restart was continuing.
this->skip_next_scan_end_ = false;
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
this->update_coex_preference_(true);
#endif
@@ -169,9 +169,6 @@ class ESP32BLETracker final : public Component,
void set_scan_duration(uint32_t scan_duration) { scan_duration_ = scan_duration; }
void set_scan_interval(uint32_t scan_interval) { scan_interval_ = scan_interval; }
void set_scan_window(uint32_t scan_window) { scan_window_ = scan_window; }
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
void set_connection_scan_window(uint32_t scan_window) { connection_scan_window_ = scan_window; }
#endif
void set_scan_active(bool scan_active) { scan_active_ = scan_active; }
bool get_scan_active() const { return scan_active_; }
void set_scan_continuous(bool scan_continuous) { scan_continuous_ = scan_continuous; }
@@ -229,10 +226,7 @@ class ESP32BLETracker final : public Component,
ScannerState get_scanner_state() const { return this->scanner_state_; }
protected:
/// Returns true when a stop was issued to the controller.
bool stop_scan_();
/// Fire on_scan_end on every listener unless a window-change restart suppressed it.
void notify_scan_end_();
void stop_scan_();
/// Start a single scan by setting up the parameters and doing some esp-idf calls.
void start_scan_(bool first);
/// Called when a `ESP_GAP_BLE_SCAN_RESULT_EVT` event is received.
@@ -319,15 +313,6 @@ class ESP32BLETracker final : public Component,
uint32_t scan_duration_;
uint32_t scan_interval_;
uint32_t scan_window_;
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
/// Window used while a GATT connection is active; set by the user, or
/// defaulted when the window was raised to full duty (0 = no fallback).
uint32_t connection_scan_window_{0};
/// The window to scan at for the given number of active GATT connections.
uint32_t desired_scan_window_(uint8_t active) const {
return (this->connection_scan_window_ != 0 && active > 0) ? this->connection_scan_window_ : this->scan_window_;
}
#endif
esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS};
esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS};
@@ -345,20 +330,15 @@ class ESP32BLETracker final : public Component,
/// state_version_ to detect if any state changed since last iteration.
uint8_t last_processed_version_{0};
ScannerState scanner_state_{ScannerState::IDLE};
// Packed 1-bit flags.
bool scan_continuous_ : 1;
bool scan_active_ : 1;
bool scan_continuous_;
bool scan_active_;
#ifdef USE_OTA_STATE_LISTENER
bool scan_continuous_before_ota_ : 1 {false};
#endif
bool ble_was_disabled_ : 1 {true};
bool parse_advertisements_ : 1 {false};
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
/// Suppress the window-change restart's on_scan_end sweeps (stop and start).
bool skip_next_scan_end_ : 1 {false};
bool scan_continuous_before_ota_{false};
#endif
bool ble_was_disabled_{true};
bool parse_advertisements_{false};
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
bool coex_prefer_ble_ : 1 {false};
bool coex_prefer_ble_{false};
#endif
// Scan timeout state machine
enum class ScanTimeoutState : uint8_t {
@@ -366,10 +346,10 @@ class ESP32BLETracker final : public Component,
MONITORING, // Actively monitoring for timeout
EXCEEDED_WAIT, // Timeout exceeded, waiting one loop before reboot
};
ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE};
uint32_t scan_start_time_{0};
/// Precomputed timeout value: scan_duration_ * 2000
uint32_t scan_timeout_ms_{0};
ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE};
};
// NOLINTNEXTLINE
+25 -12
View File
@@ -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",
@@ -135,10 +135,6 @@ void Esp32HostedUpdate::setup() {
// Publish state
this->status_clear_error();
this->publish_state();
// Defer so the automation runs on the main loop after setup, not during App.setup()
if (this->state_ == update::UPDATE_STATE_AVAILABLE && this->update_available_trigger_) {
this->defer([this]() { this->update_available_trigger_->trigger(this->update_info_); });
}
#else
// HTTP mode: check every 10s until network is ready (max 6 attempts)
// Only if update interval is > 1 minute to avoid redundant checks
@@ -189,8 +185,6 @@ void Esp32HostedUpdate::check() {
return;
}
const bool was_available = this->state_ == update::UPDATE_STATE_AVAILABLE;
// Compare versions
if (this->update_info_.latest_version.empty() ||
this->update_info_.latest_version == this->update_info_.current_version) {
@@ -203,9 +197,6 @@ void Esp32HostedUpdate::check() {
this->update_info_.progress = 0.0f;
this->status_clear_error();
this->publish_state();
if (this->state_ == update::UPDATE_STATE_AVAILABLE && !was_available && this->update_available_trigger_) {
this->update_available_trigger_->trigger(this->update_info_);
}
#endif
}
@@ -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_{};
+52 -13
View File
@@ -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:
+3
View File
@@ -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",
+4 -9
View File
@@ -118,6 +118,8 @@ static const LogString *get_exception_cause(uint32_t cause) {
}
static const LogString *get_reset_reason(uint32_t reason) {
if (reason == REASON_WDT_RST)
return LOG_STR("Hardware WDT");
if (reason == REASON_EXCEPTION_RST)
return LOG_STR("Exception");
if (reason == REASON_SOFT_WDT_RST)
@@ -160,20 +162,13 @@ void crash_handler_log() {
if (!is_crash_reason(resetInfo.reason))
return;
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
if (resetInfo.reason == REASON_WDT_RST) {
// A hardware WDT reset happens entirely in hardware: the postmortem hook
// never runs, so rst_info epc1/exccause and the RTC backtrace are
// leftovers from an earlier crash. Don't misattribute them (#18596).
ESP_LOGE(TAG, " Reason: Hardware WDT (no crash state is recorded for hardware WDT resets)");
return;
}
// Read and filter backtrace from RTC into stack-local buffer (no persistent RAM cost).
// Both resetInfo and RTC data survive until the next reset, so this can be
// called multiple times (logger init + API subscribe) with the same result.
uint32_t backtrace[MAX_BACKTRACE];
uint8_t bt_count = read_rtc_backtrace(backtrace, MAX_BACKTRACE);
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
// GCC's ROM divide routine triggers IllegalInstruction (exccause=0) at specific
// ROM addresses instead of IntegerDivideByZero (exccause=6). Patch to match
// the Arduino core's postmortem handler behavior.
@@ -129,17 +129,14 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int
ESPNowComponent::ESPNowComponent() { global_esp_now = this; }
void ESPNowComponent::dump_config() {
ESP_LOGCONFIG(TAG, "espnow:");
// Only report driver details once enabled; with enable_on_boot: false the
// Wi-Fi driver is not initialized yet and esp_now_get_version() would crash,
// and after a failed enable_() the values would be meaningless.
if (this->state_ != ESPNOW_STATE_ENABLED) {
// OFF here means enable_() failed; the core logs the FAILED marker separately
ESP_LOGCONFIG(TAG, " %s", this->is_disabled() ? LOG_STR_LITERAL("Disabled") : LOG_STR_LITERAL("Not enabled"));
return;
}
uint32_t version = 0;
esp_now_get_version(&version);
ESP_LOGCONFIG(TAG, "espnow:");
if (this->is_disabled()) {
ESP_LOGCONFIG(TAG, " Disabled");
return;
}
char own_addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(this->own_address_, own_addr_buf);
ESP_LOGCONFIG(TAG,
+1 -1
View File
@@ -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,
+1 -2
View File
@@ -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(),
}
@@ -55,11 +55,8 @@ int HOT IRAM_ATTR GPIOOneWireBus::reset_int() {
delayMicroseconds(1);
}
// delay J: finish the 480us slot, but never spin if it already elapsed
// (unsigned wrap here would busy-wait for minutes with interrupts off)
uint32_t elapsed = micros() - start;
if (elapsed < 480)
delayMicroseconds(480 - elapsed);
// delay J
delayMicroseconds(start + 480 - micros());
this->pin_.digital_write(true);
this->pin_.pin_mode(gpio::FLAG_OUTPUT);
return r ? 1 : 0;
@@ -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
+1 -34
View File
@@ -16,15 +16,12 @@ from esphome.const import (
CONF_TIMEOUT,
CONF_URL,
CONF_WATCHDOG_TIMEOUT,
PLATFORM_ESP32,
PLATFORM_HOST,
PlatformFramework,
__version__,
)
from esphome.core import CORE, Lambda, TimePeriodMilliseconds
import esphome.final_validate as fv
from esphome.core import CORE, Lambda
from esphome.helpers import IS_MACOS
from esphome.types import ConfigType
DEPENDENCIES = ["network"]
AUTO_LOAD = ["json", "watchdog"]
@@ -94,34 +91,6 @@ def validate_ssl_verification(config):
return config
# esp_http_client_open() runs DNS, TCP connect and the TLS handshake with no
# watchdog feed in between; each can take up to `timeout` on ESP-IDF.
WATCHDOG_TIMEOUT_MULTIPLIER = 3
# Headroom over the exact worst case so a fully stalled open does not land on
# the watchdog deadline.
WATCHDOG_TIMEOUT_MARGIN_MS = 1000
def default_watchdog_timeout(config: ConfigType) -> None:
"""Arm the request watchdog on ESP32 when the user did not set it.
The default never goes below the platform task watchdog, so a user who
widened `esp32.watchdog_timeout` keeps that window during requests.
"""
if not CORE.is_esp32 or CONF_WATCHDOG_TIMEOUT in config:
return
derived_ms = (
config[CONF_TIMEOUT].total_milliseconds * WATCHDOG_TIMEOUT_MULTIPLIER
+ WATCHDOG_TIMEOUT_MARGIN_MS
)
platform_ms = fv.full_config.get()[PLATFORM_ESP32][
CONF_WATCHDOG_TIMEOUT
].total_milliseconds
config[CONF_WATCHDOG_TIMEOUT] = TimePeriodMilliseconds(
milliseconds=max(derived_ms, platform_ms)
)
def _declare_request_class(value):
if CORE.is_host:
return cv.declare_id(HttpRequestHost)(value)
@@ -181,8 +150,6 @@ CONFIG_SCHEMA = cv.All(
validate_ssl_verification,
)
FINAL_VALIDATE_SCHEMA = default_watchdog_timeout
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
@@ -142,13 +142,12 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
const char *buf = body.c_str();
while (write_left > 0) {
int written = esp_http_client_write(client, buf + write_index, write_left);
if (written <= 0) {
if (written < 0) {
err = ESP_FAIL;
break;
}
write_left -= written;
write_index += written;
container->feed_wdt();
}
}
+16 -136
View File
@@ -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
+452 -163
View File
@@ -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(&current_config, &new_config, sizeof(new_config));
}
memcpy(&current_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
+49 -12
View File
@@ -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};
+3
View File
@@ -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 -107
View File
@@ -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,
-41
View File
@@ -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
-3
View File
@@ -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 = {
+5 -5
View File
@@ -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));
@@ -551,21 +551,21 @@ std::string LvSelectable::get_selected_text() {
return this->options_[selected];
}
static std::string join_string(const FixedVector<const char *> &options) {
static std::string join_string(std::vector<std::string> options) {
return std::accumulate(
options.begin(), options.end(), std::string(),
[](const std::string &a, const char *b) -> std::string { return a + (!a.empty() ? "\n" : "") + b; });
[](const std::string &a, const std::string &b) -> std::string { return a + (!a.empty() ? "\n" : "") + b; });
}
void LvSelectable::set_selected_text(const std::string &text, lv_anim_enable_t anim) {
auto *index = std::find(this->options_.begin(), this->options_.end(), text);
auto index = std::find(this->options_.begin(), this->options_.end(), text);
if (index != this->options_.end()) {
this->set_selected_index(index - this->options_.begin(), anim);
lv_obj_send_event(this->obj, lv_update_event, nullptr);
}
}
void LvSelectable::set_options(FixedVector<const char *> options) {
void LvSelectable::set_options(std::vector<std::string> options) {
auto index = this->get_selected_index();
if (index >= options.size())
index = options.size() - 1;
+3 -3
View File
@@ -499,12 +499,12 @@ class LvSelectable : public LvCompound {
virtual void set_selected_index(size_t index, lv_anim_enable_t anim) = 0;
void set_selected_text(const std::string &text, lv_anim_enable_t anim);
std::string get_selected_text();
const FixedVector<const char *> &get_options() { return this->options_; }
void set_options(FixedVector<const char *> options);
const std::vector<std::string> &get_options() { return this->options_; }
void set_options(std::vector<std::string> options);
protected:
virtual void set_option_string(const char *options) = 0;
FixedVector<const char *> options_{};
std::vector<std::string> options_{};
};
#ifdef USE_LVGL_DROPDOWN
+12 -3
View File
@@ -50,10 +50,19 @@ class LVGLSelect final : public select::Select, public Component {
protected:
void control(size_t index) override {
this->widget_->set_selected_index(index, this->anim_);
// The update event fires the widget's on_value/on_update triggers
lv_obj_send_event(this->widget_->obj, lv_update_event, nullptr);
this->publish();
}
void set_options_() {
// Widget uses std::vector<std::string>, SelectTraits uses FixedVector<const char*>
// Convert by extracting c_str() pointers
const auto &opts = this->widget_->get_options();
FixedVector<const char *> opt_ptrs;
opt_ptrs.init(opts.size());
for (const auto &opt : opts) {
opt_ptrs.push_back(opt.c_str());
}
this->traits.set_options(opt_ptrs);
}
void set_options_() { this->traits.set_options(this->widget_->get_options()); }
LvSelectable *widget_;
lv_anim_enable_t anim_;
-3
View File
@@ -3,8 +3,6 @@ from esphome.const import CONF_TEXT, CONF_VALUE
from esphome.cpp_generator import MockObj
from esphome.cpp_types import Component, esphome_ns
from .defines import CONF_SELECTED_INDEX
class LvType(cg.MockObjClass):
def __init__(self, *args, **kwargs):
@@ -114,4 +112,3 @@ class LvSelect(LvType):
parents=parens,
**kwargs,
)
self.value_property = CONF_SELECTED_INDEX
+1 -2
View File
@@ -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):
+2 -2
View File
@@ -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)
+20 -2
View File
@@ -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)
+1 -13
View File
@@ -41,19 +41,7 @@ static void register_esp8266(MDNSComponent *, StaticVector<MDNSService, MDNS_SER
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
void MDNSComponent::start_polling_window_() {
// uint32_t-ID set_interval/set_timeout already does atomic cancel-and-add.
this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() {
#ifdef USE_MDNS_WIFI_LISTENER
// MDNS.update() can suspend the loop in UdpContext::sendTimeout() while a send is
// failing (radio off-channel during a roam scan, or mid reconnect); an incoming
// packet then re-enters LEAmDNS from lwIP and corrupts shared UdpContext state.
// Skip the tick while the radio cannot transmit (#18760), but keep polling while
// the AP is serving clients (AP-only or fallback AP with the STA down).
auto *wifi = wifi::global_wifi_component;
if (wifi->is_roaming() || (!wifi->is_connected() && !wifi->is_ap_active()))
return;
#endif
MDNS.update();
});
this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); });
this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); });
}
#endif
+2
View File
@@ -266,6 +266,8 @@ DriverChip(
"JC3636W518V2",
height=360,
width=360,
offset_height=1,
draw_rounding=1,
cs_pin=10,
reset_pin=47,
invert_colors=True,
+11 -22
View File
@@ -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;
+1 -1
View File
@@ -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
+3
View File
@@ -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"
+8 -18
View File
@@ -62,22 +62,6 @@ def get_sdk_nrf_tools_path() -> Path:
return path.resolve()
def _needs_venv_rebuild(
env_python_path: Path, sentinel: Path, requirements_hash: str
) -> bool:
"""True when a penv must be (re)built.
Rebuild when the interpreter is not a regular file, which covers a
dangling symlink (a cached venv outliving a host interpreter upgrade)
and a corrupt restore, or when the sentinel is missing or stale.
"""
return (
not env_python_path.is_file()
or not sentinel.exists()
or sentinel.read_text(encoding="utf-8") != requirements_hash
)
def _get_python_env_path(version: str) -> Path:
return get_sdk_nrf_tools_path() / "penvs" / version
@@ -214,7 +198,10 @@ def setup_platformio_python_env() -> None:
+ "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode()
+ f"python{sys.version_info.major}.{sys.version_info.minor}".encode()
).hexdigest()
if _needs_venv_rebuild(env_python_path, sentinel, requirements_hash):
if (
not sentinel.exists()
or sentinel.read_text(encoding="utf-8") != requirements_hash
):
rmdir(penv_path, msg="Clean up PlatformIO toolchain Python environment")
create_venv(penv_path, msg="PlatformIO toolchain")
@@ -263,7 +250,10 @@ def check_and_install() -> None:
env_python_path = get_python_env_executable_path(python_env_path, "python")
sentinel = python_env_path / ".ready"
requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest()
install_venv = _needs_venv_rebuild(env_python_path, sentinel, requirements_hash)
install_venv = (
not sentinel.exists()
or sentinel.read_text(encoding="utf-8") != requirements_hash
)
if install_venv:
rmdir(python_env_path, msg=f"Clean up {version} Python environment")
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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)
+3
View File
@@ -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]),
+3 -2
View File
@@ -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"
+10 -30
View File
@@ -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;
}
+3 -2
View File
@@ -363,12 +363,13 @@ def resolve_include(
an explicit non-goal here.
"""
original = include.file
original_str = str(original)
filename = str(
_expand_substitutions(
original, path + ["file"], context_vars, strict_undefined, errors
original_str, path + ["file"], context_vars, strict_undefined, errors
)
)
substituted = filename != original
substituted = filename != original_str
if substituted:
include = include.with_file(filename)
try:
+2 -2
View File
@@ -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)
@@ -35,6 +35,7 @@ class ListEntitiesIterator final : public ComponentIterator {
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
bool completed() { return this->state_ == IteratorState::NONE; }
protected:
const WebServer *web_server_;
+8 -2
View File
@@ -214,8 +214,8 @@ void DeferredUpdateEventSource::process_deferred_queue_() {
void DeferredUpdateEventSource::loop() {
process_deferred_queue_();
// One step per loop; refusals retry next pass
this->entities_iterator_.try_advance(1);
if (!this->entities_iterator_.completed())
this->entities_iterator_.advance();
}
void DeferredUpdateEventSource::deferrable_send_state(void *source, const char *event_type,
@@ -321,6 +321,12 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource
#endif
source->entities_iterator_.begin(ws->include_internal_);
// just dump them all up-front and take advantage of the deferred queue
// on second thought that takes too long, but leaving the commented code here for debug purposes
// while(!source->entities_iterator_.completed()) {
// source->entities_iterator_.advance();
//}
});
}
@@ -935,8 +935,8 @@ void AsyncEventSourceResponse::process_buffer_() {
void AsyncEventSourceResponse::loop() {
process_buffer_();
process_deferred_queue_();
// One step per loop; refusals retry next pass
this->entities_iterator_.try_advance(1);
if (!this->entities_iterator_.completed())
this->entities_iterator_.advance();
}
bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id,
+3 -3
View File
@@ -530,7 +530,7 @@ void WiFiComponent::log_discarded_scan_result_(const char *ssid, const uint8_t *
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
// Skip logging during roaming scans to avoid log buffer overflow
// (roaming scans typically find many networks but only care about same-SSID APs)
if (this->is_roaming_scan_active()) {
if (this->roaming_state_ == RoamingState::SCANNING) {
return;
}
char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
@@ -835,7 +835,7 @@ void WiFiComponent::loop() {
// Post-connect roaming: check for better AP
if (this->post_connect_roaming_) {
if (this->is_roaming_scan_active()) {
if (this->roaming_state_ == RoamingState::SCANNING) {
if (this->scan_done_) {
this->process_roaming_scan_();
}
@@ -2152,7 +2152,7 @@ void WiFiComponent::retry_connect() {
// Roam connection failed - transition to reconnecting
ESP_LOGD(TAG, "Roam failed, reconnecting (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
this->roaming_state_ = RoamingState::RECONNECTING;
} else if (this->is_roaming_scan_active()) {
} else if (this->roaming_state_ == RoamingState::SCANNING) {
// Disconnected during roam scan - transition to RECONNECTING so the attempts
// counter is preserved when reconnection succeeds (IDLE would reset it)
ESP_LOGD(TAG, "Disconnected during roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
-7
View File
@@ -475,13 +475,6 @@ class WiFiComponent final : public Component {
bool is_connected() const { return this->connected_; }
/// True while a post-connect roaming scan holds the radio off-channel.
bool is_roaming_scan_active() const { return this->roaming_state_ == RoamingState::SCANNING; }
/// True while a post-connect roam is in progress (scanning off-channel, reassociating,
/// or recovering from a failed roam).
bool is_roaming() const { return this->roaming_state_ != RoamingState::IDLE; }
#ifdef USE_ESP32
/// esp_netif handle of the station interface, used by network for default-route
/// arbitration. nullptr until wifi_lazy_init_() has run.
@@ -717,7 +717,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
static constexpr uint32_t SCAN_ACTIVE_MAX_DEFAULT_MS = 500;
static constexpr uint32_t SCAN_ACTIVE_MIN_ROAMING_MS = 100;
static constexpr uint32_t SCAN_ACTIVE_MAX_ROAMING_MS = 300;
bool roaming = this->is_roaming_scan_active();
bool roaming = this->roaming_state_ == RoamingState::SCANNING;
if (passive) {
config.scan_time.passive = roaming ? SCAN_PASSIVE_ROAMING_MS : SCAN_PASSIVE_DEFAULT_MS;
} else {
@@ -580,14 +580,7 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional<ManualIP> &manual_ip) {
// lwIP starts the SNTP client if it gets an SNTP server from DHCP. We don't need the time, and more importantly,
// the built-in SNTP client has a memory leak in certain situations. Disable this feature.
// https://github.com/esphome/issues/issues/2299
{
#if SNTP_GET_SERVERS_FROM_DHCP || SNTP_GET_SERVERS_FROM_DHCPV6
// sntp_servermode_dhcp() is an empty macro unless lwIP is built with
// DHCP-supplied NTP servers, so only that build needs the core lock.
LwIPLock lock;
#endif
sntp_servermode_dhcp(false);
}
sntp_servermode_dhcp(false);
// No manual IP is set; use DHCP client
if (dhcp_status != ESP_NETIF_DHCP_STARTED) {
@@ -1064,7 +1057,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
// When scanning while connected (roaming), return to home channel between
// each scanned channel to maintain the connection (helps with BLE/WiFi coexistence)
#ifdef CONFIG_SOC_WIFI_SUPPORTED
if (this->is_roaming_scan_active()) {
if (this->roaming_state_ == RoamingState::SCANNING) {
config.coex_background_scan = true;
}
#endif
-17
View File
@@ -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
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.8.2"
__version__ = "2026.9.0-dev"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+11 -14
View File
@@ -22,23 +22,23 @@ void ComponentIterator::advance_platform_() {
this->at_ = 0;
}
bool ComponentIterator::advance_step_() {
void ComponentIterator::advance() {
switch (this->state_) {
case IteratorState::NONE:
// not started
return false;
return;
case IteratorState::BEGIN:
if (this->on_begin()) {
advance_platform_();
return true;
}
return false;
break;
// Entity iterator cases (generated from entity_types.h)
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
case IteratorState::upper: \
return this->process_platform_item_(App.get_##plural(), &ComponentIterator::on_##singular);
this->process_platform_item_(App.get_##plural(), &ComponentIterator::on_##singular); \
break;
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
ENTITY_TYPE_(type, singular, plural, count, upper)
#include "esphome/core/entity_types.h"
@@ -48,29 +48,26 @@ bool ComponentIterator::advance_step_() {
#ifdef USE_API_USER_DEFINED_ACTIONS
case IteratorState::SERVICE:
return this->process_platform_item_(api::global_api_server->get_user_services(), &ComponentIterator::on_service);
this->process_platform_item_(api::global_api_server->get_user_services(), &ComponentIterator::on_service);
break;
#endif
#ifdef USE_CAMERA
case IteratorState::CAMERA: {
camera::Camera *camera_instance = camera::Camera::instance();
if (camera_instance != nullptr && (!camera_instance->is_internal() || this->include_internal_) &&
!this->on_camera(camera_instance)) {
return false;
if (camera_instance != nullptr && (!camera_instance->is_internal() || this->include_internal_)) {
this->on_camera(camera_instance);
}
advance_platform_();
return true;
}
} break;
#endif
case IteratorState::MAX:
if (this->on_end()) {
this->state_ = IteratorState::NONE;
return true;
}
return false;
return;
}
return false;
}
bool ComponentIterator::on_end() { return true; }
+8 -35
View File
@@ -30,23 +30,7 @@ class RadioFrequency;
class ComponentIterator {
public:
void begin(bool include_internal = false);
/// Run up to max_steps iteration steps; stops early when iteration
/// completes or a callback refuses (that step is retried on the next
/// call). Inline so an idle (completed) iterator costs one compare, no call.
ESPHOME_ALWAYS_INLINE void try_advance(size_t max_steps) {
size_t steps = 0;
while (steps < max_steps && !this->completed()) {
this->yield_requested_ = false;
if (!this->advance_step_())
break;
steps++;
if (this->yield_requested_)
break;
}
}
// Remove before 2027.3.0
ESPDEPRECATED("Use try_advance() instead. Removed in 2027.3.0", "2026.8.1")
void advance() { this->try_advance(1); }
void advance();
bool completed() const { return this->state_ == IteratorState::NONE; }
virtual bool on_begin();
// Pure virtual entity callbacks (generated from entity_types.h)
@@ -89,34 +73,23 @@ class ComponentIterator {
#endif
MAX,
};
/// End the current try_advance() pass after this step; lets callbacks
/// that write directly to the socket cap direct writes per pass.
void yield_after_step_() { this->yield_requested_ = true; }
uint16_t at_{0}; // Supports up to 65,535 entities per type
IteratorState state_{IteratorState::NONE};
bool yield_requested_ : 1 {false};
bool include_internal_ : 1 {false};
bool include_internal_{false};
template<typename Container>
bool process_platform_item_(const Container &items,
void process_platform_item_(const Container &items,
bool (ComponentIterator::*on_item)(typename Container::value_type)) {
if (this->at_ >= items.size()) {
this->advance_platform_();
return true;
} else {
typename Container::value_type item = items[this->at_];
if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) {
this->at_++;
}
}
typename Container::value_type item = items[this->at_];
if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) {
this->at_++;
return true;
}
return false;
}
/// One iteration step; false if no progress was made (callback refused
/// or iterator not running).
bool advance_step_();
void advance_platform_();
};
+11 -17
View File
@@ -386,23 +386,17 @@ def run_compile(config, verbose: bool) -> int:
return rc
_LOGGER.info("Regenerating CMakeLists.txt with discovered components...")
write_project(minimal=False)
# Explicit reconfigure: ninja only re-runs cmake when CMakeLists.txt
# is strictly newer than build.ninja, which fails on coarse-mtime
# filesystems (#18682). Also keeps idf.py from regenerating memory.ld
# in testing mode.
rc = run_reconfigure()
if rc != 0:
_LOGGER.error("Reconfigure with discovered components failed")
return rc
# cmake does not rewrite CMakeCache.txt when only properties change,
# so restamp it or every build repeats discovery. Only after success,
# or a failed reconfigure would be marked fresh. build.ninja is
# restamped too so the cache is not newer and ninja does not
# re-run cmake.
for name in ("build/CMakeCache.txt", "build/build.ninja"):
path = CORE.relative_build_path(name)
if path.is_file():
os.utime(path)
if CORE.testing_mode:
# Reconfigure again so cmake is up to date with the full
# component list before the build's idf.py invocation runs --
# idf.py build would otherwise re-run cmake and regenerate
# memory.ld, wiping the DRAM/IRAM patches applied below.
# Outside testing mode ninja's own configure-time dep on
# CMakeLists.txt handles the re-run as part of the build step.
rc = run_reconfigure()
if rc != 0:
_LOGGER.error("Reconfigure with discovered components failed")
return rc
# In testing mode, generate the linker script first, patch DRAM/IRAM sizes,
# then build. memory.ld is regenerated by ninja during the build phase,
+4 -17
View File
@@ -16,7 +16,6 @@ from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__
from esphome.core import CORE, EsphomeError, TimePeriodSeconds
from esphome.happy_eyeballs import ensure_happy_eyeballs
from esphome.helpers import write_file
from esphome.net_retry import fetch_with_retry
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -158,17 +157,8 @@ def has_remote_file_changed(
}
if etag := _read_etag(local_file_path):
headers[IF_NONE_MATCH] = etag
# Retried so allow_stale=False consumers don't hard-fail on a
# healed flake. Only connection-level failures retry: HEAD
# never raises on HTTP status (servers rejecting HEAD with
# 405/501 must fall through to the GET), so 5xx is handled by
# the GET's own retry.
response = fetch_with_retry(
url,
lambda: requests.head(
url, headers=headers, timeout=timeout, allow_redirects=True
),
what="Revalidation",
response = requests.head(
url, headers=headers, timeout=timeout, allow_redirects=True
)
_LOGGER.debug(
@@ -303,7 +293,7 @@ def download_content(
_LOGGER.info("Downloading %s", url)
_LOGGER.debug("Saving to %s", path)
def _fetch() -> tuple[requests.Response, bytes]:
try:
req = requests.get(
url,
timeout=timeout,
@@ -314,10 +304,7 @@ def download_content(
# and mid-stream connection errors all surface here as
# RequestException subclasses, so this needs the same fall-back
# treatment as the request itself.
return req, req.content
try:
req, data = fetch_with_retry(url, _fetch)
data = req.content
except requests.exceptions.RequestException as e:
if path.exists():
# Memoized so a flaky host warns once per run, not per consumer.
+27 -5
View File
@@ -15,7 +15,6 @@ from typing import IO, TYPE_CHECKING
from esphome.happy_eyeballs import ensure_happy_eyeballs
from esphome.helpers import ProgressBar, rmtree
from esphome.net_retry import NETWORK_MAX_ATTEMPTS, is_transient_download_error
if TYPE_CHECKING:
import requests
@@ -30,9 +29,8 @@ _LOGGER = logging.getLogger(__name__)
_MIRROR_ATTEMPTS = 3
# Passes over the whole mirror list when a transient network error is in
# the mix; shares net_retry's policy (3 tries, 2s/4s backoff), which in
# turn matches git.py's _NETWORK_MAX_ATTEMPTS.
_MIRROR_SWEEP_ATTEMPTS = NETWORK_MAX_ATTEMPTS
# the mix; matches git.py's _NETWORK_MAX_ATTEMPTS (3 tries, 2s/4s backoff).
_MIRROR_SWEEP_ATTEMPTS = 3
def get_project_link_flags() -> list[str]:
@@ -905,6 +903,30 @@ def _spent_attempts_error(e: Exception, attempts: int) -> Exception:
return err
def _is_transient_download_error(e: Exception) -> bool:
"""Return True when a download failure is worth retrying.
Connection-level failures and HTTP 429/5xx are transient. Other HTTP
errors, local errors, and exhausted-attempts EsphomeError wrappers
(their per-mirror retries are already spent) are permanent.
"""
# Imported lazily: requests is a heavy import (~85ms) and is only
# needed when actually downloading, never during config validation.
import requests
if isinstance(e, requests.exceptions.HTTPError):
resp = e.response
return resp is not None and (resp.status_code == 429 or resp.status_code >= 500)
return isinstance(
e,
(
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.ChunkedEncodingError,
),
)
def _try_mirrors_once(
urls: list[str],
path_target: Path | None,
@@ -1109,7 +1131,7 @@ def download_from_mirrors(
# Permanent failures (404, verification mismatch) won't heal;
# only retry when a transient error is in the mix (as git.py does).
transient = next(
((u, e) for u, e in sweep_failures if is_transient_download_error(e)),
((u, e) for u, e in sweep_failures if _is_transient_download_error(e)),
None,
)
if transient is None:
-8
View File
@@ -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.
-114
View File
@@ -1,114 +0,0 @@
"""Retry policy for HTTP downloads.
Kept import-light on purpose: this module is imported at config time, so it
must not pull in requests (a heavy import, ~85ms) at module scope.
"""
from __future__ import annotations
from collections.abc import Callable
import logging
import time
_LOGGER = logging.getLogger(__name__)
# 3 tries with 2s/4s backoff, matching git.py's _NETWORK_MAX_ATTEMPTS.
# Callers memoize failures so a flaky host pays this once per file per run.
NETWORK_MAX_ATTEMPTS = 3
def _is_permanent_dns_failure(e: BaseException) -> bool:
"""Whether a hard socket.gaierror hides in ``e``'s exception chain.
EAI_AGAIN (flaky resolver) stays retryable; anything else is permanent
so offline builds fall back to their cache without sleeping first.
Narrower than git.py, which retries NXDOMAIN too.
Walks ``__cause__``, ``args`` (requests wraps MaxRetryError without
``from``) and MaxRetryError's ``reason``, but not implicit
``__context__``: an unrelated earlier attempt's resolution failure
must not reclassify an error it did not cause.
"""
import socket
seen: set[int] = set()
stack: list[BaseException] = [e]
while stack:
exc = stack.pop()
if id(exc) in seen:
continue
if (
isinstance(exc, socket.gaierror)
and exc.errno is not None
and exc.errno != socket.EAI_AGAIN
):
return True
seen.add(id(exc))
stack.extend(
nxt
for nxt in (
exc.__cause__,
getattr(exc, "reason", None), # urllib3 MaxRetryError
*exc.args,
)
if isinstance(nxt, BaseException)
)
return False
def is_transient_download_error(e: Exception) -> bool:
"""Return True when a download failure is worth retrying.
Connection-level failures and HTTP 429/5xx are transient; hard DNS
failures, other HTTP errors, and local errors are permanent.
"""
# Imported lazily: requests is a heavy import (~85ms) and is only
# needed when actually downloading, never during config validation.
import requests
if isinstance(e, requests.exceptions.HTTPError):
resp = e.response
return resp is not None and (resp.status_code == 429 or resp.status_code >= 500)
if isinstance(e, requests.exceptions.ConnectionError) and _is_permanent_dns_failure(
e
):
return False
# SSLError (a ConnectionError subclass) stays transient on purpose: it
# also covers mid-handshake connection drops, not just bad certificates.
return isinstance(
e,
(
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.ChunkedEncodingError,
requests.exceptions.ContentDecodingError,
),
)
def fetch_with_retry[T](url: str, fetch: Callable[[], T], what: str = "Download") -> T:
"""Run ``fetch``, retrying transient failures with 2s/4s backoff.
Permanent failures and the final attempt propagate to the caller;
``what`` names the operation in the retry warning.
"""
import requests
for attempt in range(1, NETWORK_MAX_ATTEMPTS):
try:
return fetch()
except requests.exceptions.RequestException as e:
if not is_transient_download_error(e):
raise
delay = 2**attempt
_LOGGER.warning(
"%s of %s failed: %s. Retrying in %d seconds... (attempt %d/%d)",
what,
url,
e,
delay,
attempt + 1,
NETWORK_MAX_ATTEMPTS,
)
time.sleep(delay)
return fetch()
+4 -5
View File
@@ -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"]
+17 -46
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+10 -18
View File
@@ -231,22 +231,18 @@ class IncludeFile:
def __init__(
self,
parent_file: Path,
file: str,
file: Path | str,
vars: dict[str, Any] | None,
yaml_loader: Callable[[Path], Any],
) -> None:
self.parent_file = parent_file
# The raw include text may be a substitution/Jinja expression, so it
# must never round-trip through Path(): on Windows, WindowsPath str()
# rewrites "/" to "\", which Jinja then decodes as escapes like
# "\b" -> backspace (issue #18545).
self.file = file
self.file = Path(file)
self.vars = vars
self.yaml_loader = yaml_loader
self._content: Any = _UNSET
def __repr__(self) -> str:
return f"IncludeFile({self.file})"
return f"IncludeFile({self.file.as_posix()})"
def load(self) -> Any:
"""Load and cache the included file content.
@@ -262,15 +258,15 @@ class IncludeFile:
raise Invalid(
f"Cannot load include with unresolved substitutions: {self.file}"
)
self._content = self.yaml_loader(self.parent_file.parent / self.file)
self._content = self.yaml_loader(Path(self.parent_file.parent / self.file))
self._content = add_context(self._content, self.vars)
return self._content
def has_unresolved_expressions(self) -> bool:
"""Check if the filename contains substitution variables or Jinja expressions."""
return has_substitution_or_expression(self.file)
return has_substitution_or_expression(str(self.file))
def with_file(self, file: str) -> IncludeFile:
def with_file(self, file: Path | str) -> IncludeFile:
"""Clone this include with *file* as the filename."""
return IncludeFile(self.parent_file, file, self.vars, self.yaml_loader)
@@ -317,7 +313,7 @@ def _candidate_include_paths(include: IncludeFile) -> list[Path]:
parent_dir = include.parent_file.parent
parent_resolved = include.parent_file.resolve()
candidates: list[Path] = []
for pattern in include_candidate_patterns(include.file):
for pattern in include_candidate_patterns(str(include.file)):
if "*" in pattern:
matches = sorted(_glob_include_candidates(parent_dir, pattern))
else:
@@ -366,7 +362,7 @@ def _load_include_candidates(
continue
expanded_paths.add(candidate)
try:
loaded = include.with_file(candidate.as_posix()).load()
loaded = include.with_file(candidate).load()
except (EsphomeError, Invalid) as err:
# Unlike an unresolved pattern (expected during the discovery
# re-parse), a matched on-disk candidate that fails to load is a
@@ -798,10 +794,6 @@ class ESPHomeLoaderMixin:
file = fields.get("file")
if file is None:
raise yaml.MarkedYAMLError("Must include 'file'", node.start_mark)
if not isinstance(file, str):
raise yaml.MarkedYAMLError(
"Include 'file' must be a string", node.start_mark
)
vars = fields.get(CONF_VARS)
return file, vars
@@ -1341,11 +1333,11 @@ class ESPHomeDumper(yaml.SafeDumper):
def represent_include_file(self, value):
if value.vars:
mapping = {"file": value.file, "vars": value.vars}
mapping = {"file": value.file.as_posix(), "vars": value.vars}
return self.represent_mapping(
tag="!include", mapping=mapping, flow_style=False
)
return self.represent_scalar(tag="!include", value=value.file)
return self.represent_scalar(tag="!include", value=value.file.as_posix())
def represent_id(self, value):
if is_secret(value.id):
+3 -3
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More