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 KostonandGitHub 3f01f9895f [esp32_hosted] Require ESP-IDF 5.3 or newer (#18417) 2026-08-16 12:44:49 -07:00
J. Nick KostonandGitHub 801a1817b5 [esp32] Split crash handler addr2line hint per core (#18418) 2026-08-16 12:44:00 -07:00
J. Nick KostonandGitHub 32c76ae828 [core] Skip redundant ESP8266 main loop wake posts from ISR context (#18416) 2026-08-16 12:43:27 -07:00
J. Nick KostonandGitHub c664f5fc95 [bk72xx_ble] Fail early with a clear error on non BLE 5.x SoCs (#18406) 2026-08-16 11:06:22 -07:00
luar123andGitHub 2bc4681fd6 [zigbee] bump esp-zigbee-sdk to 2.0.4 (#18415) 2026-08-16 13:33:25 -04:00
Jonathan SwobodaandGitHub 646501b0ef [sensor] Pass NaN through the delta filter again (#18400) 2026-08-16 12:36:51 -04:00
J. Nick KostonandGitHub de3e657d8b [platformio] Skip ccache when the binary on PATH fails to run (#18407) 2026-08-16 08:42:47 -07:00
esphome[bot]andGitHub 1add726892 Bump bundled esphome-device-builder to 1.11.0 (#18403) 2026-08-15 13:36:52 -07:00
Samuel SiebandGitHub 5a000cf5e4 [rotary_encoder] account for min and max value when resetting (#18197) 2026-08-15 11:16:04 -07:00
Joppy FurrandGitHub 6ed676fe32 [lvgl] Restore long_press_repeat_time functionality (#18393) 2026-08-14 23:14:53 -07:00
esphome[bot]andGitHub 7cceddb8a3 Bump bundled esphome-device-builder to 1.10.0 (#18389) 2026-08-15 01:48:27 +00:00
J. Nick KostonandGitHub 039b897e7b [ethernet] Defer clk_mode removal to 2026.11.0 (#18380) 2026-08-14 18:45:36 -07:00
J. Nick KostonandGitHub b178f74e5d [core] Save the validated config cache on the first upload or logs run (#18367) 2026-08-14 18:45:23 -07:00
Clyde StubbsandGitHub e5224e22ae [ci] Compare merge-branch base ref against the default branch (#18385) 2026-08-15 06:18:22 +10:00
Clyde StubbsandGitHub 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 BeeckenandGitHub 617e2ec1e0 [core] fix PYTHONPATH leak (#18360) 2026-08-14 17:23:34 +12:00
J. Nick KostonandGitHub 1afac0312d [core] Restore cv.parse_esphome_version as a deprecated helper (#18366) 2026-08-14 17:22:22 +12:00
J. Nick KostonandGitHub b05465145f [core] Add preference key stability integration test (#18364) 2026-08-13 21:14:30 -05:00
dependabot[bot]andGitHub 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 KostonandGitHub 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]andGitHub 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]andGitHub 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 KostonandGitHub 945c2458b3 [core] Load component aliases from a generated registry (#18335) 2026-08-13 13:31:19 -05:00
J. Nick KostonandGitHub 7420d23867 [ota] Retry uploads that fail from network errors (#18332) 2026-08-13 13:31:06 -05:00
J. Nick KostonandGitHub 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 KostonandGitHub 137351fa8d [esp32_ble] Silence spurious warnings for local key GAP events (#18359) 2026-08-13 13:27:01 -05:00
J. Nick KostonandGitHub 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 KleineckeGitHubOliver 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]andGitHub 87045ab9c0 Bump aioesphomeapi from 45.10.1 to 45.10.2 (#18357) 2026-08-13 11:14:14 -05:00
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]GitHubdependabot[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 KostonandGitHub 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 KostonandGitHub 8e624b4117 [core] Retry framework downloads on transient network errors (#18330) 2026-08-13 12:14:50 +12:00
J. Nick KostonandGitHub 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 KostonandGitHub 905485b673 [ld2420] Fix out-of-bounds read when device reports unknown command error (#18322) 2026-08-13 11:49:34 +12:00
esphome[bot]andGitHub 99677390e0 Bump bundled esphome-device-builder to 1.9.6 (#18328) 2026-08-12 16:27:14 -05:00
Kevin AhrendtandGitHub f2121130f9 [sendspin] Bump sendspin-cpp to v0.7.2 (#18316) 2026-08-12 12:49:54 -04:00
esphome[bot]andGitHub 1a01c34ec4 Bump aioesphomeapi from 45.10.0 to 45.10.1 (#18318) 2026-08-12 16:33:45 +00:00
J. Nick KostonandGitHub 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
64 changed files with 3024 additions and 363 deletions
+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']);
});
});
// ---------------------------------------------------------------------------
+6 -2
View File
@@ -445,8 +445,12 @@ jobs:
- 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
+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.0b3
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.9.6
RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0
RUN \
platformio settings set enable_telemetry No \
+14 -14
View File
@@ -2732,7 +2732,8 @@ def run_esphome(argv):
conf_path.name,
)
if config is None:
cache_missed = config is None
if cache_missed:
from esphome.config import read_config
config = read_config(
@@ -2741,26 +2742,25 @@ def run_esphome(argv):
# Snapshot only needed by `esphome config --no-defaults`.
snapshot_user_config=getattr(args, "no_defaults", False),
)
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config. Skip when the storage
# sidecar is absent (no compile has run): the cache would
# never be loaded back, so writing secrets to disk is wasted.
if cache_eligible and config is not None:
from esphome.compiled_config import save_compiled_config
from esphome.storage_json import ext_storage_path
if ext_storage_path(conf_path.name).exists():
save_compiled_config(config)
if config is None:
return 2
if config is None:
return 2
CORE.config = config
# Fallback for platforms whose validators didn't set the toolchain
# (only the esp32 component reads esp32.framework.toolchain). All
# other platforms only support PlatformIO today.
# other platforms only support PlatformIO today. Must run before the
# cache refresh below so its sidecar records the same toolchain a
# compile would.
if CORE.toolchain is None:
CORE.toolchain = Toolchain.PLATFORMIO
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config.
if cache_eligible and cache_missed:
from esphome.compiled_config import save_compiled_config_and_sidecar
save_compiled_config_and_sidecar(config)
if args.command not in POST_CONFIG_ACTIONS:
safe_print(f"Unknown command {args.command}")
return 1
+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
+38 -6
View File
@@ -5,11 +5,11 @@ 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), BK7238/BK7252N/BK7253
(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time,
not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken
BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only
for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail
with a clear #error.
(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.
No framework patch is needed: the LibreTiny beken-72xx builder already compiles
and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x;
@@ -21,9 +21,16 @@ import logging
import esphome.codegen as cg
from esphome.components import libretiny
from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238
from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231Q,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_BK7251,
)
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
from esphome.core import EsphomeError
from esphome.types import ConfigType
DEPENDENCIES = ["bk72xx"]
@@ -50,7 +57,32 @@ CONFIG_SCHEMA = cv.Schema(
request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT")
def _unsupported_family_message(family: str) -> str | None:
if family in (FAMILY_BK7231T, FAMILY_BK7251):
return (
f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 "
"stack; a BLE 5.x SoC such as BK7231N or BK7238 is required"
)
if family == FAMILY_BK7231Q:
return "bk72xx_ble does not support BK7231Q: this SoC has no BLE"
return None
def _final_validate(config: ConfigType) -> ConfigType:
# Warn only: a hard error here would break the validate-only CI fixtures,
# which run on a BLE 4.2 board. The hard error is raised at codegen.
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
_LOGGER.warning("%s (this configuration cannot compile)", msg)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
raise EsphomeError(msg)
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
+2 -2
View File
@@ -10,7 +10,7 @@
#ifdef USE_BK72XX_BLE
// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error).
#if !defined(CLANG_TIDY) && __has_include("ble_api.h")
#if !defined(CLANG_TIDY) && __has_include("ble_api.h") && __has_include("app_ble.h")
extern "C" {
#include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t,
@@ -115,5 +115,5 @@ BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) {
} // namespace esphome::bk72xx_ble
#endif // !CLANG_TIDY && ble_api.h
#endif // !CLANG_TIDY && ble_api.h && app_ble.h
#endif // USE_BK72XX_BLE
+13 -9
View File
@@ -34,22 +34,26 @@
// ---------------------------------------------------------------------------
// SDK-capability gate (not a chip allowlist).
// This component drives the Beken BLE *5.x* controller via its public API,
// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the
// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the
// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header
// itself so any BLE-5.x Beken chip — present or future — is supported without a
// hard-coded list, and a non-5.x build fails here with a clear message instead
// of a cryptic "ble_api.h: No such file or directory".
// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be
// the probe: it ships for every SoC (driver/include) and merely switches on
// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the
// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports
// any BLE-5.x chip — present or future — without a hard-coded list, and a
// non-5.x build fails here with a clear message instead of a cryptic
// "app_ble.h: No such file or directory".
// ---------------------------------------------------------------------------
#if defined(CLANG_TIDY)
// The clang-tidy environment does not carry the full Beken BDK BLE 5.x API
// (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing
// accurate to analyze the SDK calls against — skip the file under analysis.
#define BK72XX_BLE_NO_SDK
#elif !__has_include("ble_api.h")
#elif !__has_include("ble_api.h") || !__has_include("app_ble.h")
// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2
// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by
// one and bury this message.
#define BK72XX_BLE_NO_SDK
#error \
"bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
"bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
#endif
#ifndef BK72XX_BLE_NO_SDK
+2
View File
@@ -22,6 +22,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr"
CONF_GYROSCOPE_RANGE = "gyroscope_range"
CONF_IAQ = "iaq"
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
CONF_LABEL = "label"
CONF_LIBRETINY = "libretiny"
CONF_LOOP = "loop"
CONF_NOX_INDEX = "nox_index"
@@ -35,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"
+3
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)",
+15 -14
View File
@@ -360,17 +360,6 @@ static bool has_fault_addr() {
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause;
}
// Append both cores' backtrace addresses to buf; returns the new position.
static int append_all_backtraces(char *buf, int size, int pos) {
pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count,
s_raw_crash_data.reg_frame_count);
#if SOC_CPU_CORES_NUM > 1
pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count,
s_raw_crash_data.other_reg_frame_count);
#endif
return pos;
}
// The record was captured by a different firmware build (it survives soft
// resets, including the OTA reboot), so symbolizing its addresses against the
// current ELF would produce misleading symbols. Print them with lowercase
@@ -443,11 +432,23 @@ void crash_handler_log() {
}
#endif
// Build addr2line hint with all captured addresses for easy copy-paste
// Build addr2line hints for easy copy-paste. One line per core: the two
// backtraces are separate stacks, and a combined list decodes as one
// impossible call chain (and can overflow the buffer, dropping addresses).
static const char *const ADDR2LINE_CMD = "addr2line -pfiaC -e firmware.elf";
char hint[256];
int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc);
append_all_backtraces(hint, sizeof(hint), pos);
int pos = snprintf(hint, sizeof(hint), "Use: %s 0x%08" PRIX32, ADDR2LINE_CMD, s_raw_crash_data.pc);
append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count,
s_raw_crash_data.reg_frame_count);
ESP_LOGE(TAG, "%s", hint);
#if SOC_CPU_CORES_NUM > 1
if (s_raw_crash_data.other_backtrace_count > 0) {
pos = snprintf(hint, sizeof(hint), "Other core: %s", ADDR2LINE_CMD);
append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace,
s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count);
ESP_LOGE(TAG, "%s", hint);
}
#endif
}
} // namespace esphome::esp32
+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",
+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",
+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,
+452 -165
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;
@@ -184,13 +200,14 @@ static int32_t get_firmware_int(const char *version_string) {
return result;
}
float LD2420Component::get_setup_priority() const { return setup_priority::BUS; }
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_);
@@ -212,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");
@@ -274,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;
@@ -308,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();
@@ -320,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();
@@ -342,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) {
@@ -549,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));
@@ -564,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) {
@@ -633,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) {
@@ -676,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--;
@@ -690,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);
}
@@ -720,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;
@@ -755,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
@@ -861,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
@@ -887,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 -13
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};
@@ -105,7 +104,6 @@ class LD2420Component final : public Component, public uart::UARTDevice {
void apply_config_action();
void factory_reset_action();
void revert_config_action();
float get_setup_priority() const override;
int send_cmd_from_array(CmdFrameT cmd_frame);
void report_gate_data();
void handle_cmd_error(uint16_t error);
@@ -113,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();
@@ -154,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_; };
@@ -168,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_; };
@@ -184,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 -1
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));
+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):
+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"
@@ -220,7 +220,7 @@ void RotaryEncoderSensor::loop() {
}
if (this->pin_i_ != nullptr && this->pin_i_->digital_read()) {
this->store_.counter = 0;
this->store_.counter = std::clamp<int32_t>(0, this->store_.min_value, this->store_.max_value);
}
int counter = this->store_.counter;
if (this->store_.last_read != counter || this->publish_initial_value_) {
+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",
@@ -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"
+6 -4
View File
@@ -283,8 +283,11 @@ DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1)
void DeltaFilter::set_baseline(float (*fn)(float)) { this->baseline_ = fn; }
optional<float> DeltaFilter::new_value(float value) {
// Always yield the first value.
if (std::isnan(this->last_value_)) {
const bool no_value = std::isnan(value);
const bool no_reference = std::isnan(this->last_value_);
if (no_value && no_reference)
return {};
if (no_value || no_reference) {
this->last_value_ = value;
return value;
}
@@ -293,8 +296,7 @@ optional<float> DeltaFilter::new_value(float value) {
float min = fabsf(this->min_a0_ + ref * this->min_a1_);
float max = fabsf(this->max_a0_ + ref * this->max_a1_);
float delta = fabsf(value - ref);
// if there is no reference, e.g. for the first value, just accept this one,
// otherwise accept only if within range.
// accept only if within range
if (delta > min && delta <= max) {
this->last_value_ = value;
return value;
@@ -307,6 +307,11 @@ void ZigbeeComponent::setup() {
return;
}
#endif
#ifdef CONFIG_ZB_ZCZR
ezb_bdb_set_router_rejoin_required(true);
#endif
ezb_aps_secur_enable_distributed_security(false);
ezb_nwk_set_min_join_lqi(32);
if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) {
+1 -1
View File
@@ -285,7 +285,7 @@ async def attributes_to_code(
async def esp32_to_code(config: ConfigType) -> "MockObj":
add_idf_component(
name="espressif/esp-zigbee-lib",
ref="2.0.3",
ref="2.0.4",
)
# add sdkconfigs later so they can overwrite esp32 defaults
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.8.0b3"
__version__ = "2026.9.0-dev"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+7
View File
@@ -15,6 +15,13 @@ inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() {
// Set the wake-requested flag BEFORE esp_schedule so the consumer is
// guaranteed to see it on its next gate check.
wake_request_set();
// Skip the post when a wake was already signalled and not yet consumed by
// wakeable_delay(): esp_schedule() -> ets_post() can enter SDK WiFi pm code,
// which must not be poked per-byte from the software serial RX ISR (see
// esphome#18409). The flag can stay latched while the loop is awake, which
// is intentional; posts are only needed to cut a suspend short.
if (g_main_loop_woke)
return;
g_main_loop_woke = true;
esp_schedule();
}
+1 -1
View File
@@ -48,7 +48,7 @@ dependencies:
rules:
- if: "target in [esp32, esp32p4]"
espressif/esp-zigbee-lib:
version: 2.0.3
version: 2.0.4
rules:
- if: "target in [esp32h2, esp32c5, esp32c6]"
espressif/lan87xx:
+31 -1
View File
@@ -5,6 +5,7 @@ import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
from typing import TYPE_CHECKING, Any
@@ -234,6 +235,35 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None:
_write_pio_stamp_python(stamp_file, current)
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
target is gone. Wrapping compiles around such a find fails every compile
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"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=15,
)
except (OSError, subprocess.SubprocessError):
_LOGGER.warning(
"Ignoring ccache at %s because it failed to run; compiling without ccache",
ccache,
)
return False
return True
def _ccache_env() -> dict[str, str]:
"""Return ccache settings for PlatformIO builds.
@@ -266,7 +296,7 @@ def _ccache_env() -> dict[str, str]:
if "ESPHOME_CCACHE_ENABLE" in os.environ:
enabled = get_bool_env("ESPHOME_CCACHE_ENABLE")
else:
enabled = shutil.which("ccache") is not None
enabled = _ccache_usable()
env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"}
if not enabled:
return env
+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.
+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.1
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
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-n
bk72xx:
board: cb2s
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-q
bk72xx:
board: wa2
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-t
bk72xx:
board: generic-bk7231t-qfn32-tuya
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-7252
bk72xx:
board: generic-bk7252
bk72xx_ble:
@@ -0,0 +1,40 @@
"""The non-5.x family rejection lives in to_code (config validation must stay
family-agnostic for the validate-only CI fixtures), so codegen is the only
place it can be pinned."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.core import EsphomeError
@pytest.mark.parametrize(
("config_file", "match"),
[
("test_bk7231t.yaml", "BK7231T.*BLE 4.2"),
("test_bk7252.yaml", "BK7251.*BLE 4.2"),
("test_bk7231q.yaml", "BK7231Q.*no BLE"),
],
)
def test_unsupported_family_rejected(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
match: str,
caplog: pytest.LogCaptureFixture,
) -> None:
with pytest.raises(EsphomeError, match=match):
generate_main(component_config_path(config_file))
# Validation itself must not fail (CI validate fixtures run on a BLE 4.2
# board), but it warns before codegen raises.
assert "cannot compile" in caplog.text
def test_ble5_family_generates(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("test_bk7231n.yaml"))
assert "bk72xx_ble::BK72xxBLE" in main_cpp
@@ -2,6 +2,6 @@ esphome:
name: slotcount-controller
bk72xx:
board: generic-bk7252
board: cb2s
bk72xx_ble:
@@ -2,6 +2,6 @@ esphome:
name: slotcount-tracker
bk72xx:
board: generic-bk7252
board: cb2s
bk72xx_ble_tracker:
@@ -0,0 +1,35 @@
"""Tests for the esp32_hosted ESP-IDF version gate."""
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_IDF_VERSION
from esphome.components.esp32_hosted import _final_validate
from esphome.const import PlatformFramework
from ..types import SetCoreConfigCallable
@pytest.mark.parametrize("idf", ["5.3.0", "5.4.2", "5.5.5"])
def test_final_validate_accepts_supported_idf(
set_core_config: SetCoreConfigCallable, idf: str
) -> None:
"""ESP-IDF 5.3 and newer passes validation unchanged."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
)
assert _final_validate({}) == {}
@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"])
def test_final_validate_rejects_old_idf(
set_core_config: SetCoreConfigCallable, idf: str
) -> None:
"""ESP-IDF older than 5.3 is rejected with a clear error."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
)
with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.3 or newer"):
_final_validate({})
@@ -0,0 +1,35 @@
esphome:
name: host-pref-key-stability
host:
api:
logger:
switch:
- platform: template
id: test_switch_restore
name: Test Switch
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
number:
- platform: template
id: test_number_restore
name: Test Number
optimistic: true
restore_value: true
initial_value: 1.0
min_value: 0
max_value: 100
step: 0.5
text:
- platform: template
id: test_text_restore
name: Test Text
mode: text
optimistic: true
restore_value: true
initial_value: fallback
min_length: 0
max_length: 20
@@ -33,6 +33,11 @@ sensor:
id: source_sensor_5
accuracy_decimals: 1
- platform: template
name: "Source Sensor 6"
id: source_sensor_6
accuracy_decimals: 1
- platform: copy
source_id: source_sensor_1
name: "Filter Min"
@@ -81,6 +86,13 @@ sensor:
filters:
- delta: 50%
- platform: copy
source_id: source_sensor_6
name: "Filter NaN"
id: filter_nan
filters:
- delta: 0
script:
- id: test_filter_min
then:
@@ -188,6 +200,24 @@ script:
id: source_sensor_5
state: 250.0 # Passes (delta=90 > 80)
- id: test_filter_nan
then:
- sensor.template.publish:
id: source_sensor_6
state: 1.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: !lambda "return NAN;"
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: !lambda "return NAN;" # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: 2.0
button:
- platform: template
name: "Test Filter Min"
@@ -218,3 +248,9 @@ button:
id: btn_filter_percentage
on_press:
- script.execute: test_filter_percentage
- platform: template
name: "Test Filter NaN"
id: btn_filter_nan
on_press:
- script.execute: test_filter_nan
@@ -50,19 +50,10 @@ uart_mock:
0x04, 0x03, 0x02, 0x01,
]
# Catch-all response: match any command footer (04 03 02 01).
# Returns a generic ACK with cmd=0xFF (CMD_ENABLE_CONF case in switch).
# All commands get unblocked via cmd_reply_.ack = true.
# Data fields stay zeroed (min_gate=0, max_gate=0, timeout=0, thresholds=0).
#
# Response layout:
# [0-3] FD FC FB FA = header
# [4-5] 04 00 = length 4
# [6] FF = cmd (handled as CMD_ENABLE_CONF)
# [7] 01 = status (ACK)
# [8-9] 00 00 = error = 0
# [10-13] 04 03 02 01 = footer
- expect_tx: [0x04, 0x03, 0x02, 0x01]
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
# TX = FD FC FB FA 04 00 FF 00 02 00 04 03 02 01
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
@@ -72,8 +63,58 @@ uart_mock:
0x04, 0x03, 0x02, 0x01,
]
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Catch-all for the remaining commands, which are all CMD_READ_ABD_PARAM
# (0x0008) reads: min/max/timeout limits and the 16 gate threshold reads.
# The reply carries three zeroed uint32 values (data length 16 = 4 + 12),
# so limits and thresholds all read as 0.
#
# Response layout:
# [0-3] FD FC FB FA = header
# [4-5] 10 00 = length 16
# [6] 08 = cmd (CMD_READ_ABD_PARAM)
# [7] 01 = status (ACK)
# [8-9] 00 00 = error = 0
# [10-21] 00 x12 = three zeroed uint32 data values
# [22-25] 04 03 02 01 = footer
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
injections:
# Phase 1 (t=100ms): Valid LD2420 energy mode data frame - happy path
# Phase 1 (t=700ms): Valid LD2420 energy mode data frame - happy path
# Delay=700ms keeps it outside the component's 500ms listen settle window,
# which is measured from boot and ignores earlier reception; this frame is
# both the happy path data and the wake-up that starts the setup handshake.
# Buffer is clean (buffer_pos_=0). This frame should parse correctly.
# Presence: 1 (target detected), Distance: 100cm, Gate energies: all 0
#
@@ -84,7 +125,7 @@ uart_mock:
# [7-8] 64 00 = distance 100 (uint16_t LE)
# [9-40] 00 00 x16 = 16 gate energies (uint16_t LE each)
# [41-44] F8 F7 F6 F5 = energy frame footer
- delay: 100ms
- delay: 700ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
@@ -98,13 +139,15 @@ uart_mock:
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 2 (t=300ms): Garbage bytes
# Phase 2 (t=1600ms): Garbage bytes
# LD2420's readline_ does NOT check frame headers at position 0 (unlike LD2412),
# so these bytes accumulate in the buffer. buffer_pos_ goes from 0 to 7.
- delay: 200ms
# Delay=900ms leaves time for the setup handshake (triggered by Phase 1,
# the first data seen from the module) to finish first.
- delay: 900ms
inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22]
# Phase 3 (t=400ms): Truncated energy frame WITH footer (13 bytes)
# Phase 3 (t=1700ms): Truncated energy frame WITH footer (13 bytes)
# This tests PR #14458 bug #3: missing length validation in handle_energy_mode_.
# The 7 garbage bytes from Phase 2 are still in the buffer (buffer_pos_=7).
# These 13 bytes are appended at positions 7-19 (buffer_pos_=20).
@@ -126,7 +169,7 @@ uart_mock:
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 4 (t=600ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
# Phase 4 (t=1900ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
# After Phase 3, buffer_pos_=0 (reset after energy footer detection).
# 49 bytes fill positions 0-48 (buffer_pos_=49), 50th byte triggers overflow.
# Logs "Max command length exceeded; ignoring", buffer_pos_=0.
@@ -140,11 +183,11 @@ uart_mock:
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
]
# Phase 5 (t=1500ms): Valid frame after overflow - recovery test
# Phase 5 (t=2300ms): Valid frame after overflow - recovery test
# Buffer was reset by overflow. This valid frame should parse correctly.
# Presence: 1 (target), Distance: 50cm
# Delay=900ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
- delay: 900ms
# Delay=400ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
- delay: 400ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
@@ -0,0 +1,137 @@
esphome:
name: uart-mock-ld2420-retry-test
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
uart:
baud_rate: 115200
port: /dev/null
# Exercises the per-command retry path: the module ignores the first config
# mode enable command and only answers the resend, so the startup handshake
# must time out once, resend, and then complete normally.
uart_mock:
id: mock_uart
baud_rate: 115200
auto_start: true
injections:
# Wake-up frame (t=700ms): energy frame (presence=1, distance=100).
# Delay=700ms keeps it outside the component's 500ms listen settle window.
- delay: 700ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x01,
0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# The config mode enable command is matched by an empty responder so the
# catch-all cannot answer it (responders match on the TX suffix and every
# command ends with the frame footer); the on_tx hook below acks it from
# the second attempt on, so the first attempt is genuine silence.
responses:
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx: []
# Version response: returns "v2.0.0" → 200 >= 154 → energy mode
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
0x04, 0x03, 0x02, 0x01,
]
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
# gate threshold reads. Three zeroed uint32 data values.
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Ignore the first config mode enable command; ack every one after it
on_tx:
- lambda: |-
static int enable_count = 0;
if (data.size() == 14 && data[6] == 0xFF) {
enable_count++;
if (enable_count >= 2) {
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>{
0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01});
}
}
ld2420:
id: ld2420_dev
uart_id: mock_uart
sensor:
- platform: ld2420
ld2420_id: ld2420_dev
moving_distance:
name: "Moving Distance"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
binary_sensor:
- platform: ld2420
ld2420_id: ld2420_dev
has_target:
name: "Has Target"
filters:
- settle: 50ms
@@ -0,0 +1,168 @@
esphome:
name: uart-mock-ld2420-boot-test
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
uart:
baud_rate: 115200
port: /dev/null
# Simulates a cold boot where the LD2420 module boots slower than the ESP:
# the module is silent for 2 seconds and then sends its first energy frame.
# The module locks up until power cycled if it receives any data before it
# has sent its first frame, so the component must stay quiet for the full
# 2 seconds and only start its setup handshake after the first frame.
uart_mock:
id: mock_uart
baud_rate: 115200
auto_start: true
injections:
# Module boot finished (t=2000ms): first energy frame
# (presence=1, distance=100). Any TX from the component before this
# point would have locked up real hardware.
- delay: 2000ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x01,
0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Repeat frame (t=3100ms): distance=100 again. If the API client happens
# to subscribe after the first frame, the first published state is
# swallowed as the entity's initial state; repeating the value makes the
# test's first collected state deterministic. Delay=1100ms keeps >1000ms
# publish throttle gap from the first frame.
- delay: 1100ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x01,
0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Post-setup frame (t=4200ms): distance=50 proves streaming still works
# after the setup handshake. Delay=1100ms keeps >1000ms publish throttle
# gap from the repeat frame.
- delay: 1100ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x01,
0x32, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
responses:
# Version response: returns "v2.0.0" → 200 >= 154 → energy mode
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
0x04, 0x03, 0x02, 0x01,
]
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
# gate threshold reads. Three zeroed uint32 data values.
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
ld2420:
id: ld2420_dev
uart_id: mock_uart
sensor:
- platform: ld2420
ld2420_id: ld2420_dev
moving_distance:
name: "Moving Distance"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
binary_sensor:
- platform: ld2420
ld2420_id: ld2420_dev
has_target:
name: "Has Target"
filters:
- settle: 50ms
@@ -0,0 +1,128 @@
esphome:
name: uart-mock-ld2420-giveup-test
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
uart:
baud_rate: 115200
port: /dev/null
# Exercises the sequence retry and give-up path: the module streams energy
# frames and answers every command except the firmware version read. The
# startup handshake must retry the whole sequence, eventually give up with a
# warning instead of marking the component failed, and keep parsing the
# stream afterwards. Runs for roughly 16 seconds of retry cadence.
uart_mock:
id: mock_uart
baud_rate: 115200
auto_start: true
# Module streams a valid energy frame (presence=1, distance=100) continuously
periodic_rx:
- interval: 250ms
data:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x01,
0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
injections:
# Post-give-up parser probe (t=22s): 50 bytes of 0xFF overflow the frame
# buffer, which the parser answers with a "Max command length exceeded"
# warning. The give-up happens around t=16s, so seeing that warning after
# the give-up proves the stream parser is still running in the degraded
# state. (A distinct sensor value cannot serve as the probe: the 1s
# publish throttle races the constant periodic stream, and the API
# deduplicates repeated identical states.)
- delay: 22000ms
inject_rx:
[
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
]
responses:
# Version read: matched so the catch-all cannot answer it, but never
# replied to; this is the command the handshake gives up on
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx: []
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE), sent blind before each
# sequence retry and on the final give-up
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
ld2420:
id: ld2420_dev
uart_id: mock_uart
sensor:
- platform: ld2420
ld2420_id: ld2420_dev
moving_distance:
name: "Moving Distance"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
binary_sensor:
- platform: ld2420
ld2420_id: ld2420_dev
has_target:
name: "Has Target"
filters:
- settle: 50ms
@@ -0,0 +1,167 @@
esphome:
name: uart-mock-ld2420-restart-test
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
uart:
baud_rate: 115200
port: /dev/null
# Exercises the module restart path. The restart command hits the module while
# it is mid transmission, so a few tail bytes of the in-flight frame arrive
# right after the restart. The module is then silent for 2 seconds while it
# boots, and it locks up until power cycled if it receives any data in that
# window. The component must not treat the tail bytes as proof the module is
# up, and must only re-run its setup handshake after the module's first
# post-boot frame.
uart_mock:
id: mock_uart
baud_rate: 115200
auto_start: true
injections:
# Initial wake-up frame (t=700ms): energy frame (presence=1, distance=100).
# Delay=700ms keeps it outside the component's 500ms listen settle window,
# which is measured from boot and ignores earlier reception.
- delay: 700ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x01,
0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
responses:
# Version response: returns "v2.0.0" → 200 >= 154 → energy mode
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
0x04, 0x03, 0x02, 0x01,
]
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# The module restart command (CMD_RESTART, 0x0068) gets no reply; a real
# module goes silent and reboots. Matching it here prevents the catch-all
# below from answering it.
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x68, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx: []
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
# gate threshold reads. Three zeroed uint32 data values.
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
button:
- platform: template
name: "Restart Module"
on_press:
- lambda: 'id(ld2420_dev).restart_module_action();'
# Tail of the energy frame the module was transmitting when the restart
# command hit it; must not count as proof the module is up
- uart_mock.inject_rx:
id: mock_uart
data: [0x00, 0x00, 0x00, 0xF8, 0xF7, 0xF6, 0xF5]
# The module's first frame after its ~2s boot (presence=1, distance=100)
- uart_mock.inject_rx:
id: mock_uart
delay: 2000ms
data:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x01,
0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
ld2420:
id: ld2420_dev
uart_id: mock_uart
sensor:
- platform: ld2420
ld2420_id: ld2420_dev
moving_distance:
name: "Moving Distance"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
binary_sensor:
- platform: ld2420
ld2420_id: ld2420_dev
has_target:
name: "Has Target"
filters:
- settle: 50ms
@@ -22,10 +22,24 @@ uart_mock:
baud_rate: 115200
auto_start: false
responses:
# Catch-all response only (no version-specific response).
# Without a version response, firmware_ver_ stays at default "v0.0.0".
# get_firmware_int("v0.0.0") = 0 < 154 → simple mode (CMD_SYSTEM_MODE_SIMPLE).
- expect_tx: [0x04, 0x03, 0x02, 0x01]
# Version response with an old firmware version "v1.5.3".
# get_firmware_int("v1.5.3") = 153 < 154 → simple mode (CMD_SYSTEM_MODE_SIMPLE).
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x31, 0x2E, 0x35, 0x2E, 0x33,
0x04, 0x03, 0x02, 0x01,
]
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
@@ -35,11 +49,50 @@ uart_mock:
0x04, 0x03, 0x02, 0x01,
]
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = simple (0x0064)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
# gate threshold reads. Three zeroed uint32 data values.
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
injections:
# Phase 1 (t=100ms): Valid simple mode text frame - happy path
# "ON Range 0100\r\n" → presence=true, distance=100
# Simple mode frames end with \r\n (0x0D 0x0A), triggering handle_simple_mode_.
- delay: 100ms
# Phase 0 (t=700ms): Wake-up frame. The component listens for data from the
# module before transmitting anything, so this frame starts the setup
# handshake. It is not parsed as simple mode because the component's system
# mode is only switched to simple after the firmware version is read.
# Delay=700ms keeps it outside the component's 500ms listen settle window,
# which is measured from boot and ignores earlier reception.
- delay: 700ms
inject_rx:
[
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
@@ -47,12 +100,24 @@ uart_mock:
0x0D, 0x0A,
]
# Phase 2 (t=300ms): Garbage bytes
# Phase 1 (t=1600ms): Valid simple mode text frame - happy path
# "ON Range 0100\r\n" → presence=true, distance=100
# Simple mode frames end with \r\n (0x0D 0x0A), triggering handle_simple_mode_.
# Delay=900ms leaves time for the setup handshake to finish first.
- delay: 900ms
inject_rx:
[
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
0x30, 0x31, 0x30, 0x30,
0x0D, 0x0A,
]
# Phase 2 (t=1800ms): Garbage bytes
# LD2420's readline_ stores all bytes regardless of header. buffer_pos_ = 7.
- delay: 200ms
inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22]
# Phase 3 (t=500ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
# Phase 3 (t=2000ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
# buffer_pos_ starts at 7 (from Phase 2 garbage).
# Positions 7-48 fill (42 bytes), byte 43 triggers overflow (buffer_pos_=49).
# After overflow: buffer_pos_=0, remaining 7 bytes fill positions 0-6.
@@ -67,13 +132,13 @@ uart_mock:
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
]
# Phase 4 (t=1400ms): Recovery after overflow
# Phase 4 (t=2700ms): Recovery after overflow
# buffer_pos_ = 7 (from overflow remainder). These 15 bytes fill positions 7-21.
# At position 21 (0x0A), \r\n detected → handle_simple_mode_(buffer, 22).
# Parser skips 0xFF bytes at positions 0-6, finds "ON" at positions 7-8,
# parses digits "0050" → distance=50.
# Delay=900ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
- delay: 900ms
# Delay=700ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
- delay: 700ms
inject_rx:
[
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
@@ -81,7 +146,7 @@ uart_mock:
0x0D, 0x0A,
]
# Phase 5 (t=2500ms): 16-digit distance - tests PR #14458 bug #1
# Phase 5 (t=3800ms): 16-digit distance - tests PR #14458 bug #1
# "ON Range 0000000000000000\r\n" has 16 digit characters.
# handle_simple_mode_ outbuf is 16 bytes, can hold 15 digits (index 0-14).
#
@@ -100,7 +165,7 @@ uart_mock:
0x0D, 0x0A,
]
# Phase 6 (t=3700ms): Post-bug-trigger recovery
# Phase 6 (t=5000ms): Post-bug-trigger recovery
# If Phase 5 didn't hang, this frame should parse correctly.
# "ON Range 0025\r\n" → distance=25
# Delay=1200ms ensures >1000ms gap from Phase 5 for throttle.
@@ -0,0 +1,147 @@
esphome:
name: uart-mock-ld2420-warm-test
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
uart:
baud_rate: 115200
port: /dev/null
# Simulates a warm restart: the ESP rebooted but the LD2420 module stayed
# powered and keeps streaming energy frames from the moment the firmware
# starts. The component must not transmit anything until it has seen data
# from the module, then run its setup handshake against the live stream.
uart_mock:
id: mock_uart
baud_rate: 115200
auto_start: true
# Module streams valid energy frames continuously. Two alternating frames
# are used (presence=1/distance=100 and presence=0/distance=75) so states
# keep changing: with a constant frame the API deduplicates the repeated
# identical states, and a client that subscribes after the first publish
# would swallow the only transition as the initial state and never see an
# update.
periodic_rx:
- interval: 250ms
data:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x01,
0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
- interval: 1050ms
data:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x00,
0x4B, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
responses:
# Version response: returns "v2.0.0" → 200 >= 154 → energy mode
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
0x04, 0x03, 0x02, 0x01,
]
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
# gate threshold reads. Three zeroed uint32 data values.
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
ld2420:
id: ld2420_dev
uart_id: mock_uart
sensor:
- platform: ld2420
ld2420_id: ld2420_dev
moving_distance:
name: "Moving Distance"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
binary_sensor:
- platform: ld2420
ld2420_id: ld2420_dev
has_target:
name: "Has Target"
filters:
- settle: 50ms
+17 -7
View File
@@ -25,15 +25,25 @@ def clear_host_prefs(device_name: str) -> None:
host_prefs_path(device_name).unlink(missing_ok=True)
def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path:
"""Write preference entries, replacing the file's contents.
Returns the path that was written.
"""
payload = b""
for key, data in entries.items():
if len(data) > 255:
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
payload += struct.pack("<IB", key, len(data)) + data
path = host_prefs_path(device_name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(payload)
return path
def write_host_pref(device_name: str, key: int, data: bytes) -> Path:
"""Write a single preference entry, replacing the file's contents.
Returns the path that was written.
"""
if len(data) > 255:
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
path = host_prefs_path(device_name)
path.parent.mkdir(parents=True, exist_ok=True)
payload = struct.pack("<IB", key, len(data)) + data
path.write_bytes(payload)
return path
return write_host_prefs(device_name, {key: data})
@@ -0,0 +1,168 @@
"""Integration test for entity preference key stability.
Entity preferences are stored under keys derived from the sanitized object_id
hash. This test seeds the host preferences file the way existing firmware
wrote it and verifies the state is restored, proving the key scheme has not
drifted; a save and reload round trip cannot catch drift because it writes
and reads with the same code.
The second run also seeds the raw-name-hash entries a 2026.8 beta device left
behind (see https://github.com/esphome/esphome/pull/18361) and proves they are
ignored: the object_id entries win and the beta leftovers are inert.
"""
from __future__ import annotations
import socket
import struct
from aioesphomeapi import (
NumberInfo,
NumberState,
SwitchInfo,
SwitchState,
TextInfo,
TextState,
)
import pytest
from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
from .host_prefs import clear_host_prefs, write_host_prefs
from .state_utils import InitialStateHelper, require_entity
from .types import CompileFunction, ConfigWriter
DEVICE_NAME = "host-pref-key-stability"
# All entities are on the main device (device_id 0) and their preferences use
# no version salt, so the key is just the object_id hash.
SWITCH_KEY = fnv1_hash_object_id("Test Switch")
NUMBER_KEY = fnv1_hash_object_id("Test Number")
# Raw-name-hash keys as written by 2026.8 beta firmware; never read by this build
SWITCH_BETA_KEY = fnv1_hash_name("Test Switch")
NUMBER_BETA_KEY = fnv1_hash_name("Test Number")
# template_text salts its key with the length limits and pattern hash; this must
# match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20,
# no pattern configured)
TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6)
TEXT_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
TEXT_BETA_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes
TEXT_MAX_LENGTH = 20
def text_pref_payload(value: str) -> bytes:
"""Build the length-prefixed buffer TextSaver stores for a value."""
data = value.encode("utf-8")
assert len(data) <= TEXT_MAX_LENGTH
return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data))
@pytest.mark.asyncio
async def test_preference_key_stability(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
) -> None:
"""Test that preferences stored by earlier firmware are restored."""
port, port_socket = reserved_tcp_port
assert SWITCH_KEY != SWITCH_BETA_KEY
assert NUMBER_KEY != NUMBER_BETA_KEY
assert TEXT_KEY != TEXT_BETA_KEY
# Write and compile once
config_path = await write_yaml_config(yaml_config)
binary_path = await compile_esphome(config_path)
# Release the reserved port so the binary can bind to it
port_socket.close()
async def boot_and_get_initial_states() -> tuple[
SwitchState, NumberState, TextState
]:
"""Boot the binary and return the restored entity states."""
async with (
run_binary_and_wait_for_port(binary_path, "127.0.0.1", port),
wait_and_connect_api_client(port=port) as client,
):
device_info = await client.device_info()
assert device_info.name == DEVICE_NAME
entities, _ = await client.list_entities_services()
switch_entity = require_entity(
entities, "test_switch", SwitchInfo, "Test Switch"
)
number_entity = require_entity(
entities, "test_number", NumberInfo, "Test Number"
)
text_entity = require_entity(entities, "test_text", TextInfo, "Test Text")
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(
initial_state_helper.on_state_wrapper(lambda s: None)
)
await initial_state_helper.wait_for_initial_states()
switch_state = initial_state_helper.initial_states[switch_entity.key]
number_state = initial_state_helper.initial_states[number_entity.key]
text_state = initial_state_helper.initial_states[text_entity.key]
assert isinstance(switch_state, SwitchState)
assert isinstance(number_state, NumberState)
assert isinstance(text_state, TextState)
return switch_state, number_state, text_state
try:
# --- Run 1: entries under the object_id-hash keys, exactly as any
# earlier firmware wrote them. The restored states prove the key
# scheme has not drifted.
write_host_prefs(
DEVICE_NAME,
{
SWITCH_KEY: b"\x01", # bool: switch was ON
NUMBER_KEY: struct.pack("<f", 42.5),
TEXT_KEY: text_pref_payload("hello"),
},
)
switch_state, number_state, text_state = await boot_and_get_initial_states()
assert switch_state.state is True, (
"Switch state stored under the object_id preference key was lost"
)
assert number_state.state == 42.5, (
"Number value stored under the object_id preference key was lost"
)
assert text_state.state == "hello", (
"Text value stored under the object_id preference key was lost"
)
# --- Run 2: raw-name-hash entries from a 2026.8 beta device present
# alongside the object_id entries. The object_id data must win; the
# beta entries are never read.
write_host_prefs(
DEVICE_NAME,
{
SWITCH_KEY: b"\x01", # current: ON
SWITCH_BETA_KEY: b"\x00", # beta leftover: OFF
NUMBER_KEY: struct.pack("<f", 13.5), # current
NUMBER_BETA_KEY: struct.pack("<f", 99.5), # beta leftover
TEXT_KEY: text_pref_payload("world"), # current
TEXT_BETA_KEY: text_pref_payload("ignored"), # beta leftover
},
)
switch_state, number_state, text_state = await boot_and_get_initial_states()
assert switch_state.state is True, (
"Beta raw-name-key data overrode the object_id switch state"
)
assert number_state.state == 13.5, (
"Beta raw-name-key data overrode the object_id number value"
)
assert text_state.state == "world", (
"Beta raw-name-key data overrode the object_id text value"
)
finally:
clear_host_prefs(DEVICE_NAME)
+35 -3
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import math
from aioesphomeapi import ButtonInfo, EntityState, SensorState
import pytest
@@ -25,6 +26,7 @@ async def test_sensor_filters_delta(
"filter_baseline_max": [],
"filter_zero_delta": [],
"filter_percentage": [],
"filter_nan": [],
}
filter_min_done = loop.create_future()
@@ -32,16 +34,23 @@ async def test_sensor_filters_delta(
filter_baseline_max_done = loop.create_future()
filter_zero_delta_done = loop.create_future()
filter_percentage_done = loop.create_future()
filter_nan_done = loop.create_future()
def on_state(state: EntityState) -> None:
if not isinstance(state, SensorState) or state.missing_state:
if not isinstance(state, SensorState):
return
sensor_name = key_to_sensor.get(state.key)
if sensor_name not in sensor_values:
return
sensor_values[sensor_name].append(state.state)
if state.missing_state:
# Only the NaN test is interested in unavailable states
if sensor_name != "filter_nan":
return
sensor_values[sensor_name].append(math.nan)
else:
sensor_values[sensor_name].append(state.state)
# Check completion conditions
if (
@@ -74,6 +83,12 @@ async def test_sensor_filters_delta(
and not filter_percentage_done.done()
):
filter_percentage_done.set_result(True)
elif (
sensor_name == "filter_nan"
and len(sensor_values[sensor_name]) == 3
and not filter_nan_done.done()
):
filter_nan_done.set_result(True)
async with (
run_compiled(yaml_config),
@@ -89,6 +104,7 @@ async def test_sensor_filters_delta(
"filter_baseline_max": "Filter Baseline Max",
"filter_zero_delta": "Filter Zero Delta",
"filter_percentage": "Filter Percentage",
"filter_nan": "Filter NaN",
},
)
@@ -108,13 +124,14 @@ async def test_sensor_filters_delta(
"Test Filter Baseline Max": "filter_baseline_max",
"Test Filter Zero Delta": "filter_zero_delta",
"Test Filter Percentage": "filter_percentage",
"Test Filter NaN": "filter_nan",
}
buttons = {}
for entity in entities:
if isinstance(entity, ButtonInfo) and entity.name in button_name_map:
buttons[button_name_map[entity.name]] = entity.key
assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}"
assert len(buttons) == 6, f"Expected 6 buttons, found {len(buttons)}"
# Test 1: Min
sensor_values["filter_min"].clear()
@@ -186,3 +203,18 @@ async def test_sensor_filters_delta(
assert sensor_values["filter_percentage"] == pytest.approx(expected), (
f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}"
)
# Test 6: NaN passes through once, then is suppressed
sensor_values["filter_nan"].clear()
client.button_command(buttons["filter_nan"])
try:
await asyncio.wait_for(filter_nan_done, timeout=2.0)
except TimeoutError:
pytest.fail(f"Test 6 timed out. Values: {sensor_values['filter_nan']}")
values = sensor_values["filter_nan"]
assert values[0] == pytest.approx(1.0), f"Test 6 failed: got {values}"
assert math.isnan(values[1]), (
f"Test 6 failed: NaN not passed through, got {values}"
)
assert values[2] == pytest.approx(2.0), f"Test 6 failed: got {values}"
+430 -1
View File
@@ -15,6 +15,36 @@ test_uart_mock_ld2420_simple (simple mode):
3. Buffer overflow recovery
4. 16-digit distance triggers infinite loop pre-fix (PR #14458 bug #1)
5. Post-bug-trigger recovery proves the parser survived
test_uart_mock_ld2420_warm_restart (module streaming at boot):
Simulates a warm restart where the module stayed powered and streams energy
frames from the moment the firmware starts. Asserts the component never
transmits before receiving data from the module, completes setup against
the live stream, and publishes sensor data.
test_uart_mock_ld2420_delayed_boot (module boots slower than the ESP):
Simulates a cold boot where the module is silent for 2 seconds. The module
locks up until power cycled if it receives data before sending its first
frame, so the component must stay quiet until the module talks, then
complete setup and keep parsing the stream.
test_uart_mock_ld2420_restart_button (module restart action):
Presses the restart button after setup. The restart hits the module mid
transmission, so a few tail bytes of the in-flight frame arrive right after
the restart command, then the module is silent for 2 seconds while it
boots. The component must not treat the tail bytes as proof the module is
up and must only re-run its handshake after the module's first post-boot
frame; transmitting into the boot window locks up real hardware.
test_uart_mock_ld2420_cmd_retry (per-command resend):
The module ignores the first config mode enable command and only answers
the resend. The handshake must time out once, resend, and complete.
test_uart_mock_ld2420_give_up (sequence retry and give-up):
The module streams and answers everything except the firmware version
read. The handshake must retry the whole sequence, eventually give up with
a warning instead of marking the component failed, and keep publishing
sensor data from the stream afterwards.
"""
from __future__ import annotations
@@ -25,7 +55,12 @@ from pathlib import Path
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import InitialStateHelper, SensorStateCollector, find_entity
from .state_utils import (
InitialStateHelper,
SensorStateCollector,
find_entity,
require_entity,
)
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -160,6 +195,400 @@ async def test_uart_mock_ld2420(
)
SETUP_COMPLETE_LOG = "Module setup complete; firmware v2.0.0"
class _LogWatcher:
"""Resolves futures when watched substrings appear in device log lines.
Use as the run_compiled line_callback. watch() returns a future that
resolves once a line containing all given substrings has been seen `count`
times; `after` gates matching on another future being done, and `until`
stops matching once another future is done. collect() gathers every line
containing any of the given substrings into `self.collected`.
"""
def __init__(self) -> None:
self._loop = asyncio.get_running_loop()
self._watches: list[dict] = []
self._collect_substrings: tuple[str, ...] = ()
self.collected: list[str] = []
def watch(
self,
substrings: str | list[str],
*,
count: int = 1,
after: asyncio.Future | None = None,
until: asyncio.Future | None = None,
) -> asyncio.Future:
subs = [substrings] if isinstance(substrings, str) else substrings
watch = {
"subs": subs,
"count": count,
"after": after,
"until": until,
"future": self._loop.create_future(),
"seen": 0,
}
self._watches.append(watch)
return watch["future"]
def collect(self, *substrings: str) -> None:
self._collect_substrings = substrings
def __call__(self, line: str) -> None:
for watch in self._watches:
if watch["future"].done():
continue
if watch["after"] is not None and not watch["after"].done():
continue
if watch["until"] is not None and watch["until"].done():
continue
if all(s in line for s in watch["subs"]):
watch["seen"] += 1
if watch["seen"] >= watch["count"]:
watch["future"].set_result(True)
if any(s in line for s in self._collect_substrings):
self.collected.append(line)
async def _wait_or_fail(awaitable, timeout: float, message) -> None:
"""Await with a timeout, translating TimeoutError into pytest.fail.
`message` may be a string or a zero-argument callable evaluated at
failure time (for messages that embed the current collector state).
"""
try:
await asyncio.wait_for(awaitable, timeout=timeout)
except TimeoutError:
pytest.fail(message() if callable(message) else message)
async def _subscribe_and_wait(client, collector: SensorStateCollector | None = None):
"""List entities, subscribe states, and wait for the initial state flood."""
entities, _ = await client.list_entities_services()
if collector is not None:
collector.build_key_mapping(entities)
initial_state_helper = InitialStateHelper(entities)
on_state = collector.on_state if collector is not None else (lambda s: None)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await _wait_or_fail(
initial_state_helper.wait_for_initial_states(),
11.0,
"Timeout waiting for initial states",
)
return entities
async def _run_listen_first_test(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
*,
post_setup_distance: float | None = None,
strict_first: bool = True,
) -> None:
"""Shared body for the listen-first startup tests.
Asserts the component never transmits before the module has sent data
(real hardware locks up until power cycled if it does), that the setup
handshake completes, and that sensor data publishes. When
post_setup_distance is given, additionally waits for that value to prove
streaming still works after the handshake. strict_first asserts on the
first collected state; pass False for fixtures whose stream alternates
values, where the first collected state depends on subscribe timing.
"""
loop = asyncio.get_running_loop()
setup_complete = loop.create_future()
rx_seen = False
tx_before_rx = False
failure_lines: list[str] = []
def line_callback(line: str) -> None:
nonlocal rx_seen, tx_before_rx
if "uart_mock" in line:
if "RX inject" in line or "Injecting" in line:
rx_seen = True
elif "TX " in line and not rx_seen:
tx_before_rx = True
if SETUP_COMPLETE_LOG in line and not setup_complete.done():
setup_complete.set_result(True)
if (
"marked FAILED" in line
or "was marked as failed" in line
or "Communication failed" in line
or "No data received from the module" in line
):
failure_lines.append(line)
collector = SensorStateCollector(
sensor_names=["moving_distance"],
binary_sensor_names=["has_target"],
)
post_setup_received = None
if post_setup_distance is not None:
post_setup_received = collector.add_waiter(
lambda: (
pytest.approx(post_setup_distance)
in collector.sensor_states["moving_distance"]
)
)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
await _subscribe_and_wait(client, collector)
# Setup handshake must complete once the module has talked
await _wait_or_fail(
setup_complete,
10.0,
"Timeout waiting for 'Module setup complete' log line. "
"The startup state machine did not finish its handshake.",
)
# Sensor data must flow from the stream
await _wait_or_fail(
collector.wait_for_all(timeout=5.0),
6.0,
lambda: (
f"Timeout waiting for sensor data. Received:\n"
f" sensor_states: {collector.sensor_states}\n"
f" binary_states: {collector.binary_states}"
),
)
if strict_first:
assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0)
assert collector.binary_states["has_target"][0] is True
else:
assert pytest.approx(100.0) in collector.sensor_states["moving_distance"]
assert True in collector.binary_states["has_target"]
if post_setup_received is not None:
await _wait_or_fail(
post_setup_received,
5.0,
lambda: (
f"Timeout waiting for post-setup frame "
f"(distance={post_setup_distance}). Received:\n"
f" moving_distance: {collector.sensor_states['moving_distance']}"
),
)
# The component must never transmit before the module has talked;
# real hardware locks up until power cycled if it does.
assert not tx_before_rx, (
"Component transmitted on the UART before receiving any data "
"from the module; this locks up real LD2420 hardware"
)
assert not failure_lines, f"Unexpected failure log lines: {failure_lines}"
@pytest.mark.asyncio
async def test_uart_mock_ld2420_warm_restart(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Module streams from boot; component must listen first, then set up."""
await _run_listen_first_test(
yaml_config, run_compiled, api_client_connected, strict_first=False
)
@pytest.mark.asyncio
async def test_uart_mock_ld2420_delayed_boot(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Module silent for 2 s; component must not transmit into the boot window."""
await _run_listen_first_test(
yaml_config, run_compiled, api_client_connected, post_setup_distance=50.0
)
@pytest.mark.asyncio
async def test_uart_mock_ld2420_cmd_retry(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""First config command gets no reply; the resend must recover."""
watcher = _LogWatcher()
resend_seen = watcher.watch("No reply to startup command")
setup_complete = watcher.watch(SETUP_COMPLETE_LOG)
watcher.collect(
"marked FAILED",
"was marked as failed",
"Communication failed",
"Module setup attempt",
)
collector = SensorStateCollector(
sensor_names=["moving_distance"],
binary_sensor_names=["has_target"],
)
async with (
run_compiled(yaml_config, line_callback=watcher),
api_client_connected() as client,
):
await _subscribe_and_wait(client, collector)
# The first enable command is ignored, so a resend must happen
await _wait_or_fail(
resend_seen, 10.0, "Timeout waiting for the startup command resend log line"
)
# The resend gets an ack and the handshake completes normally
await _wait_or_fail(
setup_complete,
10.0,
"Timeout waiting for 'Module setup complete' after the resend",
)
await _wait_or_fail(
collector.wait_for_all(timeout=5.0),
6.0,
lambda: (
f"Timeout waiting for sensor data. Received:\n"
f" sensor_states: {collector.sensor_states}"
),
)
assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0)
# A single command resend must not burn a whole sequence retry or
# produce any failure log line
assert not watcher.collected, (
f"Unexpected failure log lines: {watcher.collected}"
)
@pytest.mark.asyncio
async def test_uart_mock_ld2420_give_up(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Version read never answers; retries then give-up, stream keeps working."""
watcher = _LogWatcher()
sequence_retry_seen = watcher.watch("Module setup attempt 1 failed; retrying")
give_up_seen = watcher.watch("Firmware version and operating mode were never read")
# The overflow probe injected at t=22s (after the give-up) makes the
# parser log this warning only if it is still running
parser_alive_after_give_up = watcher.watch(
"Max command length exceeded", after=give_up_seen
)
watcher.collect("marked FAILED", "was marked as failed")
collector = SensorStateCollector(
sensor_names=["moving_distance"],
binary_sensor_names=["has_target"],
)
async with (
run_compiled(yaml_config, line_callback=watcher),
api_client_connected() as client,
):
await _subscribe_and_wait(client, collector)
# The version read times out three times, then the sequence retries
await _wait_or_fail(
sequence_retry_seen, 15.0, "Timeout waiting for the sequence retry log line"
)
# After all sequence retries the component gives up with a warning
await _wait_or_fail(
give_up_seen, 30.0, "Timeout waiting for the give-up log line"
)
# The stream must still be parsed after giving up
await _wait_or_fail(
parser_alive_after_give_up,
20.0,
"No parser activity after the give-up; the stream parser "
"must keep running in the degraded state",
)
# The stream published sensor data while the handshake was failing
assert pytest.approx(100.0) in collector.sensor_states["moving_distance"], (
f"Expected the stream to publish distance=100, "
f"got: {collector.sensor_states['moving_distance']}"
)
# The whole point of the degraded state: the component keeps running
assert not watcher.collected, (
f"Component was marked failed: {watcher.collected}"
)
@pytest.mark.asyncio
async def test_uart_mock_ld2420_restart_button(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Restart action must not transmit into the module's boot window."""
watcher = _LogWatcher()
first_setup_complete = watcher.watch(SETUP_COMPLETE_LOG)
second_setup_complete = watcher.watch(SETUP_COMPLETE_LOG, count=2)
restart_seen = watcher.watch(["[ld2420", "Restarting"])
# The module's first frame after its simulated 2 s boot
module_frame_after_restart = watcher.watch("RX inject 45 bytes", after=restart_seen)
# Config mode enable transmitted before the module's first post-boot
# frame; on real hardware this locks the module up
tx_into_boot_window = watcher.watch(
["uart_mock", "TX ", "FF:00:02:00"],
after=restart_seen,
until=module_frame_after_restart,
)
watcher.collect("marked FAILED", "was marked as failed", "Communication failed")
async with (
run_compiled(yaml_config, line_callback=watcher),
api_client_connected() as client,
):
entities = await _subscribe_and_wait(client)
# Wait for the initial startup handshake to finish
await _wait_or_fail(
first_setup_complete,
10.0,
"Timeout waiting for the initial 'Module setup complete'",
)
# Restart the module; the button automation also injects the in-flight
# frame tail immediately and the module's first frame 2 s later
restart_btn = require_entity(entities, "restart_module", ButtonInfo)
client.button_command(restart_btn.key)
# The handshake must complete again after the module comes back
await _wait_or_fail(
second_setup_complete,
15.0,
"Timeout waiting for 'Module setup complete' after the restart. "
"The component did not recover from the module restart.",
)
assert not tx_into_boot_window.done(), (
"Component transmitted the config handshake into the module's "
"boot window after a restart; the in-flight frame tail bytes must "
"not count as proof the module is up"
)
assert not watcher.collected, (
f"Unexpected failure log lines: {watcher.collected}"
)
@pytest.mark.asyncio
async def test_uart_mock_ld2420_simple(
yaml_config: str,
@@ -1,10 +1,15 @@
"""Shared storage-sidecar factory for the lazy-import fixture scripts."""
from pathlib import Path
from esphome.storage_json import StorageJSON
def make_storage() -> StorageJSON:
"""A minimal post-compile esp32 sidecar the upload/logs fast path accepts."""
"""A minimal post-compile esp32 sidecar the upload/logs fast path accepts.
build_path must be set: the fast path rejects sidecars without one.
"""
return StorageJSON(
storage_version=1,
name="test",
@@ -15,8 +20,8 @@ def make_storage() -> StorageJSON:
address="1.2.3.4",
web_port=None,
target_platform="ESP32S3",
build_path=None,
firmware_bin_path=None,
build_path=Path("/build/test"),
firmware_bin_path=Path("/build/test/firmware.bin"),
loaded_integrations=set(),
loaded_platforms=set(),
no_mdns=False,
+268 -31
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import contextmanager
from ipaddress import IPv4Address, IPv4Network
import json
import os
@@ -19,6 +20,7 @@ from esphome.compiled_config import (
compiled_config_path,
load_compiled_config,
save_compiled_config,
save_compiled_config_and_sidecar,
)
from esphome.const import (
CONF_API,
@@ -31,7 +33,16 @@ from esphome.const import (
KEY_VARIANT,
Toolchain,
)
from esphome.core import CORE, ID, HexInt, Lambda, MACAddress, TimePeriodMilliseconds
from esphome.core import (
CORE,
ID,
EsphomeError,
HexInt,
Lambda,
MACAddress,
TimePeriodMilliseconds,
)
from esphome.storage_json import StorageJSON
from esphome.util import OrderedDict
_VALIDATED_CONFIG = {
@@ -54,8 +65,9 @@ def _cache_body(config: dict | None = None) -> str:
def _write_storage(
storage_path: Path,
*,
esp_platform: str = "ESP32",
esp_platform: str | None = "ESP32",
core_platform: str | None = "esp32",
build_path: str | None = "/build/lite_test",
) -> None:
"""Write a vanilla StorageJSON sidecar for the cache tests."""
storage_path.parent.mkdir(parents=True, exist_ok=True)
@@ -69,7 +81,7 @@ def _write_storage(
"address": "192.168.1.42",
"web_port": None,
"esp_platform": esp_platform,
"build_path": "/build/lite_test",
"build_path": build_path,
"firmware_bin_path": "/build/lite_test/firmware.bin",
"loaded_integrations": ["api", "logger", "ota", "wifi"],
"loaded_platforms": [],
@@ -359,31 +371,262 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache(
mock_read.assert_called_once()
def test_run_esphome_upload_does_not_refresh_cache_without_sidecar(
tmp_path: Path,
) -> None:
"""Without a StorageJSON sidecar (no compile has run), the fallback
skips the cache write -- load_compiled_config requires the sidecar,
so writing the rendered (secret-resolved) config would be inert and
leak secrets to disk for nothing."""
def _storage_fixture(tmp_path: Path) -> StorageJSON:
"""A loaded StorageJSON instance matching _write_storage's contents."""
fixture = tmp_path / "fixture_storage.json"
_write_storage(fixture)
return StorageJSON.load(fixture)
def _bare_yaml(tmp_path: Path) -> Path:
"""A minimal YAML with CORE.config_path pointed at it."""
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
return yaml_path
@contextmanager
def _fallback_run(command: str = "upload", **from_core_kwargs) -> Any:
"""Patch the fallback path's collaborators for a run_esphome call.
Without kwargs, from_esphome_core stays real (yielded mock is None).
"""
with (
patch(
"esphome.config.read_config",
return_value={"esphome": {"name": "lite_test"}},
),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
) as mock_read,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{"upload": lambda args, config: 0},
{command: lambda args, config: 0},
),
):
run_esphome(["esphome", "upload", str(yaml_path)])
if not from_core_kwargs:
yield mock_read, None
return
with patch.object(
StorageJSON, "from_esphome_core", **from_core_kwargs
) as mock_from_core:
yield mock_read, mock_from_core
@pytest.mark.parametrize("command", ["upload", "logs"])
def test_run_esphome_fallback_writes_sidecar_and_cache_without_sidecar(
tmp_path: Path, command: str
) -> None:
"""A never-compiled config caches on its first upload/logs run: the
fallback writes the StorageJSON sidecar itself (load_compiled_config
needs it), so the second run hits the fast path."""
yaml_path = _bare_yaml(tmp_path)
storage_dir = tmp_path / ".esphome" / "storage"
with _fallback_run(command, return_value=_storage_fixture(tmp_path)) as (
mock_read,
mock_from_core,
):
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
mock_from_core.assert_called_once()
assert (storage_dir / "lite_test.yaml.validated.json").exists()
storage = StorageJSON.load(storage_dir / "lite_test.yaml.json")
assert storage is not None
# No compile happened, so the sidecar must not claim one.
assert mock_from_core.call_args.kwargs == {"claim_build": False}
# The second run loads the cache instead of re-validating.
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
mock_read.assert_called_once()
# as_dict serialized unset paths as str(None) until 2026.9; files
# written by those wizards are still on disk.
_WIZARD_SIDECAR_CASES = pytest.mark.parametrize(
"wizard_kwargs",
[
{"esp_platform": None, "core_platform": None, "build_path": None},
{"build_path": None},
{"build_path": "None"},
],
ids=["legacy_wizard", "modern_wizard", "none_string_wizard"],
)
def _prime_core(tmp_path: Path) -> None:
"""Set the post-validation CORE state from_esphome_core reads."""
CORE.name = "lite_test"
CORE.build_path = tmp_path / "build" / "lite_test"
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp8266",
KEY_TARGET_FRAMEWORK: "arduino",
}
@_WIZARD_SIDECAR_CASES
def test_run_esphome_fallback_completes_wizard_sidecar(
tmp_path: Path, wizard_kwargs: dict[str, Any]
) -> None:
"""A wizard-written sidecar can't drive the fast path (no build_path;
older wizards also no platform fields); the fallback rewrites it from
CORE so the cache loads on the next run."""
yaml_path = _bare_yaml(tmp_path)
storage_dir = tmp_path / ".esphome" / "storage"
_write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs)
with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core):
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
mock_from_core.assert_called_once()
storage = StorageJSON.load(storage_dir / "lite_test.yaml.json")
assert storage is not None and storage.core_platform == "esp32"
# What the wizard recorded about a build (nothing, or a real one)
# carries through instead of being stamped with this run's values.
assert storage.esphome_version == "2026.1.0"
assert load_compiled_config(yaml_path) is not None
def test_run_esphome_fallback_skips_cache_when_sidecar_write_fails(
tmp_path: Path,
) -> None:
"""A failed sidecar write is non-fatal and skips the cache save too:
without the sidecar the cache could never be loaded back, so writing
it would only leave resolved secrets on disk."""
yaml_path = _bare_yaml(tmp_path)
with (
_fallback_run(side_effect=RuntimeError("boom")),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
):
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
mock_save.assert_not_called()
assert not (tmp_path / ".esphome" / "storage" / "lite_test.yaml.json").exists()
def test_run_esphome_fallback_write_failure_takes_io_branch(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""StorageJSON.save raises EsphomeError (write_file wraps OSError into
it), which must land in the plain I/O warning, not the traceback
branch for structural bugs."""
yaml_path = _bare_yaml(tmp_path)
with (
_fallback_run(return_value=_storage_fixture(tmp_path)),
patch.object(StorageJSON, "save", side_effect=EsphomeError("boom")),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
caplog.at_level("WARNING", logger="esphome.compiled_config"),
):
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
mock_save.assert_not_called()
assert "Could not refresh the storage sidecar" in caplog.text
assert "Unexpected error" not in caplog.text
def test_run_esphome_fallback_leaves_unreadable_sidecar_alone(tmp_path: Path) -> None:
"""A present-but-corrupt sidecar is not overwritten: it may hold a real
build's metadata, and replacing it would suppress the next compile's
clean of a possibly incoherent build tree. The cache save is skipped."""
yaml_path = _bare_yaml(tmp_path)
storage_dir = tmp_path / ".esphome" / "storage"
sidecar = storage_dir / "lite_test.yaml.json"
sidecar.parent.mkdir(parents=True, exist_ok=True)
sidecar.write_text("{truncated", encoding="utf-8")
with _fallback_run(return_value=None) as (_, mock_from_core):
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
mock_from_core.assert_not_called()
assert sidecar.read_text(encoding="utf-8") == "{truncated"
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
def test_run_esphome_fallback_skips_cache_when_rebuilt_sidecar_incomplete(
tmp_path: Path,
) -> None:
"""If the rebuilt sidecar would still be incomplete, nothing is written:
the cache could never be loaded back, so saving it would only rewrite
resolved secrets on every run."""
yaml_path = _bare_yaml(tmp_path)
storage_dir = tmp_path / ".esphome" / "storage"
incomplete = tmp_path / "incomplete_storage.json"
_write_storage(incomplete, build_path=None)
with _fallback_run(return_value=StorageJSON.load(incomplete)):
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
assert not (storage_dir / "lite_test.yaml.json").exists()
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
def test_run_esphome_fallback_sidecar_records_platformio_toolchain(
tmp_path: Path,
) -> None:
"""The toolchain fallback runs before the sidecar write, so platforms
whose validators leave CORE.toolchain unset record the same
"platformio" a compile writes, not null."""
yaml_path = _bare_yaml(tmp_path)
_prime_core(tmp_path)
assert CORE.toolchain is None
with _fallback_run():
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
storage = StorageJSON.load(
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json"
)
assert storage is not None
assert storage.toolchain == "platformio"
@pytest.mark.parametrize("existing_sidecar", [None, "wizard"])
def test_run_esphome_fallback_skips_sidecar_when_build_tree_exists(
tmp_path: Path, existing_sidecar: str | None
) -> None:
"""An existing build tree with a missing or wizard-only sidecar keeps
it that way: the mismatch is what makes the next compile wipe the
unknown tree, so the fallback writes nothing and skips the cache."""
yaml_path = _bare_yaml(tmp_path)
_prime_core(tmp_path)
CORE.build_path.mkdir(parents=True)
storage_dir = tmp_path / ".esphome" / "storage"
if existing_sidecar == "wizard":
_write_storage(storage_dir / "lite_test.yaml.json", build_path=None)
wizard_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8")
with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core):
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
mock_from_core.assert_not_called()
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
if existing_sidecar == "wizard":
sidecar_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8")
assert sidecar_body == wizard_body
else:
assert not (storage_dir / "lite_test.yaml.json").exists()
def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) -> None:
"""Drive the real from_esphome_core on the fallback path: the
post-validation CORE state yields a complete, loadable sidecar."""
yaml_path = _bare_yaml(tmp_path)
_prime_core(tmp_path)
CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}}
CORE.toolchain = Toolchain.PLATFORMIO
save_compiled_config_and_sidecar(CORE.config)
storage = StorageJSON.load(
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json"
)
assert storage is not None
assert storage.core_platform == "esp8266"
assert storage.build_path is not None
# No compile happened, so the sidecar must not claim one.
assert storage.esphome_version is None
assert storage.firmware_bin_path is None
assert load_compiled_config(yaml_path) is not None
@pytest.mark.parametrize("command", ["upload", "logs"])
@@ -409,6 +652,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
patch(
"esphome.compiled_config.save_compiled_config", wraps=save_compiled_config
) as mock_save,
patch.object(StorageJSON, "from_esphome_core") as mock_from_core,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{command: lambda args, config: 0},
@@ -417,6 +661,8 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
mock_save.assert_called_once_with(fresh_config)
# The compile-written sidecar is complete; the fallback leaves it alone.
mock_from_core.assert_not_called()
# mtime is now newer than the source YAML, so a follow-up call hits
# the fast path instead of repeating read_config.
assert cache.stat().st_mtime >= yaml_path.stat().st_mtime
@@ -647,24 +893,15 @@ def test_int_keys_coerce_to_strings(primed_storage: Path) -> None:
assert config["table"] == {"1": "a", "2": "b"}
def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None:
"""A wizard-only sidecar (no compile -- no core_platform / target_platform)
can't drive upload/logs, so the fast path falls back."""
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
@_WIZARD_SIDECAR_CASES
def test_load_compiled_config_rejects_wizard_only_sidecar(
tmp_path: Path, wizard_kwargs: dict[str, Any]
) -> None:
"""A wizard-written sidecar (no build_path; older wizards also no
platform fields) can't drive upload/logs, so the fast path falls back."""
yaml_path = _bare_yaml(tmp_path)
storage_dir = tmp_path / ".esphome" / "storage"
storage_dir.mkdir(parents=True, exist_ok=True)
# StorageJSON with both core_platform and target_platform unset.
(storage_dir / "lite_test.yaml.json").write_text(
'{"storage_version": 1, "name": "lite_test", "friendly_name": null, '
'"comment": null, "esphome_version": null, "src_version": 1, '
'"address": null, "web_port": null, "esp_platform": null, '
'"build_path": null, "firmware_bin_path": null, '
'"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, '
'"framework": null, "core_platform": null}'
)
_write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs)
cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json")
_set_cache_mtime(cache_path, yaml_path, offset=5)
+52
View File
@@ -0,0 +1,52 @@
"""Platform get_download_types contract for never-built configs.
Wizard-written and upload/logs-fallback sidecars record no
firmware_bin_path; the download panel must get an empty list for them,
not entries pointing at files that were never built.
"""
from __future__ import annotations
from importlib import import_module
from pathlib import Path
from typing import Any
import pytest
from esphome.storage_json import StorageJSON
PLATFORMS = ["esp32", "esp8266", "rp2", "libretiny", "nrf52"]
def _download_types(platform: str, storage: StorageJSON) -> list[dict[str, Any]]:
return import_module(f"esphome.components.{platform}").get_download_types(storage)
def _wizard_storage() -> StorageJSON:
return StorageJSON.from_wizard(
name="test_device",
friendly_name="Test Device",
address="test_device.local",
platform="ESP32",
)
@pytest.mark.parametrize("platform", PLATFORMS)
def test_no_firmware_path_yields_no_downloads(platform: str) -> None:
"""No recorded firmware path means nothing was built; no downloads."""
assert _download_types(platform, _wizard_storage()) == []
@pytest.mark.parametrize("platform", PLATFORMS)
def test_recorded_firmware_path_yields_downloads(platform: str, tmp_path: Path) -> None:
"""With a firmware path recorded, every platform offers entries in
the documented title/description/file/download shape."""
storage = _wizard_storage()
storage.firmware_bin_path = tmp_path / "firmware.bin"
types = _download_types(platform, storage)
assert types
assert all(
{"title", "description", "file", "download"} <= entry.keys() for entry in types
)
+47 -1
View File
@@ -9,6 +9,7 @@ import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import threading
from types import SimpleNamespace
@@ -431,6 +432,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None:
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
):
env = toolchain._ccache_env()
@@ -457,6 +459,44 @@ def test_ccache_env_disabled_without_binary(setup_core: Path) -> None:
assert env == {"ESPHOME_CCACHE_ENABLE": "0"}
@pytest.mark.parametrize(
"probe_error",
[
pytest.param(OSError("not runnable"), id="oserror"),
pytest.param(subprocess.CalledProcessError(1, "ccache"), id="nonzero-exit"),
pytest.param(subprocess.TimeoutExpired("ccache", 15), id="timeout"),
],
)
def test_ccache_env_disabled_when_probe_fails(
setup_core: Path, probe_error: Exception
) -> None:
"""A ccache that resolves on PATH but fails to run stays disabled."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run", side_effect=probe_error),
):
env = toolchain._ccache_env()
assert env == {"ESPHOME_CCACHE_ENABLE": "0"}
def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None:
"""An explicit ESPHOME_CCACHE_ENABLE=1 does not probe the binary."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
patch.object(toolchain.subprocess, "run") as mock_probe,
):
env = toolchain._ccache_env()
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
mock_probe.assert_not_called()
def test_ccache_env_opt_out(setup_core: Path) -> None:
"""ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present."""
CORE.build_path = setup_core / "build" / "test"
@@ -496,6 +536,7 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir(
with (
patch.dict(os.environ, user_env, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
):
env = toolchain._ccache_env()
@@ -514,6 +555,7 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only(
with (
patch.dict(os.environ, {}, clear=False),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
):
os.environ.pop("ESPHOME_CCACHE_ENABLE", None)
mock_run_external_process.return_value = 0
@@ -533,6 +575,7 @@ def test_ccache_env_requires_build_path(setup_core: Path) -> None:
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
pytest.raises(ValueError, match="CORE.build_path must be set"),
):
toolchain._ccache_env()
@@ -544,7 +587,10 @@ def test_run_platformio_cli_merges_caller_env(
"""A caller-supplied env is the base and gains the ccache settings."""
CORE.build_path = str(setup_core / "build" / "test")
with patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"):
with (
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
):
mock_run_external_process.return_value = 0
toolchain.run_platformio_cli(
"test", env={"CUSTOM_VAR": "1", "ESPHOME_CCACHE_ENABLE": "0"}
+99
View File
@@ -915,3 +915,102 @@ def test_storage_json_load_area(tmp_path: Path) -> None:
legacy = storage_json.StorageJSON.load(legacy_path)
assert legacy is not None
assert legacy.area is None
def test_from_esphome_core_without_claiming_a_build(setup_core: Path) -> None:
"""claim_build=False carries the build artifact fields from the old
sidecar while validation-derived fields still stamp from CORE."""
mock_core = MagicMock()
mock_core.name = "my_device"
mock_core.friendly_name = "My Device"
mock_core.comment = None
mock_core.address = "my_device.local"
mock_core.web_port = None
mock_core.target_platform = "esp8266"
mock_core.is_esp32 = False
mock_core.is_nrf52 = False
mock_core.build_path = "/build/my_device"
mock_core.loaded_integrations = set()
mock_core.loaded_platforms = set()
mock_core.config = {}
mock_core.target_framework = "arduino"
mock_core.toolchain = Toolchain.PLATFORMIO
mock_core.area = None
old = storage_json.StorageJSON.from_wizard(
name="my_device",
friendly_name="My Device",
address="my_device.local",
platform="ESP8266",
)
old.esphome_version = "2025.1.0"
old.firmware_bin_path = Path("/old/firmware.bin")
result = storage_json.StorageJSON.from_esphome_core(
mock_core, old, claim_build=False
)
# Build artifact fields carry from the old sidecar, not this run.
assert result.esphome_version == "2025.1.0"
assert result.firmware_bin_path == Path("/old/firmware.bin")
# Validation-derived fields stamp from CORE.
assert result.build_path == "/build/my_device"
assert result.toolchain == "platformio"
assert result.core_platform == "esp8266"
# With no old sidecar, no build is claimed at all.
bare = storage_json.StorageJSON.from_esphome_core(
mock_core, None, claim_build=False
)
assert bare.esphome_version is None
assert bare.firmware_bin_path is None
def test_load_strict_distinguishes_missing_from_unreadable(tmp_path: Path) -> None:
"""load_strict returns None only for a missing file; corrupt raises."""
assert storage_json.StorageJSON.load_strict(tmp_path / "missing.json") is None
corrupt = tmp_path / "corrupt.json"
corrupt.write_text("{truncated")
with pytest.raises(ValueError):
storage_json.StorageJSON.load_strict(corrupt)
def test_as_dict_serializes_unset_paths_as_null(setup_core: Path) -> None:
"""Unset build/firmware paths serialize as JSON null, not str(None)."""
storage = storage_json.StorageJSON.from_wizard(
name="wiz",
friendly_name="Wiz",
address="wiz.local",
platform="ESP32",
)
result = storage.as_dict()
assert result["build_path"] is None
assert result["firmware_bin_path"] is None
def test_load_treats_legacy_none_string_paths_as_unset(tmp_path: Path) -> None:
"""Sidecars written before as_dict emitted null hold str(None); those
must load as unset, not as Path("None")."""
file_path = tmp_path / "legacy_none.json"
file_path.write_text(
json.dumps(
{
"storage_version": 1,
"name": "wiz",
"friendly_name": "Wiz",
"esp_platform": "ESP32",
"core_platform": "esp32",
"build_path": "None",
"firmware_bin_path": "None",
}
)
)
result = storage_json.StorageJSON.load(file_path)
assert result is not None
assert result.build_path is None
assert result.firmware_bin_path is None