diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml new file mode 100644 index 0000000000..cd3b7207b7 --- /dev/null +++ b/.github/workflows/release-nightly.yml @@ -0,0 +1,38 @@ +--- +name: Nightly Dev Release + +# Works out the dated dev tag and starts the release workflow with it, so that +# the release run is named after the tag it builds. A workflow run name is +# fixed when the run starts and cannot read a file or the current date. + +on: + schedule: + - cron: "0 2 * * *" + +permissions: + contents: read # actions/checkout to read the version from esphome/const.py + +jobs: + trigger: + name: Start release build + if: github.repository == 'esphome/esphome' + runs-on: ubuntu-latest + permissions: + contents: read # actions/checkout to read the version from esphome/const.py + actions: write # gh workflow run starts release.yml + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Start the release workflow + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION=$(sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p" esphome/const.py) + if [[ -z "$VERSION" ]]; then + echo "::error::Could not read __version__ from esphome/const.py" + exit 1 + fi + TAG="${VERSION}$(date --utc '+%Y%m%d')" + echo "Starting release build for ${TAG}" + gh workflow run release.yml --ref "${GITHUB_REF_NAME}" --field tag="${TAG}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 839b805237..10b28ace38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,23 @@ --- name: Publish Release +# Releases (production and beta) are named after the version they publish. +# Dev builds are named after the dated dev tag, which is passed in by the +# nightly workflow because a run name cannot compute it itself. +run-name: ${{ github.event.inputs.tag || github.event.release.tag_name || format('Manual build ({0})', github.ref_name) }} + on: workflow_dispatch: + inputs: + tag: + description: >- + Tag to build. Only supported on dev, where the nightly workflow + uses it. Leave empty to build the version from esphome/const.py + with today's date appended. + required: false + default: "" release: types: [published] - schedule: - - cron: "0 2 * * *" permissions: contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write @@ -23,6 +34,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get tag id: tag + env: + INPUT_TAG: ${{ github.event.inputs.tag }} # yamllint disable rule:line-length run: | if [[ "${{ github.event_name }}" = "release" ]]; then @@ -34,12 +47,23 @@ jobs: ENVIRONMENT="production" fi else - TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p") - today="$(date --utc '+%Y%m%d')" - TAG="${TAG}${today}" BRANCH=${GITHUB_REF#refs/heads/} + # The nightly workflow passes the finished tag so that the run name + # matches what is built. Without it, work it out here. + TAG="${INPUT_TAG}" + if [[ -n "$TAG" && "$BRANCH" != "dev" ]]; then + echo "::error::The tag input is only supported on dev. A build from ${BRANCH} has to use the tag worked out here, which carries the branch name, so that it cannot publish over the dev, beta, latest or stable images." + exit 1 + fi + if [[ -z "$TAG" ]]; then + TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p") + today="$(date --utc '+%Y%m%d')" + TAG="${TAG}${today}" + if [[ "$BRANCH" != "dev" ]]; then + TAG="${TAG}-${BRANCH}" + fi + fi if [[ "$BRANCH" != "dev" ]]; then - TAG="${TAG}-${BRANCH}" BRANCH_BUILD="true" ENVIRONMENT="" else diff --git a/docker/Dockerfile b/docker/Dockerfile index c0f7222bca..a4f5d3c3a6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5 RUN \ platformio settings set enable_telemetry No \ diff --git a/esphome/__main__.py b/esphome/__main__.py index cb45dd7c5f..cc1e12cb3a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1941,7 +1941,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_name = args.name for c in new_name: if c not in ALLOWED_NAME_CHARS: - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{c}' is an invalid character for names. Valid characters are: " @@ -1954,7 +1954,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: yaml = yaml_util.load_yaml(CORE.config_path) if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]: - print( + safe_print( color( AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed." ) @@ -2001,7 +2001,9 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) > 1 ): - print(color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")) + safe_print( + color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename") + ) return 1 new_raw = re.sub( @@ -2019,7 +2021,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: # ``kitchen``; running ``esphome rename weird-file.yaml kitchen`` # would otherwise just re-flash the same hostname). if new_name == old_name: - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{new_name}' is already the device's name.", @@ -2029,7 +2031,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_path: Path = CORE.config_dir / (new_name + ".yaml") if new_path.resolve() == CORE.config_path.resolve(): - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{new_name}' is already the device's name.", @@ -2037,7 +2039,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) return 1 if new_path.exists(): - print( + safe_print( color( AnsiFore.BOLD_RED, f"Cannot rename: {new_path} already exists. " @@ -2045,7 +2047,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) ) return 1 - print( + safe_print( f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}" ) print() @@ -2054,7 +2056,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) if rc != 0: - print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) + safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) new_path.unlink() return 1 @@ -2080,7 +2082,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: if CORE.config_path != new_path: CORE.config_path.unlink() - print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) + safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) print() return 0 diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index 1bcd567b84..303af99e66 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -1,48 +1,69 @@ """Validated-config cache for the upload/logs fast path. -compile dumps the validated config to /storage/.validated.yaml; +compile dumps the validated config to /storage/.validated.json; the next upload/logs for that YAML reuses it instead of running the full -read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps -!lambda/!include/IDs/paths intact; mtime gates staleness. +read_config pipeline. The cache is deliberately lossy: only ``!lambda`` +bodies survive typed (``Lambda``); IDs, time periods, MAC/IP addresses, +paths, UUIDs and enums store the same string form the YAML dumper +produced for them. JSON additionally coerces non-str dict keys to +strings; validated configs only use string keys (every schema key +validator is ``cv.string``). mtime gates staleness. """ from __future__ import annotations +import json import logging from pathlib import Path +from typing import Any -from esphome.core import CORE +from esphome.const import __version__ as ESPHOME_VERSION +from esphome.core import CORE, Lambda from esphome.helpers import write_file from esphome.storage_json import StorageJSON, ext_storage_path from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) +# Bump when the on-disk shape changes; a mismatched version falls back +# to read_config. The envelope also stamps the writing esphome version: +# after an upgrade the cache holds the previous release's validation, so +# it falls back once and the re-save self-heals. +_CACHE_VERSION = 1 +_LAMBDA_KEY = "__esphome_lambda__" + def compiled_config_path(config_filename: str) -> Path: """Path to the cached validated config alongside the storage sidecar.""" - return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" - - -def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool: - """True iff the cache file exists and isn't older than the source.""" - try: - return cache_path.stat().st_mtime >= source_path.stat().st_mtime - except OSError: - return False + return CORE.data_dir / "storage" / f"{config_filename}.validated.json" def save_compiled_config(config: ConfigType) -> None: """Write the validated-config cache. Always-write so mtime stays fresh. - Mode 0600 because show_secrets=True resolves !secret inline. + Mode 0600 because config validation resolved !secret inline. Failures are non-fatal: the fast path falls back to read_config. """ - from esphome import yaml_util - try: - rendered = yaml_util.dump(config, show_secrets=True) + # The legacy YAML cache holds inline-resolved secrets and nothing + # reads it anymore; drop it even when the write below fails. A + # failed removal leaves resolved secrets on disk, so it warns. + try: + _legacy_compiled_config_path(CORE.config_filename).unlink(missing_ok=True) + except OSError as err: + _LOGGER.warning( + "Could not remove the legacy validated-config cache: %s", err + ) + rendered = json.dumps( + {"v": _CACHE_VERSION, "esphome": ESPHOME_VERSION, "config": config}, + separators=(",", ":"), + default=_json_default, + ) write_file(compiled_config_path(CORE.config_filename), rendered, private=True) + except TypeError as err: + # Structural, not transient: this config can never cache (e.g. a + # 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) @@ -51,25 +72,29 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: """Load the cached validated config and apply storage metadata to CORE. Returns None (caller falls back to read_config) when the cache is - missing, older than the source YAML, unparseable, or the sidecar - is incomplete. + missing, older than the source YAML, unparseable, a different cache + version, or the sidecar is incomplete. The loaded config carries no + source ranges; callers must not feed it into read_config/write_cpp. """ cache_path = compiled_config_path(conf_path.name) if not _cache_is_fresh(cache_path, conf_path): return None - from esphome import yaml_util - try: - # Fast path never validates or generates code - no source ranges - # needed (see load_yaml). Callers must not feed this config into - # read_config/write_cpp: the esp_range consumers in config.py and - # cpp_generator.py are isinstance-guarded and would degrade - # silently (wrong error/lambda locations) instead of raising. - config = yaml_util.load_yaml( - cache_path, clear_secrets=False, track_document_range=False + envelope = json.loads( + cache_path.read_text(encoding="utf-8"), object_hook=_decode_object ) - except Exception: # noqa: BLE001 # pylint: disable=broad-except + except (OSError, ValueError) as err: + _LOGGER.debug("Ignoring unreadable compiled config cache: %s", err) + return None + + if ( + not isinstance(envelope, dict) + or envelope.get("v") != _CACHE_VERSION + or envelope.get("esphome") != ESPHOME_VERSION + or not isinstance(config := envelope.get("config"), dict) + ): + _LOGGER.debug("Ignoring compiled config cache with a foreign envelope") return None storage = StorageJSON.load(ext_storage_path(conf_path.name)) @@ -81,3 +106,38 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: return None storage.apply_to_core() return config + + +# Remove before 2027.8: by then every maintained install has saved the +# JSON cache at least once and dropped its legacy YAML file. +def _legacy_compiled_config_path(config_filename: str) -> Path: + """Path of the pre-JSON YAML cache; only ever removed.""" + return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" + + +def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool: + """True iff the cache file exists and isn't older than the source.""" + try: + return cache_path.stat().st_mtime >= source_path.stat().st_mtime + except OSError: + return False + + +def _json_default(value: Any) -> Any: + """Mirror ESPHomeDumper's representers: Lambda stays typed, the rest + stringify (IDs, time periods, MAC/IP addresses, paths, UUIDs, enums). + + IncludeFile/Extend/Remove have no JSON mirror and would stringify + wrong, but none survive validation (config.py's packages merge and + the substitution pass consume them) so no guard is spent on them. + """ + if isinstance(value, Lambda): + return {_LAMBDA_KEY: value.value} + return str(value) + + +def _decode_object(obj: dict[str, Any]) -> Any: + """Revive the Lambda sentinel; every other mapping passes through.""" + if len(obj) == 1 and isinstance(value := obj.get(_LAMBDA_KEY), str): + return Lambda(value) + return obj diff --git a/esphome/components/ble_device_base/scan_response_merger.cpp b/esphome/components/ble_device_base/scan_response_merger.cpp new file mode 100644 index 0000000000..15445cee02 --- /dev/null +++ b/esphome/components/ble_device_base/scan_response_merger.cpp @@ -0,0 +1,150 @@ +#include "scan_response_merger.h" + +#ifdef USE_BLE_SCAN_RESPONSE_MERGER + +#include + +namespace esphome::ble_device_base { + +void ScanResponseMerger::deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len, bool raw_only) { + if (this->dispatcher_ == nullptr) + return; + this->dispatcher_->dispatch(mac, rssi, addr_type, data, data_len, raw_only, + *this->scan_continuous_ ? nullptr : this->log_tag_); +} + +void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len, uint32_t now) { + // One pass: find a same-device entry (deliver + reuse) while remembering the + // first free slot as the fallback. + PendingAdv *slot = nullptr; + PendingAdv *free_slot = nullptr; + for (auto &p : this->pending_adv_) { + if (!p.used) { + if (free_slot == nullptr) + free_slot = &p; + continue; + } + if (p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + // Same device advertised again before its scan response arrived — deliver + // the previous advertisement (its scan response is not coming) and reuse + // the slot, so no frame is ever lost. + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + slot = &p; + break; + } + } + if (slot == nullptr) + slot = free_slot; + if (slot == nullptr) { + // Table full — degrade gracefully: deliver the advertisement unmerged. + this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/false); + return; + } + slot->used = true; + this->pending_count_++; + memcpy(slot->mac, mac, 6); + slot->addr_type = addr_type; + slot->rssi = rssi; + slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data); + memcpy(slot->data, data, slot->data_len); + slot->stored_ms = now; +} + +void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len) { + // Fast-out on the empty table (sweep/flush use the same guard); this is the + // hottest caller. + if (this->pending_count_ != 0) { + for (auto &p : this->pending_adv_) { + if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + // Append in place: the slot is released on delivery, so its 62-byte + // buffer (legacy adv + scan response) holds the merged frame directly. + const uint8_t room = sizeof(p.data) - p.data_len; + const uint8_t add = (data_len <= room) ? data_len : room; + memcpy(p.data + p.data_len, data, add); + p.used = false; + this->pending_count_--; + // The advertisement's RSSI, not the scan response's (header contract). + this->deliver_(mac, p.rssi, addr_type, p.data, p.data_len + add, /*raw_only=*/false); + return; + } + } + } + // Unmatched scan-response: goes out on the raw callback only (HA merges per + // address); local listeners/triggers receive each advertisement exactly once + // via the merged/plain path above. + this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/true); +} + +void ScanResponseMerger::sweep(uint32_t now) { + if (this->pending_count_ == 0) + return; + for (auto &p : this->pending_adv_) { + if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) { + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + } + } +} + +void ScanResponseMerger::flush() { + if (this->pending_count_ == 0) + return; + for (auto &p : this->pending_adv_) { + if (p.used) { + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + } + } +} + +void AdvDispatcher::dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only, const char *log_unclaimed_tag) { + // Raw callback (the raw-advertisement path). Both full advertisements and + // unmatched scan responses (raw_only) are forwarded. + if (this->raw_callback_.is_set()) { + const RawAdvertisement adv{.address = mac_lsb_first_to_uint64(mac), + .data = data, + .data_len = data_len, + .rssi = rssi, + .addr_type = addr_type}; + this->raw_callback_.invoke(adv); + } + +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Scan-response-only frames are never parsed for local sensors/triggers. + if (raw_only) + return; + ESPBTDevice device; + device.from_scan_result(mac, rssi, addr_type, data, data_len); + // The listener list holds sensors AND the tracker's automation triggers + // (the triggers are listeners, exactly like esp32_ble_tracker), so one + // loop feeds both and ORs into `found`. + bool found = false; + for (auto *listener : this->listeners_) { + if (listener->parse_device(device)) { + found = true; + } + } + if (!found && log_unclaimed_tag != nullptr) + this->discovered_log_.log_device(log_unclaimed_tag, device); +#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT +} + +void AdvDispatcher::on_scan_end() { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->listeners_) + listener->on_scan_end(); + this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) +#endif +} + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/components/ble_device_base/scan_response_merger.h b/esphome/components/ble_device_base/scan_response_merger.h new file mode 100644 index 0000000000..9415664fcf --- /dev/null +++ b/esphome/components/ble_device_base/scan_response_merger.h @@ -0,0 +1,152 @@ +// Shared support for trackers whose controller delivers advertisement and +// scan response as SEPARATE reports (ln882h, rp2, bk72xx; ESP-IDF concatenates +// both into one result before ESPHome sees it): +// +// ScanResponseMerger — Bluedroid-style merge: a scannable advertisement is +// held briefly, its scan response is appended on arrival and the pair is +// delivered as ONE merged frame. Merged delivery is what the receiving side +// is built around: Home Assistant keeps the latest raw frame per device and +// skips re-parsing when it is unchanged — split delivery alternates two raw +// frames per device and defeats both. +// +// AdvDispatcher — the delivery half every such tracker repeats: raw +// callback, listener parsing, discovered-device log. Trackers delegate +// their BLEHub register_listener / set_raw_advertisement_callback here. +// +// The merger delivers straight into the tracker's AdvDispatcher — bind() wires +// the pair once in setup(). Single-task use only (every tracker calls this on +// the ESPHome main task). The clock is caller-provided: pass the same clock to +// stash_adv() and sweep() (millis() or App.get_loop_component_start_time(), +// never mixed). + +#pragma once + +#include "esphome/core/defines.h" + +// Emitted (cg.add_define) by each tracker that adopts the merger, so builds +// whose tracker merges in-stack (esp32) never compile this code. +#ifdef USE_BLE_SCAN_RESPONSE_MERGER + +#include "ble_device.h" +#include "ble_hub.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::ble_device_base { + +/// The delivery half of a split-report tracker, shared so the dispatch +/// contract (raw-callback ordering, raw_only gate, discovered-log policy) +/// lives in one place. Owns the members every tracker otherwise duplicates; +/// the tracker's BLEHub methods delegate here. +class AdvDispatcher { + public: + void register_listener(ESPBTDeviceListener *listener) { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + this->listeners_.push_back(listener); +#endif + } + void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->raw_callback_ = callback; } + /// Dispatch one (possibly merged) advertisement: the raw callback, and — + /// unless raw_only — parsing for listeners/triggers. raw_only marks + /// unmatched scan-response frames: forwarded on the raw callback only, never + /// parsed for local sensors/triggers (Home Assistant merges per address). + /// log_unclaimed_tag: when non-null, a device no listener claimed is logged + /// under this tag (esp32_ble_tracker parity: pass the tracker TAG on + /// one-shot scans, nullptr on continuous scans, which would spam). + void dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only, const char *log_unclaimed_tag); + /// Fire listeners' on_scan_end and reset the per-scan discovered-log dedup. + void on_scan_end(); + + protected: + RawAdvertisementCallback raw_callback_{}; +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Parsed-advertisement consumers registered through ble_device_base. + // Codegen-sized: no heap allocation, no std::vector template instantiations. + StaticVector listeners_; + // Per-period "Found device" DEBUG log with MAC dedup. Guarded like its only + // writer so a no-listener build does not carry an unused vector. + DiscoveredDeviceLog discovered_log_{}; +#endif +}; + +class ScanResponseMerger { + public: + /// Wire the merger's output; call once in the tracker's setup(). Every + /// delivered frame goes to dispatcher->dispatch(); scan_continuous is read + /// at each delivery (runtime continuous flips are honored) to decide the + /// unclaimed-device log tag, so both pointers must outlive the merger — + /// tracker members always do. + void bind(AdvDispatcher *dispatcher, const bool *scan_continuous, const char *log_tag) { + this->dispatcher_ = dispatcher; + this->scan_continuous_ = scan_continuous; + this->log_tag_ = log_tag; + } + /// Hold a scannable advertisement, waiting for its scan response. The + /// tracker calls this only when it wants the merge (scannable advertisement + /// while an active scan runs) and delivers everything else directly. A + /// same-device re-advertisement delivers the held frame (its scan response + /// is not coming) and reuses the slot; a full table degrades gracefully to + /// unmerged delivery. + void stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + uint32_t now); + /// A scan response arrived: append it to the held advertisement from the + /// same device and deliver the pair as one frame. The merged frame reports + /// the ADVERTISEMENT's RSSI — every unmerged path reports the + /// advertisement's measurement, so a device's RSSI must not jump between two + /// measurements depending on merge timing. Unmatched responses are delivered + /// raw_only. + void submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len); + /// Timeout flush (call from loop() with the stash_adv() clock): deliver + /// held advertisements whose scan response never arrived (device didn't + /// answer / frame lost) — unmerged, past PENDING_ADV_TIMEOUT_MS. + void sweep(uint32_t now); + /// Deliver every held advertisement now (scan period/scan is ending, before + /// on_scan_end fires): unmerged delivery, same as the timeout path. + void flush(); + /// Lets loop() skip the cross-TU sweep() call in the common case (empty: + /// passive scan, or every pair already matched). + bool empty() const { return this->pending_count_ == 0; } + + private: + /// All delivery funnels through here: an unbound merger (bind() not called) + /// drops the frame instead of jumping through a null pointer, mirroring the + /// guard-before-invoke convention of the ble_hub.h callback slots. + void deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only); + + // 62 bytes = legacy adv (31) + scan response (31), the same merged maximum + // as ESP-IDF delivers on ESP32. + struct PendingAdv { + bool used{false}; + uint8_t mac[6]; + uint8_t addr_type; + int8_t rssi; + uint8_t data_len; // <= sizeof(data) + uint8_t data[62]; + uint32_t stored_ms; + }; + // Sized for the unanswered case: a pair that IS answered normally matches + // within one report-queue drain, so a slot is held for the full timeout only + // by scannable devices that never reply. 8 concurrent such advertisers + // before the merge degrades (frames still delivered, just unmerged) at + // ~80 B each. + static constexpr size_t MAX_PENDING_ADV = 8; + // On air a scan response follows its advertisement by T_IFS (150 µs) — the + // timeout only covers HOST-side report queuing under WiFi/BLE coexistence, + // measured on-device (ln882h) at up to ~136 ms. 300 ms = >2x that margin, + // while staying below any device's re-advertising period. + static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300; + AdvDispatcher *dispatcher_{nullptr}; + const bool *scan_continuous_{nullptr}; // read at delivery; see bind() + const char *log_tag_{nullptr}; + // pending_count_ mirrors the number of set `used` flags; both are updated + // together on every transition. + PendingAdv pending_adv_[MAX_PENDING_ADV]; + uint8_t pending_count_{0}; +}; + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 000db307b9..31559a514c 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -12,6 +12,7 @@ from esphome.components.packages import validate_source_shorthand import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI import esphome.final_validate as fv +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.yaml_util import dump dashboard_import_ns = cg.esphome_ns.namespace("dashboard_import") @@ -109,6 +110,7 @@ def import_config( if git_file.query and "full_config" in git_file.query: url = git_file.raw_url try: + ensure_happy_eyeballs() req = requests.get(url, timeout=30) req.raise_for_status() except requests.exceptions.RequestException as e: diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d16e8ae03c..2e72c78974 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3286,9 +3286,19 @@ def copy_files(): if str(path).startswith("http"): import requests + from esphome.happy_eyeballs import ensure_happy_eyeballs + + ensure_happy_eyeballs() + + try: + req = requests.get(path, timeout=30) + req.raise_for_status() + except requests.exceptions.RequestException as e: + raise EsphomeError( + f"Could not download extra build file {path}: {e}" + ) from e CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True) - content = requests.get(path, timeout=30).content - CORE.relative_build_path(name).write_bytes(content) + CORE.relative_build_path(name).write_bytes(req.content) else: copy_file_if_changed(path, CORE.relative_build_path(name)) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 7510f2f8b6..5872b607f1 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -36,6 +36,7 @@ from esphome.const import ( CONF_WEIGHT, ) from esphome.core import CORE, HexInt +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -319,6 +320,7 @@ def download_gfont(value): if not external_files.is_file_recent(path, value[CONF_REFRESH]): _LOGGER.debug("download_gfont: path=%s", path) try: + ensure_happy_eyeballs() req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) req.raise_for_status() except requests.exceptions.RequestException as e: diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index af074fc7ea..99f2ead3bb 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_WAKEUP_PIN +from esphome.types import ConfigType from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE @@ -14,7 +15,7 @@ ld6002b_ns = cg.esphome_ns.namespace("ld6002b") LD6002BComponent = ld6002b_ns.class_("LD6002BComponent", cg.Component, uart.UARTDevice) -def _validate_wakeup_options(config): +def _validate_wakeup_options(config: ConfigType) -> ConfigType: """Reject wake options that would silently do nothing. Runs before the schema so the defaults for the keys below have not been diff --git a/esphome/components/ld6002b/binary_sensor.py b/esphome/components/ld6002b/binary_sensor.py index 319ace6f5d..63f7b40c23 100644 --- a/esphome/components/ld6002b/binary_sensor.py +++ b/esphome/components/ld6002b/binary_sensor.py @@ -4,24 +4,35 @@ import esphome.config_validation as cv from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY from . import LD6002BComponent -from .const import CONF_LD6002B_ID, MAX_TARGETS +from .const import AREA_COUNT, CONF_LD6002B_ID, MAX_TARGETS DEPENDENCIES = ["ld6002b"] -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_OCCUPANCY, - ), - } -).extend( - { - cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_OCCUPANCY, - ) - for i in range(MAX_TARGETS) - } +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ), + } + ) + .extend( + { + cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ) + for i in range(MAX_TARGETS) + } + ) + .extend( + { + cv.Optional(f"detection_area_{i}"): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ) + for i in range(AREA_COUNT) + } + ) ) @@ -36,3 +47,8 @@ async def to_code(config): if target_config := config.get(f"target_{i + 1}"): sens = await binary_sensor.new_binary_sensor(target_config) cg.add(hub.set_target_presence_binary_sensor(i, sens)) + + for i in range(AREA_COUNT): + if area_config := config.get(f"detection_area_{i}"): + sens = await binary_sensor.new_binary_sensor(area_config) + cg.add(hub.set_area_presence_binary_sensor(i, sens)) diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py index 0046131b62..c327c331c6 100644 --- a/esphome/components/ld6002b/button/__init__.py +++ b/esphome/components/ld6002b/button/__init__.py @@ -2,15 +2,21 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import ( + CONF_AREA_ID, CONF_ID, CONF_WAKEUP_PIN, ENTITY_CATEGORY_CONFIG, ENTITY_CATEGORY_DIAGNOSTIC, ) import esphome.final_validate as fv +from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns from ..const import ( + CONF_APPLY_AREA, + CONF_AUTO_INTERFERENCE, + CONF_CLEAR_INTERFERENCE, + CONF_GET_AREAS, CONF_GET_DELAY, CONF_GET_INSTALLATION, CONF_GET_LOW_POWER_MODE, @@ -19,6 +25,7 @@ from ..const import ( CONF_GET_TRIGGER_SPEED, CONF_GET_Z_RANGE, CONF_LD6002B_ID, + CONF_RESET_DETECTION_AREA, CONF_RESET_UNATTENDED, CONF_WAKE, ) @@ -31,6 +38,21 @@ ButtonType = ld6002b_ns.enum("ButtonType", is_class=True) CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_APPLY_AREA): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_AUTO_INTERFERENCE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_GET_AREAS): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_CLEAR_INTERFERENCE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_RESET_DETECTION_AREA): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), cv.Optional(CONF_GET_DELAY): button.button_schema( LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ), @@ -62,10 +84,21 @@ CONFIG_SCHEMA = cv.Schema( ) -def final_validate(config): +def final_validate(config: ConfigType) -> ConfigType: full_config = fv.full_config.get() hub_id = config[CONF_LD6002B_ID] + if config.get(CONF_APPLY_AREA): + has_area_id_select = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None + for entry in full_config.get("select", []) + ) + if not has_area_id_select: + raise cv.Invalid( + f"{CONF_APPLY_AREA} requires select.area_id for the same ld6002b instance", + path=[CONF_APPLY_AREA], + ) + if config.get(CONF_WAKE): hub_path = full_config.get_path_for_id(hub_id) hub_config = full_config.get_config_for_path(hub_path[:-1]) @@ -81,6 +114,11 @@ def final_validate(config): FINAL_VALIDATE_SCHEMA = final_validate BUTTON_MAP = { + CONF_APPLY_AREA: ButtonType.APPLY_AREA, + CONF_AUTO_INTERFERENCE: ButtonType.AUTO_INTERFERENCE, + CONF_GET_AREAS: ButtonType.GET_AREAS, + CONF_CLEAR_INTERFERENCE: ButtonType.CLEAR_INTERFERENCE, + CONF_RESET_DETECTION_AREA: ButtonType.RESET_DETECTION_AREA, CONF_GET_DELAY: ButtonType.GET_DELAY, CONF_GET_SENSITIVITY: ButtonType.GET_SENSITIVITY, CONF_GET_TRIGGER_SPEED: ButtonType.GET_TRIGGER_SPEED, diff --git a/esphome/components/ld6002b/const.py b/esphome/components/ld6002b/const.py index fac9f08015..b7c3f54a6f 100644 --- a/esphome/components/ld6002b/const.py +++ b/esphome/components/ld6002b/const.py @@ -1,6 +1,11 @@ +CONF_APPLY_AREA = "apply_area" +CONF_AREA_CONFIG = "area_config" +CONF_AUTO_INTERFERENCE = "auto_interference" CONF_AUTO_WAKE = "auto_wake" +CONF_CLEAR_INTERFERENCE = "clear_interference" CONF_CLUSTER_ID = "cluster_id" CONF_DOPPLER_INDEX = "doppler_index" +CONF_GET_AREAS = "get_areas" CONF_GET_DELAY = "get_delay" CONF_GET_INSTALLATION = "get_installation" CONF_GET_LOW_POWER_MODE = "get_low_power_mode" @@ -16,6 +21,7 @@ CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time" CONF_OTA_VERSION = "ota_version" CONF_POINT_CLOUD = "point_cloud" CONF_POINT_COUNT = "point_count" +CONF_RESET_DETECTION_AREA = "reset_detection_area" CONF_RESET_UNATTENDED = "reset_unattended" CONF_TARGET_DISPLAY = "target_display" CONF_TRIGGER_SPEED = "trigger_speed" @@ -26,4 +32,10 @@ CONF_Z = "z" CONF_Z_MAX = "z_max" CONF_Z_MIN = "z_min" +KEY_X_MIN = "x_min" +KEY_X_MAX = "x_max" +KEY_Y_MIN = "y_min" +KEY_Y_MAX = "y_max" + +AREA_COUNT = 4 MAX_TARGETS = 3 diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 25b3da174c..ca6b9b9552 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -15,12 +15,16 @@ static constexpr uint32_t SETUP_DELAY_MS = 100; // Command/message types static constexpr uint16_t TYPE_CONTROL = 0x0201; +static constexpr uint16_t TYPE_SET_AREA = 0x0202; static constexpr uint16_t TYPE_SET_HOLD_DELAY = 0x0203; static constexpr uint16_t TYPE_SET_Z_RANGE = 0x0204; static constexpr uint16_t TYPE_SET_LOW_POWER_SLEEP = 0x0205; static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08; +static constexpr uint16_t TYPE_REPORT_AREA_PRESENCE = 0x0A0A; +static constexpr uint16_t TYPE_REPORT_INTERFERENCE_AREAS = 0x0A0B; +static constexpr uint16_t TYPE_REPORT_DETECTION_AREAS = 0x0A0C; static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D; static constexpr uint16_t TYPE_REPORT_SENSITIVITY = 0x0A0E; static constexpr uint16_t TYPE_REPORT_TRIGGER = 0x0A0F; @@ -32,6 +36,10 @@ static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A14; static constexpr uint16_t TYPE_QUERY_VERSION = 0xFFFF; // Control command values for TYPE_CONTROL +static constexpr uint32_t CMD_AUTO_INTERFERENCE = 0x01; +static constexpr uint32_t CMD_GET_AREAS = 0x02; +static constexpr uint32_t CMD_CLEAR_INTERFERENCE = 0x03; +static constexpr uint32_t CMD_RESET_DETECTION_AREA = 0x04; static constexpr uint32_t CMD_GET_DELAY = 0x05; static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06; static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; @@ -55,13 +63,26 @@ static constexpr uint32_t CMD_GET_LOW_POWER = 0x18; static constexpr uint32_t CMD_GET_LOW_POWER_SLEEP = 0x19; static constexpr uint32_t CMD_RESET_UNATTENDED = 0x1A; -static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id +static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id +static constexpr uint16_t AREA_DATA_LEN = 24; // 6 floats +static constexpr uint16_t AREA_CONFIG_LEN = 28; // int32 + 6 floats +static constexpr uint16_t AREA_PRESENCE_ENTRY_LEN = 4; // uint32 per detection area + +static constexpr uint8_t AREA_ID_DEFAULT = 4; // detection_area_0 for initial display static constexpr uint8_t VERSION_QUERY_DATA[] = {0x01, 0x01, 0x00, 0x00}; #ifdef ESPHOME_LOG_HAS_VERBOSE static const char *control_command_name(uint32_t command) { switch (command) { + case CMD_AUTO_INTERFERENCE: + return "auto_interference"; + case CMD_GET_AREAS: + return "get_areas"; + case CMD_CLEAR_INTERFERENCE: + return "clear_interference"; + case CMD_RESET_DETECTION_AREA: + return "reset_detection_area"; case CMD_GET_DELAY: return "get_delay"; case CMD_POINT_CLOUD_ON: @@ -115,6 +136,8 @@ static const char *frame_type_name(uint16_t type) { switch (type) { case TYPE_CONTROL: return "control"; + case TYPE_SET_AREA: + return "set_area"; case TYPE_SET_HOLD_DELAY: return "set_hold_delay"; case TYPE_SET_Z_RANGE: @@ -125,6 +148,12 @@ static const char *frame_type_name(uint16_t type) { return "report_target"; case TYPE_REPORT_POINT_CLOUD: return "report_point_cloud"; + case TYPE_REPORT_AREA_PRESENCE: + return "report_area_presence"; + case TYPE_REPORT_INTERFERENCE_AREAS: + return "report_interference_areas"; + case TYPE_REPORT_DETECTION_AREAS: + return "report_detection_areas"; case TYPE_REPORT_DELAY: return "report_delay"; case TYPE_REPORT_SENSITIVITY: @@ -150,6 +179,8 @@ static const char *frame_type_name(uint16_t type) { static bool is_expected_control_report(uint32_t command, uint16_t type) { switch (command) { + case CMD_GET_AREAS: + return type == TYPE_REPORT_INTERFERENCE_AREAS || type == TYPE_REPORT_DETECTION_AREAS; case CMD_GET_DELAY: return type == TYPE_REPORT_DELAY; case CMD_GET_SENSITIVITY: @@ -200,6 +231,10 @@ void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) { data[3] = (value >> 24) & 0xFF; } +void LD6002BComponent::write_int32_le(uint8_t *data, int32_t value) { + write_u32_le(data, static_cast(value)); +} + void LD6002BComponent::write_f32_le(uint8_t *data, float value) { uint32_t raw; std::memcpy(&raw, &value, sizeof(raw)); @@ -356,6 +391,37 @@ void LD6002BComponent::setup() { this->send_control_command_(CMD_GET_LOW_POWER); } + bool want_area_report = false; +#ifdef USE_SENSOR + for (const auto &area : this->interference_areas_) { + if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr || + area.z_min != nullptr || area.z_max != nullptr) { + want_area_report = true; + break; + } + } + if (!want_area_report) { + for (const auto &area : this->detection_areas_) { + if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr || + area.z_min != nullptr || area.z_max != nullptr) { + want_area_report = true; + break; + } + } + } +#endif +#ifdef USE_NUMBER + if (this->area_x_min_number_ != nullptr || this->area_x_max_number_ != nullptr || + this->area_y_min_number_ != nullptr || this->area_y_max_number_ != nullptr || + this->area_z_min_number_ != nullptr || this->area_z_max_number_ != nullptr) { + want_area_report = true; + } +#endif + if (want_area_report) { + this->send_control_command_(CMD_GET_AREAS); + } + + this->init_area_id_pref_(); this->init_version_pref_(); #ifdef USE_TEXT_SENSOR @@ -374,7 +440,7 @@ void LD6002BComponent::dump_config() { this->auto_wake_ ? "true" : "false", static_cast(this->max_data_len_)); if (this->wakeup_pin_ != nullptr) { LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_); - ESP_LOGCONFIG(TAG, " Wake Pulse: %ums", this->wakeup_pulse_ms_); + ESP_LOGCONFIG(TAG, " Wake Pulse: %" PRIu32 "ms", this->wakeup_pulse_ms_); } #ifdef USE_SENSOR LOG_SENSOR(" ", "Target Count", this->target_count_sensor_); @@ -386,12 +452,31 @@ void LD6002BComponent::dump_config() { LOG_SENSOR(" ", "Target Doppler Index", target.dop_idx); LOG_SENSOR(" ", "Target Cluster ID", target.cluster_id); } + for (auto &area : this->interference_areas_) { + LOG_SENSOR(" ", "Interference Area X Min", area.x_min); + LOG_SENSOR(" ", "Interference Area X Max", area.x_max); + LOG_SENSOR(" ", "Interference Area Y Min", area.y_min); + LOG_SENSOR(" ", "Interference Area Y Max", area.y_max); + LOG_SENSOR(" ", "Interference Area Z Min", area.z_min); + LOG_SENSOR(" ", "Interference Area Z Max", area.z_max); + } + for (auto &area : this->detection_areas_) { + LOG_SENSOR(" ", "Detection Area X Min", area.x_min); + LOG_SENSOR(" ", "Detection Area X Max", area.x_max); + LOG_SENSOR(" ", "Detection Area Y Min", area.y_min); + LOG_SENSOR(" ", "Detection Area Y Max", area.y_max); + LOG_SENSOR(" ", "Detection Area Z Min", area.z_min); + LOG_SENSOR(" ", "Detection Area Z Max", area.z_max); + } #endif #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Presence", this->presence_binary_sensor_); for (uint8_t i = 0; i < MAX_TARGETS; i++) { LOG_BINARY_SENSOR(" ", "Target Presence", this->target_presence_[i]); } + for (uint8_t i = 0; i < AREA_COUNT; i++) { + LOG_BINARY_SENSOR(" ", "Detection Area Presence", this->area_presence_[i]); + } #endif #ifdef USE_TEXT_SENSOR LOG_TEXT_SENSOR(" ", "Work Mode", this->work_mode_text_sensor_); @@ -402,6 +487,12 @@ void LD6002BComponent::dump_config() { LOG_NUMBER(" ", "Z Min", this->z_min_number_); LOG_NUMBER(" ", "Z Max", this->z_max_number_); LOG_NUMBER(" ", "Low Power Sleep", this->low_power_sleep_number_); + LOG_NUMBER(" ", "Area X Min", this->area_x_min_number_); + LOG_NUMBER(" ", "Area X Max", this->area_x_max_number_); + LOG_NUMBER(" ", "Area Y Min", this->area_y_min_number_); + LOG_NUMBER(" ", "Area Y Max", this->area_y_max_number_); + LOG_NUMBER(" ", "Area Z Min", this->area_z_min_number_); + LOG_NUMBER(" ", "Area Z Max", this->area_z_max_number_); #endif #ifdef USE_SWITCH LOG_SWITCH(" ", "Low Power", this->low_power_switch_); @@ -412,6 +503,7 @@ void LD6002BComponent::dump_config() { LOG_SELECT(" ", "Sensitivity", this->sensitivity_select_); LOG_SELECT(" ", "Trigger Speed", this->trigger_speed_select_); LOG_SELECT(" ", "Installation Mode", this->installation_select_); + LOG_SELECT(" ", "Area ID", this->area_id_select_); #endif } @@ -527,6 +619,7 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ } if (len == 0 && this->command_active_ && this->command_sent_ && type == this->active_command_.type) { ESP_LOGV(TAG, "ACK for command 0x%04X (module frame 0x%04X)", type, this->frame_id_); + const bool refresh_areas = (type == TYPE_SET_AREA) && this->area_write_in_flight_; // This settles one expected reply; the rest stay owed and become the debt for the next command. this->send_generation_++; this->stale_ack_type_ = type; @@ -536,6 +629,10 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ this->command_sent_ = false; this->last_send_ms_ = 0; this->process_command_queue_(); + if (refresh_areas) { + this->area_write_in_flight_ = false; + this->set_timeout(AREA_REFRESH_TIMEOUT, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); }); + } return; } @@ -557,6 +654,15 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ case TYPE_REPORT_POINT_CLOUD: this->handle_point_cloud_(data, len); break; + case TYPE_REPORT_AREA_PRESENCE: + this->handle_area_presence_(data, len); + break; + case TYPE_REPORT_INTERFERENCE_AREAS: + this->handle_area_report_(true, data, len); + break; + case TYPE_REPORT_DETECTION_AREAS: + this->handle_area_report_(false, data, len); + break; case TYPE_REPORT_DELAY: this->handle_delay_report_(data, len); break; @@ -654,8 +760,9 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) this->target_presence_any_ = (reported > 0); #ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; if (this->presence_binary_sensor_ != nullptr) { - this->presence_binary_sensor_->publish_state(this->target_presence_any_); + this->presence_binary_sensor_->publish_state(presence); } #endif this->update_work_mode_fallback_(); @@ -728,6 +835,84 @@ void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) { #endif } +// 0x0A0A carries one uint32 per detection area -- the protocol names the four +// fields detection_state_area0..3 -- so this covers area ids 4..7 only. The +// interference areas have no presence report: a target inside one is what they +// exist to suppress. +void LD6002BComponent::handle_area_presence_(const uint8_t *data, uint16_t len) { + const uint16_t needed = AREA_COUNT * AREA_PRESENCE_ENTRY_LEN; + if (len < needed) + return; + + this->area_presence_any_ = false; + for (uint8_t i = 0; i < AREA_COUNT; i++) { + uint32_t state = read_u32_le(data + (i * AREA_PRESENCE_ENTRY_LEN)); + bool present = state != 0; + this->area_presence_any_ = this->area_presence_any_ || present; +#ifdef USE_BINARY_SENSOR + if (this->area_presence_[i] != nullptr) { + this->area_presence_[i]->publish_state(present); + } +#endif + } + +#ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif + this->update_work_mode_fallback_(); +} + +void LD6002BComponent::handle_area_report_(bool interference, const uint8_t *data, uint16_t len) { + uint16_t needed = AREA_COUNT * AREA_DATA_LEN; + if (len < needed) + return; + + for (uint8_t i = 0; i < AREA_COUNT; i++) { + uint16_t offset = i * AREA_DATA_LEN; + float x_min = read_f32_le(data + offset + 0); + float x_max = read_f32_le(data + offset + 4); + float y_min = read_f32_le(data + offset + 8); + float y_max = read_f32_le(data + offset + 12); + float z_min = read_f32_le(data + offset + 16); + float z_max = read_f32_le(data + offset + 20); + +#ifdef USE_SENSOR + AreaSensors &area = interference ? this->interference_areas_[i] : this->detection_areas_[i]; + if (area.x_min != nullptr) + area.x_min->publish_state(x_min); + if (area.x_max != nullptr) + area.x_max->publish_state(x_max); + if (area.y_min != nullptr) + area.y_min->publish_state(y_min); + if (area.y_max != nullptr) + area.y_max->publish_state(y_max); + if (area.z_min != nullptr) + area.z_min->publish_state(z_min); + if (area.z_max != nullptr) + area.z_max->publish_state(z_max); +#endif + + AreaConfig &store = interference ? this->interference_area_values_[i] : this->detection_area_values_[i]; + store.x_min = x_min; + store.x_max = x_max; + store.y_min = y_min; + store.y_max = y_max; + store.z_min = z_min; + store.z_max = z_max; + + uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + bool selected_interference = selected_id < AREA_COUNT; + uint8_t selected_index = selected_interference ? selected_id : static_cast(selected_id - AREA_COUNT); + if (selected_interference == interference && selected_index == i) { + this->update_area_numbers_(store); + } + } + this->try_apply_pending_area_(interference); +} + void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { if (len < 4) return; @@ -815,13 +1000,24 @@ void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint1 void LD6002BComponent::handle_work_mode_report_(const uint8_t *data, uint16_t len) { if (len < 1) return; -#ifdef USE_TEXT_SENSOR + // Zero is the unattended half of this transition. Read outside the text sensor's + // ifdef because the area sensors do not need one configured to have gone stale. const bool low_power = (data[0] == 0); +#ifdef USE_TEXT_SENSOR if (this->work_mode_text_sensor_ != nullptr) { this->work_mode_reported_ = true; this->publish_work_mode_(low_power); } #endif + // Protocol V1.2 section 2.1.17: this message is sent only on the transition + // between the unattended low-power mode and normal operation, so a zero is the + // module stating that nobody is in any area. Not while a target is still being + // tracked, though: the reset_unattended command is undocumented on whether it + // forces this report, and where two statements from the module disagree the live + // one wins. + if (low_power && !this->target_presence_any_) { + this->clear_area_presence_(); + } } void LD6002BComponent::update_work_mode_fallback_() { @@ -832,9 +1028,10 @@ void LD6002BComponent::update_work_mode_fallback_() { if (!this->low_power_reported_) { return; } - // Presence is only meaningful while the stream that maintains it runs; with it - // off there is nothing to weigh and low power alone decides. - const bool presence = this->target_display_enabled_ && this->target_presence_any_; + // Target presence is only meaningful while the stream that maintains it runs. + // Area presence keeps its own report, so it still counts with the target stream + // off and low power alone decides only when neither half has anything to say. + const bool presence = (this->target_display_enabled_ && this->target_presence_any_) || this->area_presence_any_; this->publish_work_mode_(this->low_power_enabled_ && !presence); #endif } @@ -857,6 +1054,16 @@ void LD6002BComponent::publish_work_mode_(bool low_power) { void LD6002BComponent::publish_number_clamped_(number::Number *number, float value) { if (number == nullptr) return; + if (std::isnan(value)) { + // NAN is this component's "the module has not told us yet". Publishing it on an + // entity that has never had a state would report a nan where unknown is the + // truth; on one that already shows a value it is the only way to say that value + // no longer describes the selected area. + if (number->has_state()) { + number->publish_state(value); + } + return; + } const float min_value = number->traits.get_min_value(); const float max_value = number->traits.get_max_value(); // Outside the declared range the user cannot write the value back, so publish @@ -891,14 +1098,14 @@ void LD6002BComponent::handle_version_report_(const uint8_t *data, uint16_t len) #endif } -void LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) { +bool LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) { if (len > CMD_MAX_DATA_LEN) { ESP_LOGW(TAG, "Command data too large: %u", len); - return; + return false; } if (this->cmd_count_ >= CMD_QUEUE_SIZE) { ESP_LOGW(TAG, "Command queue full, dropping command 0x%04X", type); - return; + return false; } PendingCommand &cmd = this->cmd_queue_[this->cmd_tail_]; @@ -911,6 +1118,7 @@ void LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_ this->cmd_tail_ = (this->cmd_tail_ + 1) % CMD_QUEUE_SIZE; this->cmd_count_++; this->process_command_queue_(); + return true; } void LD6002BComponent::process_command_queue_() { @@ -945,6 +1153,18 @@ void LD6002BComponent::process_command_queue_() { } else { ESP_LOGW(TAG, "Command 0x%04X timed out", this->active_command_.type); } + if (this->active_command_.type == TYPE_SET_AREA) { + this->area_write_in_flight_ = false; + } + // The deferred apply is waiting on the report this command would have + // brought back, and nothing else re-arms it. Dropping it here is the + // difference between one apply lost to a timeout and one that rides in on + // an unrelated area report later, writing bounds the user has moved on from. + if (active_control_command == CMD_GET_AREAS && this->deferred_apply_pending_) { + this->deferred_apply_pending_ = false; + this->restore_deferred_edits_(); + ESP_LOGW(TAG, "Area read timed out, dropping deferred area apply"); + } // A reply may still be in flight for the attempt we just gave up on, so carry one over as // debt rather than clearing the ledger, or that late ACK would retire the successor. Only // one: reaching this point means nothing was answered at all, so the older attempts are @@ -1067,10 +1287,10 @@ void LD6002BComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t this->last_traffic_ms_ = now; } -void LD6002BComponent::send_control_command_(uint32_t command) { +bool LD6002BComponent::send_control_command_(uint32_t command) { uint8_t data[4]; write_u32_le(data, command); - this->queue_command_(TYPE_CONTROL, data, sizeof(data)); + return this->queue_command_(TYPE_CONTROL, data, sizeof(data)); } void LD6002BComponent::send_z_range_() { @@ -1090,6 +1310,64 @@ void LD6002BComponent::send_z_range_() { this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data)); } +void LD6002BComponent::apply_area_config_() { + if (!this->area_id_set_) { + ESP_LOGW(TAG, "Area ID not selected; ignoring apply"); + return; + } + if (this->area_id_ >= AREA_ID_COUNT) { + ESP_LOGW(TAG, "Invalid area id: %u", this->area_id_); + return; + } + + const bool interference = this->area_id_ < AREA_COUNT; + const uint8_t index = interference ? this->area_id_ : static_cast(this->area_id_ - AREA_COUNT); + AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + if (!std::isnan(this->area_x_min_)) + desired.x_min = this->area_x_min_; + if (!std::isnan(this->area_x_max_)) + desired.x_max = this->area_x_max_; + if (!std::isnan(this->area_y_min_)) + desired.y_min = this->area_y_min_; + if (!std::isnan(this->area_y_max_)) + desired.y_max = this->area_y_max_; + if (!std::isnan(this->area_z_min_)) + desired.z_min = this->area_z_min_; + if (!std::isnan(this->area_z_max_)) + desired.z_max = this->area_z_max_; + + if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || + std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { + // Ask first: a read that never reached the queue would leave a deferral waiting + // on a report nobody requested, with the user's values already retired for it. + if (!this->send_control_command_(CMD_GET_AREAS)) { + ESP_LOGW(TAG, "Area read not queued; area config left unapplied"); + return; + } + this->deferred_apply_pending_ = true; + this->pending_area_id_ = this->area_id_; + // The ledger, not the mirror: the mirror also carries whatever the module last + // reported for the axes the user never touched, and staging those would hand them + // back later wearing the user's badge -- a module value the next report is then + // kept away from. Staging only what was actually typed is also what makes the + // replay's overlay right: the untouched axes come from the fresh report. An + // empty ledger is a meaning rather than a gap, then: an apply with nothing + // staged rewrites the area exactly as the report just described it, which is + // what a direct apply with nothing staged already does. + this->pending_area_updates_ = this->area_edits_; + // Staged above, so they are the deferred apply's values now rather than an + // unsent edit. Anything typed from here belongs to whatever the user does + // next, which may well be a different area. + this->area_edits_ = AreaConfig{}; + ESP_LOGI(TAG, "Area config incomplete; requesting current areas before applying"); + return; + } + // Only a write the module will actually see retires them. + if (this->queue_area_config_(this->area_id_, desired)) { + this->area_edits_ = AreaConfig{}; + } +} + void LD6002BComponent::wake_() { // A command's own pulse raises the pin and writes after it, so ride along instead of // claiming the flag: claiming it would send that command down the immediate-write path @@ -1124,6 +1402,30 @@ void LD6002BComponent::set_number_value(NumberType type, float value) { this->queue_command_(TYPE_SET_LOW_POWER_SLEEP, data, sizeof(data)); break; } + case NumberType::AREA_X_MIN: + this->area_x_min_ = value; + this->area_edits_.x_min = value; + break; + case NumberType::AREA_X_MAX: + this->area_x_max_ = value; + this->area_edits_.x_max = value; + break; + case NumberType::AREA_Y_MIN: + this->area_y_min_ = value; + this->area_edits_.y_min = value; + break; + case NumberType::AREA_Y_MAX: + this->area_y_max_ = value; + this->area_edits_.y_max = value; + break; + case NumberType::AREA_Z_MIN: + this->area_z_min_ = value; + this->area_edits_.z_min = value; + break; + case NumberType::AREA_Z_MAX: + this->area_z_max_ = value; + this->area_edits_.z_max = value; + break; } } @@ -1154,9 +1456,179 @@ void LD6002BComponent::set_select_value(SelectType type, size_t index) { this->send_control_command_(CMD_INSTALL_SIDE); } break; + case SelectType::AREA_ID: + this->area_id_ = static_cast(index); + this->area_id_set_ = true; + this->update_area_numbers_for_id_(this->area_id_); + this->save_area_id_pref_(this->area_id_); + break; } } +void LD6002BComponent::update_area_numbers_(const AreaConfig &area) { + // A report refreshes every axis the user is not in the middle of changing. An + // unapplied edit is the one value here the module cannot know about, so taking + // the report over it would discard what the user typed with nothing to show for it. + const AreaConfig &edits = this->area_edits_; + if (std::isnan(edits.x_min)) + this->area_x_min_ = area.x_min; + if (std::isnan(edits.x_max)) + this->area_x_max_ = area.x_max; + if (std::isnan(edits.y_min)) + this->area_y_min_ = area.y_min; + if (std::isnan(edits.y_max)) + this->area_y_max_ = area.y_max; + if (std::isnan(edits.z_min)) + this->area_z_min_ = area.z_min; + if (std::isnan(edits.z_max)) + this->area_z_max_ = area.z_max; + this->publish_area_numbers_(); +} + +// The mirror, not the report: an axis a report was kept away from has to keep its +// displayed value too, or the entity and the value the next apply sends disagree. +void LD6002BComponent::publish_area_numbers_() { +#ifdef USE_NUMBER + this->publish_number_clamped_(this->area_x_min_number_, this->area_x_min_); + this->publish_number_clamped_(this->area_x_max_number_, this->area_x_max_); + this->publish_number_clamped_(this->area_y_min_number_, this->area_y_min_); + this->publish_number_clamped_(this->area_y_max_number_, this->area_y_max_); + this->publish_number_clamped_(this->area_z_min_number_, this->area_z_min_); + this->publish_number_clamped_(this->area_z_max_number_, this->area_z_max_); +#endif +} + +void LD6002BComponent::update_area_numbers_for_id_(uint8_t area_id) { + if (area_id >= AREA_ID_COUNT) + return; + const bool interference = area_id < AREA_COUNT; + const uint8_t index = interference ? area_id : static_cast(area_id - AREA_COUNT); + const AreaConfig &area = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + // The edits belonged to the area being navigated away from. + this->area_edits_ = AreaConfig{}; + this->update_area_numbers_(area); +} + +bool LD6002BComponent::queue_area_config_(uint8_t area_id, const AreaConfig &desired) { + // One frame carries all three pairs and cannot express a crossed one; the module + // would keep a box nothing can ever be inside. Both callers arrive with the six + // bounds resolved, so this is the last place that can say no -- and the return + // value is how saying no reaches the caller, which must not then retire the edits + // the user still has to fix. + if (desired.x_min > desired.x_max || desired.y_min > desired.y_max || desired.z_min > desired.z_max) { + ESP_LOGW(TAG, "Area %u not written, min above max", area_id); + return false; + } + uint8_t data[AREA_CONFIG_LEN]; + write_int32_le(data, static_cast(area_id)); + write_f32_le(data + 4, desired.x_min); + write_f32_le(data + 8, desired.x_max); + write_f32_le(data + 12, desired.y_min); + write_f32_le(data + 16, desired.y_max); + write_f32_le(data + 20, desired.z_min); + write_f32_le(data + 24, desired.z_max); + + if (!this->queue_command_(TYPE_SET_AREA, data, sizeof(data))) { + // Nothing is on its way, so the cache must not claim these bounds, the ack + // refresh must not be armed for an ack that cannot come, and the values stay + // the user's unsent edit. + return false; + } + this->area_write_in_flight_ = true; + + const bool interference = area_id < AREA_COUNT; + const uint8_t index = interference ? area_id : static_cast(area_id - AREA_COUNT); + AreaConfig &store = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + store = desired; + // The six numbers show one area at a time, and a deferred apply can land here for + // an area the user has navigated away from. Same question handle_area_report_ + // asks before it touches them. + const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (area_id == selected_id) { + this->update_area_numbers_(store); + } + return true; +} + +void LD6002BComponent::try_apply_pending_area_(bool reported_interference) { + if (!this->deferred_apply_pending_) { + return; + } + if (this->pending_area_id_ >= AREA_ID_COUNT) { + this->deferred_apply_pending_ = false; + return; + } + const bool interference = this->pending_area_id_ < AREA_COUNT; + const uint8_t index = + interference ? this->pending_area_id_ : static_cast(this->pending_area_id_ - AREA_COUNT); + AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + + if (!std::isnan(this->pending_area_updates_.x_min)) + desired.x_min = this->pending_area_updates_.x_min; + if (!std::isnan(this->pending_area_updates_.x_max)) + desired.x_max = this->pending_area_updates_.x_max; + if (!std::isnan(this->pending_area_updates_.y_min)) + desired.y_min = this->pending_area_updates_.y_min; + if (!std::isnan(this->pending_area_updates_.y_max)) + desired.y_max = this->pending_area_updates_.y_max; + if (!std::isnan(this->pending_area_updates_.z_min)) + desired.z_min = this->pending_area_updates_.z_min; + if (!std::isnan(this->pending_area_updates_.z_max)) + desired.z_max = this->pending_area_updates_.z_max; + + if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || + std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { + // Only the report covering this area's half can still fill it in, and there is + // exactly one of those per read. Once it has landed with a bound still unknown, + // nothing further is coming and waiting means waiting forever. + if (reported_interference == interference) { + this->deferred_apply_pending_ = false; + this->restore_deferred_edits_(); + ESP_LOGW(TAG, "Dropping deferred area apply, area report incomplete"); + } + return; + } + + const uint8_t area_id = this->pending_area_id_; + this->deferred_apply_pending_ = false; + if (!this->queue_area_config_(area_id, desired)) { + // Nothing was queued, so this is a drop like the other two: hand the staged + // values back rather than leaving them with no ledger to protect them. + this->restore_deferred_edits_(); + } +} + +void LD6002BComponent::init_area_id_pref_() { +#ifdef USE_SELECT + if (this->area_id_select_ == nullptr) { + return; + } + this->area_id_pref_ = this->area_id_select_->make_entity_preference(); + this->area_id_pref_initialized_ = true; + + uint8_t value = 0; + if (!this->area_id_pref_.load(&value) || value >= AREA_ID_COUNT) { + // No stored selection. The numbers are about to display this area either way, + // so select it for real: a displayed area that apply_area then refuses to write + // is the one combination the user cannot make sense of. + value = AREA_ID_DEFAULT; + } + this->area_id_select_->publish_state(value); + this->area_id_ = value; + this->area_id_set_ = true; + this->update_area_numbers_for_id_(value); +#endif +} + +void LD6002BComponent::save_area_id_pref_(uint8_t value) { +#ifdef USE_SELECT + if (!this->area_id_pref_initialized_) { + return; + } + this->area_id_pref_.save(&value); +#endif +} + void LD6002BComponent::init_version_pref_() { #ifdef USE_TEXT_SENSOR if (this->ota_version_text_sensor_ == nullptr) { @@ -1211,6 +1683,74 @@ void LD6002BComponent::clear_target_slot_(uint8_t index) { } #endif +void LD6002BComponent::restore_deferred_edits_() { + // The staged values become an unsent edit again, but only for the user who is + // still looking at the area they were staged for; anyone else's ledger belongs to + // the area they are on now. + const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (this->pending_area_id_ != selected_id) { + return; + } + // Axis by axis rather than a whole-struct assignment: the user can have edited + // another bound while the deferral was in flight, and that edit is newer than + // anything the deferral staged. Assigning over the ledger would drop it back to + // NaN and let the next report take the value away. A live edit wins; only an axis + // with nothing in the ledger takes its staged value back. + // + // The mirror moves with the ledger, because on the report path handle_area_report_ + // ran update_area_numbers_ before the replay, with the ledger still empty -- so the + // mirror already holds the module's bounds and both the entities and the next apply + // would build on them. On the timeout path no report arrived, the mirror still + // holds the staged values, and this is an identity. + const AreaConfig &staged = this->pending_area_updates_; + if (std::isnan(this->area_edits_.x_min) && !std::isnan(staged.x_min)) { + this->area_edits_.x_min = staged.x_min; + this->area_x_min_ = staged.x_min; + } + if (std::isnan(this->area_edits_.x_max) && !std::isnan(staged.x_max)) { + this->area_edits_.x_max = staged.x_max; + this->area_x_max_ = staged.x_max; + } + if (std::isnan(this->area_edits_.y_min) && !std::isnan(staged.y_min)) { + this->area_edits_.y_min = staged.y_min; + this->area_y_min_ = staged.y_min; + } + if (std::isnan(this->area_edits_.y_max) && !std::isnan(staged.y_max)) { + this->area_edits_.y_max = staged.y_max; + this->area_y_max_ = staged.y_max; + } + if (std::isnan(this->area_edits_.z_min) && !std::isnan(staged.z_min)) { + this->area_edits_.z_min = staged.z_min; + this->area_z_min_ = staged.z_min; + } + if (std::isnan(this->area_edits_.z_max) && !std::isnan(staged.z_max)) { + this->area_edits_.z_max = staged.z_max; + this->area_z_max_ = staged.z_max; + } + this->publish_area_numbers_(); +} + +void LD6002BComponent::clear_area_presence_() { + if (!this->area_presence_any_) { + return; + } + // Nothing else corrects this: 0x0A0A carries no period the protocol states and no + // command stops it, so the module going unattended is the only moment the + // component can know a stored "occupied" has stopped being true. + this->area_presence_any_ = false; +#ifdef USE_BINARY_SENSOR + for (uint8_t i = 0; i < AREA_COUNT; i++) { + if (this->area_presence_[i] != nullptr) { + this->area_presence_[i]->publish_state(false); + } + } + const bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif +} + void LD6002BComponent::clear_target_state_() { // Nothing corrects any of this until the stream comes back. The slot table goes // with it: slots key on cluster ids, which only track a person while reports are @@ -1242,8 +1782,9 @@ void LD6002BComponent::clear_target_state_() { if (this->target_presence_any_) { this->target_presence_any_ = false; #ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; if (this->presence_binary_sensor_ != nullptr) { - this->presence_binary_sensor_->publish_state(this->target_presence_any_); + this->presence_binary_sensor_->publish_state(presence); } #endif this->update_work_mode_fallback_(); @@ -1284,6 +1825,27 @@ void LD6002BComponent::set_switch_state(SwitchType type, bool state) { void LD6002BComponent::press_button(ButtonType type) { switch (type) { + case ButtonType::APPLY_AREA: + this->apply_area_config_(); + break; + case ButtonType::AUTO_INTERFERENCE: + this->send_control_command_(CMD_AUTO_INTERFERENCE); + // The module recomputes the interference areas without reporting them. + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::GET_AREAS: + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::CLEAR_INTERFERENCE: + this->send_control_command_(CMD_CLEAR_INTERFERENCE); + // The module rewrites the areas but does not report them, so ask for the new geometry the + // way the apply_area ack path does; the queue keeps it behind the command above. + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::RESET_DETECTION_AREA: + this->send_control_command_(CMD_RESET_DETECTION_AREA); + this->send_control_command_(CMD_GET_AREAS); + break; case ButtonType::GET_DELAY: this->send_control_command_(CMD_GET_DELAY); break; diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index 141f4ff027..bea3804312 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -31,6 +31,10 @@ namespace esphome::ld6002b { static constexpr uint8_t MAX_TARGETS = 3; +static constexpr uint8_t AREA_COUNT = 4; +// Interference areas own ids 0..AREA_COUNT-1 and detection areas the next four, so +// this is the whole id space TYPE_SET_AREA accepts. +static constexpr uint8_t AREA_ID_COUNT = AREA_COUNT * 2; static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024; static constexpr size_t DEFAULT_MAX_DATA_LEN_POINT_CLOUD = 4096; // Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes. @@ -41,12 +45,19 @@ enum class NumberType : uint8_t { Z_MIN, Z_MAX, LOW_POWER_SLEEP, + AREA_X_MIN, + AREA_X_MAX, + AREA_Y_MIN, + AREA_Y_MAX, + AREA_Z_MIN, + AREA_Z_MAX, }; enum class SelectType : uint8_t { SENSITIVITY, TRIGGER_SPEED, INSTALLATION_MODE, + AREA_ID, }; enum class SwitchType : uint8_t { @@ -56,6 +67,11 @@ enum class SwitchType : uint8_t { }; enum class ButtonType : uint8_t { + APPLY_AREA, + AUTO_INTERFERENCE, + GET_AREAS, + CLEAR_INTERFERENCE, + RESET_DETECTION_AREA, GET_DELAY, GET_SENSITIVITY, GET_TRIGGER_SPEED, @@ -76,8 +92,25 @@ struct TargetSensors { sensor::Sensor *cluster_id{nullptr}; }; +struct AreaSensors { + sensor::Sensor *x_min{nullptr}; + sensor::Sensor *x_max{nullptr}; + sensor::Sensor *y_min{nullptr}; + sensor::Sensor *y_max{nullptr}; + sensor::Sensor *z_min{nullptr}; + sensor::Sensor *z_max{nullptr}; +}; #endif +struct AreaConfig { + float x_min{NAN}; + float x_max{NAN}; + float y_min{NAN}; + float y_max{NAN}; + float z_min{NAN}; + float z_max{NAN}; +}; + struct VersionPref { char value[20]; }; @@ -122,6 +155,67 @@ class LD6002BComponent : public Component, public uart::UARTDevice { return; this->targets_[target].cluster_id = sensor; } + void set_interference_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].x_min = sensor; + } + void set_interference_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].x_max = sensor; + } + void set_interference_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].y_min = sensor; + } + void set_interference_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].y_max = sensor; + } + void set_interference_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].z_min = sensor; + } + void set_interference_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].z_max = sensor; + } + + void set_detection_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].x_min = sensor; + } + void set_detection_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].x_max = sensor; + } + void set_detection_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].y_min = sensor; + } + void set_detection_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].y_max = sensor; + } + void set_detection_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].z_min = sensor; + } + void set_detection_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].z_max = sensor; + } #endif #ifdef USE_BINARY_SENSOR @@ -131,6 +225,11 @@ class LD6002BComponent : public Component, public uart::UARTDevice { return; this->target_presence_[target] = sensor; } + void set_area_presence_binary_sensor(uint8_t area, binary_sensor::BinarySensor *sensor) { + if (area >= AREA_COUNT) + return; + this->area_presence_[area] = sensor; + } #endif #ifdef USE_TEXT_SENSOR @@ -143,12 +242,20 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void set_z_min_number(number::Number *number) { this->z_min_number_ = number; } void set_z_max_number(number::Number *number) { this->z_max_number_ = number; } void set_low_power_sleep_number(number::Number *number) { this->low_power_sleep_number_ = number; } + + void set_area_x_min_number(number::Number *number) { this->area_x_min_number_ = number; } + void set_area_x_max_number(number::Number *number) { this->area_x_max_number_ = number; } + void set_area_y_min_number(number::Number *number) { this->area_y_min_number_ = number; } + void set_area_y_max_number(number::Number *number) { this->area_y_max_number_ = number; } + void set_area_z_min_number(number::Number *number) { this->area_z_min_number_ = number; } + void set_area_z_max_number(number::Number *number) { this->area_z_max_number_ = number; } #endif #ifdef USE_SELECT void set_sensitivity_select(select::Select *select) { this->sensitivity_select_ = select; } void set_trigger_speed_select(select::Select *select) { this->trigger_speed_select_ = select; } void set_installation_select(select::Select *select) { this->installation_select_ = select; } + void set_area_id_select(select::Select *select) { this->area_id_select_ = select; } #endif #ifdef USE_SWITCH @@ -176,6 +283,8 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void handle_frame_(uint16_t type, const uint8_t *data, uint16_t len); void handle_target_report_(const uint8_t *data, uint16_t len); void handle_point_cloud_(const uint8_t *data, uint16_t len); + void handle_area_presence_(const uint8_t *data, uint16_t len); + void handle_area_report_(bool interference, const uint8_t *data, uint16_t len); void handle_delay_report_(const uint8_t *data, uint16_t len); void handle_sensitivity_report_(const uint8_t *data, uint16_t len); void handle_trigger_speed_report_(const uint8_t *data, uint16_t len); @@ -189,22 +298,35 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void publish_work_mode_(bool low_power); // Drops every target-derived reading and the slot table they are indexed by. void clear_target_state_(); + void clear_area_presence_(); + void restore_deferred_edits_(); + void publish_area_numbers_(); #ifdef USE_SENSOR void clear_target_slot_(uint8_t index); #endif #ifdef USE_NUMBER void publish_number_clamped_(number::Number *number, float value); #endif + void update_area_numbers_(const AreaConfig &area); + void update_area_numbers_for_id_(uint8_t area_id); + bool queue_area_config_(uint8_t area_id, const AreaConfig &desired); + void try_apply_pending_area_(bool reported_interference); + void init_area_id_pref_(); + void save_area_id_pref_(uint8_t value); void init_version_pref_(); void save_version_pref_(const char *value); - void queue_command_(uint16_t type, const uint8_t *data, uint8_t len); + // Returns whether the command was queued: it is dropped, with a log line, when + // the payload is too long or the ring is full. + bool queue_command_(uint16_t type, const uint8_t *data, uint8_t len); void process_command_queue_(); void send_command_(uint16_t type, const uint8_t *data, uint8_t len); void send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track); void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track); - void send_control_command_(uint32_t command); + // Returns whether the command reached the queue; see queue_command_. + bool send_control_command_(uint32_t command); void send_z_range_(); + void apply_area_config_(); void wake_(); static uint16_t read_u16_be(const uint8_t *data); @@ -212,16 +334,20 @@ class LD6002BComponent : public Component, public uart::UARTDevice { static int32_t read_int32_le(const uint8_t *data); static float read_f32_le(const uint8_t *data); static void write_u32_le(uint8_t *data, uint32_t value); + static void write_int32_le(uint8_t *data, int32_t value); static void write_f32_le(uint8_t *data, float value); #ifdef USE_SENSOR std::array targets_{}; sensor::Sensor *target_count_sensor_{nullptr}; sensor::Sensor *point_count_sensor_{nullptr}; + std::array interference_areas_{}; + std::array detection_areas_{}; #endif #ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *presence_binary_sensor_{nullptr}; std::array target_presence_{}; + std::array area_presence_{}; #endif #ifdef USE_TEXT_SENSOR text_sensor::TextSensor *work_mode_text_sensor_{nullptr}; @@ -234,11 +360,21 @@ class LD6002BComponent : public Component, public uart::UARTDevice { number::Number *z_min_number_{nullptr}; number::Number *z_max_number_{nullptr}; number::Number *low_power_sleep_number_{nullptr}; + + number::Number *area_x_min_number_{nullptr}; + number::Number *area_x_max_number_{nullptr}; + number::Number *area_y_min_number_{nullptr}; + number::Number *area_y_max_number_{nullptr}; + number::Number *area_z_min_number_{nullptr}; + number::Number *area_z_max_number_{nullptr}; #endif #ifdef USE_SELECT select::Select *sensitivity_select_{nullptr}; select::Select *trigger_speed_select_{nullptr}; select::Select *installation_select_{nullptr}; + select::Select *area_id_select_{nullptr}; + ESPPreferenceObject area_id_pref_{}; + bool area_id_pref_initialized_{false}; #endif #ifdef USE_SWITCH switch_::Switch *low_power_switch_{nullptr}; @@ -264,9 +400,13 @@ class LD6002BComponent : public Component, public uart::UARTDevice { uint8_t *data_buf_{nullptr}; uint16_t next_frame_id_{0}; - // Sized for the boot burst: with every platform configured, setup() enqueues - // roughly ten GET/config commands back to back before the first ack lands. - static constexpr uint8_t CMD_QUEUE_SIZE = 16; + // Sized for the two bursts that reach it, both counted as what is still queued + // once the first command is dequeued: boot leaves 11 with every platform + // configured, and pressing all fourteen buttons before an ack lands leaves 15. + // Neither overflowed 16, but one free slot is not headroom, and overflowing is a + // dropped command with only a log line to show for it. Costs 256 bytes more per + // configured instance, and this component is MULTI_CONF. + static constexpr uint8_t CMD_QUEUE_SIZE = 24; static constexpr uint32_t CMD_ACK_TIMEOUT_MS = 300; // A sleeping module consumes the first frame to wake and answers only the one after it. static constexpr uint32_t CMD_FIRST_ACK_TIMEOUT_MS = 600; @@ -276,6 +416,9 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // Named so a repeated press replaces its own pending timeout instead of stacking // another, and so the command path can cancel it when it takes the pin over. static constexpr const char *WAKE_BUTTON_TIMEOUT = "wake_button"; + // Named so a burst of writes collapses to one read once they settle, rather than + // one read per write. + static constexpr const char *AREA_REFRESH_TIMEOUT = "area_refresh"; // A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms. static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000; @@ -307,6 +450,24 @@ class LD6002BComponent : public Component, public uart::UARTDevice { float z_min_{NAN}; float z_max_{NAN}; + float area_x_min_{NAN}; + float area_x_max_{NAN}; + float area_y_min_{NAN}; + float area_y_max_{NAN}; + float area_z_min_{NAN}; + float area_z_max_{NAN}; + // What the user has typed and not yet applied; NaN per axis means "nothing of + // mine here, take the module's value". Same sentinel shape as + // pending_area_updates_. Exactly two things empty it: the area_id select moving + // to another area, and an apply that was accepted. A write the bounds guard + // refused leaves it alone, and a deferred apply that had to be dropped hands its + // staged values back here -- but only while the user is still on the area they + // were staged for. Either way the values stay the user's to fix. + AreaConfig area_edits_{}; + std::array interference_area_values_{}; + std::array detection_area_values_{}; + uint8_t area_id_{0xFF}; + bool area_id_set_{false}; // Which person owns each target_N slot, so a slot survives the module re-sorting its array. std::array slot_cluster_{}; @@ -318,9 +479,14 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // The report handlers read these and drop anything a stopped stream still emits. bool target_display_enabled_{false}; bool point_cloud_enabled_{false}; + bool area_presence_any_{false}; + bool area_write_in_flight_{false}; bool work_mode_reported_{false}; bool low_power_enabled_{false}; bool low_power_reported_{false}; + bool deferred_apply_pending_{false}; + uint8_t pending_area_id_{0xFF}; + AreaConfig pending_area_updates_{}; bool last_work_mode_valid_{false}; bool last_work_mode_low_power_{false}; diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py index 10e9e89dc8..7e0be66c64 100644 --- a/esphome/components/ld6002b/number/__init__.py +++ b/esphome/components/ld6002b/number/__init__.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import ( + CONF_AREA_ID, + CONF_BUTTON, DEVICE_CLASS_DISTANCE, DEVICE_CLASS_DURATION, ENTITY_CATEGORY_CONFIG, @@ -9,14 +11,22 @@ from esphome.const import ( UNIT_MILLISECOND, UNIT_SECOND, ) +import esphome.final_validate as fv +from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns from ..const import ( + CONF_APPLY_AREA, + CONF_AREA_CONFIG, CONF_HOLD_DELAY, CONF_LD6002B_ID, CONF_LOW_POWER_SLEEP_TIME, CONF_Z_MAX, CONF_Z_MIN, + KEY_X_MAX, + KEY_X_MIN, + KEY_Y_MAX, + KEY_Y_MIN, ) DEPENDENCIES = ["ld6002b"] @@ -51,10 +61,83 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_DURATION, entity_category=ENTITY_CATEGORY_CONFIG, ), + cv.Optional(CONF_AREA_CONFIG): cv.Schema( + { + cv.Optional(KEY_X_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_X_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_Y_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_Y_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + } + ), } ) +def final_validate(config: ConfigType) -> ConfigType: + if config.get(CONF_AREA_CONFIG) is None: + return config + + full_config = fv.full_config.get() + hub_id = config[CONF_LD6002B_ID] + + has_apply_area = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_APPLY_AREA) is not None + for entry in full_config.get(CONF_BUTTON, []) + ) + if not has_apply_area: + raise cv.Invalid( + f"{CONF_AREA_CONFIG} requires button.apply_area for the same ld6002b instance", + path=[CONF_AREA_CONFIG], + ) + + has_area_id_select = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None + for entry in full_config.get("select", []) + ) + if not has_area_id_select: + raise cv.Invalid( + f"{CONF_AREA_CONFIG} requires select.area_id for the same ld6002b instance", + path=[CONF_AREA_CONFIG], + ) + + return config + + +FINAL_VALIDATE_SCHEMA = final_validate + + async def to_code(config): hub = await cg.get_variable(config[CONF_LD6002B_ID]) @@ -80,3 +163,19 @@ async def to_code(config): ) await cg.register_parented(n, config[CONF_LD6002B_ID]) cg.add(getattr(hub, setter)(n)) + + if area_config := config.get(CONF_AREA_CONFIG): + for key, number_type, setter in ( + (KEY_X_MIN, NumberType.AREA_X_MIN, "set_area_x_min_number"), + (KEY_X_MAX, NumberType.AREA_X_MAX, "set_area_x_max_number"), + (KEY_Y_MIN, NumberType.AREA_Y_MIN, "set_area_y_min_number"), + (KEY_Y_MAX, NumberType.AREA_Y_MAX, "set_area_y_max_number"), + (CONF_Z_MIN, NumberType.AREA_Z_MIN, "set_area_z_min_number"), + (CONF_Z_MAX, NumberType.AREA_Z_MAX, "set_area_z_max_number"), + ): + if conf := area_config.get(key): + n = await number.new_number( + conf, number_type, min_value=-10, max_value=10, step=0.1 + ) + await cg.register_parented(n, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(n)) diff --git a/esphome/components/ld6002b/select/__init__.py b/esphome/components/ld6002b/select/__init__.py index 3fcc117e2f..3da647ee2c 100644 --- a/esphome/components/ld6002b/select/__init__.py +++ b/esphome/components/ld6002b/select/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv -from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG +from esphome.const import CONF_AREA_ID, CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG from .. import LD6002BComponent, ld6002b_ns from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED @@ -11,6 +11,16 @@ DEPENDENCIES = ["ld6002b"] LD6002BSelect = ld6002b_ns.class_("LD6002BSelect", select.Select) SelectType = ld6002b_ns.enum("SelectType", is_class=True) +AREA_ID_OPTIONS = [ + "interference_area_0", + "interference_area_1", + "interference_area_2", + "interference_area_3", + "detection_area_0", + "detection_area_1", + "detection_area_2", + "detection_area_3", +] CONFIG_SCHEMA = cv.Schema( { @@ -24,6 +34,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_INSTALLATION_MODE): select.select_schema( LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG ), + cv.Optional(CONF_AREA_ID): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), } ) @@ -47,6 +60,7 @@ SELECT_MAP = ( "set_installation_select", ["top", "side"], ), + (CONF_AREA_ID, SelectType.AREA_ID, "set_area_id_select", AREA_ID_OPTIONS), ) diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index ff88d343b9..3aedaf9fdd 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -12,11 +12,18 @@ from esphome.const import ( from . import LD6002BComponent from .const import ( + AREA_COUNT, CONF_CLUSTER_ID, CONF_DOPPLER_INDEX, CONF_LD6002B_ID, CONF_POINT_COUNT, CONF_Z, + CONF_Z_MAX, + CONF_Z_MIN, + KEY_X_MAX, + KEY_X_MIN, + KEY_Y_MAX, + KEY_Y_MIN, MAX_TARGETS, ) @@ -68,20 +75,79 @@ TARGET_SCHEMA = cv.Schema( } ) - -CONFIG_SCHEMA = cv.Schema( +AREA_SCHEMA = cv.Schema( { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( - accuracy_decimals=0, + cv.Optional(KEY_X_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema( - accuracy_decimals=0, + cv.Optional(KEY_X_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(KEY_Y_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(KEY_Y_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, state_class=STATE_CLASS_MEASUREMENT, ), } -).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) +) + +# (config key, C++ setter axis) for the six bounds every area sensor block carries. +_AREA_AXES = ( + (KEY_X_MIN, "x_min"), + (KEY_X_MAX, "x_max"), + (KEY_Y_MIN, "y_min"), + (KEY_Y_MAX, "y_max"), + (CONF_Z_MIN, "z_min"), + (CONF_Z_MAX, "z_max"), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) + .extend( + {cv.Optional(f"interference_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)} + ) + .extend( + {cv.Optional(f"detection_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)} + ) +) async def to_code(config): @@ -112,3 +178,11 @@ async def to_code(config): if cluster_id_config := target_config.get(CONF_CLUSTER_ID): sens = await sensor.new_sensor(cluster_id_config) cg.add(hub.set_target_cluster_id_sensor(i, sens)) + + for kind in ("interference", "detection"): + for i in range(AREA_COUNT): + if area_config := config.get(f"{kind}_area_{i}"): + for key, axis in _AREA_AXES: + if axis_config := area_config.get(key): + sens = await sensor.new_sensor(axis_config) + cg.add(getattr(hub, f"set_{kind}_area_{axis}_sensor")(i, sens)) diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 45f1b95164..8443799144 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -129,6 +129,9 @@ async def stop_scan_action_to_code( async def to_code(config: ConfigType) -> None: # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. cg.add_define("USE_LN882H_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (the LN controller + # delivers the pair as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp index cddcd6c17d..11ea46525c 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -3,7 +3,6 @@ #include "ln882h_ble_tracker.h" #include -#include #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -22,6 +21,9 @@ void LN882HBLETracker::setup() { // Receive the controller's scan reports; the controller queues them from the // rw task and delivers here on the main task. this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_ + // is read at each delivery to decide unclaimed-device logging. + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); // scan_running_ check: an on_boot start_scan action (priority 600) runs // before this setup() (200) and enable_loop() is a no-op pre-setup — parking // the loop here would strand that already-running scan. @@ -72,19 +74,11 @@ void LN882HBLETracker::loop() { this->start_scan_(); } } - // Flush pending scannable advertisements whose scan response never arrived - // (device didn't answer / frame lost) — delivered unmerged after the timeout. - // Main-task only, like every consumer of pending_adv_. + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. Main-task only, like every merger call. const uint32_t now = millis(); - if (this->pending_count_ != 0) { - for (auto &p : this->pending_adv_) { - if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) { - p.used = false; - this->pending_count_--; - this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); - } - } - } + if (!this->merger_.empty()) + this->merger_.sweep(now); if (this->scan_continuous_) { if (!this->scan_running_) { @@ -145,129 +139,25 @@ void LN882HBLETracker::dump_config() { } // --------------------------------------------------------------------------- -// Adv/scan-response demux with Bluedroid-style merge: the LN controller -// delivers the pair as separate reports; a scannable advertisement is held -// until its scan response arrives and delivered as one merged frame. +// Adv/scan-response demux into the shared merger (ble_device_base): the LN +// controller delivers the pair as separate reports; a scannable advertisement +// is held until its scan response arrives and delivered as one merged frame. // --------------------------------------------------------------------------- void LN882HBLETracker::on_scan_report(const ln882h_ble::BLEScanReport &report) { if (report.is_scan_response) { - this->deliver_scan_rsp_(report); + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); return; } // Stash only while the scan runs: after a one-shot stop the loop is - // disabled and nothing would sweep the table, so a late report would + // disabled and nothing would sweep the merger, so a late report would // surface minutes later as a fresh advertisement. if (this->scan_running_ && this->scan_active_ && report.scannable) { - this->stash_adv_(report); + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, millis()); return; } - this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false); -} - -// Hold a scannable advertisement, waiting (≤ PENDING_ADV_TIMEOUT_MS) for its -// scan response. -void LN882HBLETracker::stash_adv_(const ln882h_ble::BLEScanReport &report) { - // One pass: find a same-device entry (deliver + reuse) while remembering the - // first free slot as the fallback. - PendingAdv *slot = nullptr; - PendingAdv *free_slot = nullptr; - for (auto &p : this->pending_adv_) { - if (!p.used) { - if (free_slot == nullptr) - free_slot = &p; - continue; - } - if (p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) { - // Same device advertised again before its scan response arrived — deliver - // the previous advertisement (its scan response is not coming) and reuse - // the slot, so no frame is ever lost. - p.used = false; - this->pending_count_--; - this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); - slot = &p; - break; - } - } - if (slot == nullptr) - slot = free_slot; - if (slot == nullptr) { - // Table full — degrade gracefully: deliver the advertisement unmerged. - this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false); - return; - } - slot->used = true; - this->pending_count_++; - memcpy(slot->mac, report.mac, 6); - slot->addr_type = report.addr_type; - slot->rssi = report.rssi; - slot->data_len = (report.data_len <= sizeof(slot->data)) ? report.data_len : sizeof(slot->data); - memcpy(slot->data, report.data, slot->data_len); - slot->stored_ms = millis(); -} - -// Scan response arrived: merge it with the pending advertisement from the same -// device into ONE frame (ESP-IDF/Bluedroid semantics). -void LN882HBLETracker::deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report) { - // Fast-out on the empty table (loop()/flush use the same guard); this is - // the hottest caller. - if (this->pending_count_ != 0) { - for (auto &p : this->pending_adv_) { - if (p.used && p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) { - // Append in place: the slot is released on delivery, so its 62-byte - // buffer (legacy adv + scan response) holds the merged frame directly. - const uint8_t room = sizeof(p.data) - p.data_len; - const uint8_t add = (report.data_len <= room) ? report.data_len : room; - memcpy(p.data + p.data_len, report.data, add); - p.used = false; - this->pending_count_--; - // The advertisement's RSSI, not the scan response's: every unmerged path - // reports the advertisement's measurement, so a device's RSSI must not - // jump between two measurements depending on merge timing. - this->process_adv_(report.mac, p.rssi, report.addr_type, p.data, p.data_len + add, /*raw_only=*/false); - return; - } - } - } - // Unmatched scan-response: goes out on the raw callback only (HA merges per - // address); local listeners/triggers receive each advertisement exactly once - // via the merged/plain path above. - this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/true); -} - -void LN882HBLETracker::process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, - uint8_t data_len, bool raw_only) { - // Raw callback (the raw-advertisement path). Both full advertisements and - // unmatched scan responses (raw_only) are forwarded. - if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(mac), - .data = data, - .data_len = data_len, - .rssi = rssi, - .addr_type = addr_type}; - this->raw_advertisement_callback_.invoke(adv); - } - -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Scan-response-only frames are never parsed for local sensors/triggers. - if (raw_only) - return; - ble_device_base::ESPBTDevice device; - device.from_scan_result(mac, rssi, addr_type, data, data_len); - // The listener list holds sensors AND this tracker's automation triggers - // (the triggers are listeners, exactly like esp32_ble_tracker), so one - // loop feeds both and ORs into `found`. - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) { - found = true; - } - } - // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed - // it and the scan is one-shot (continuous scans would spam). - if (!found && !this->scan_continuous_) - this->discovered_log_.log_device(TAG, device); -#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); } // --------------------------------------------------------------------------- @@ -356,29 +246,11 @@ void LN882HBLETracker::stop_scan_() { // Close a scan period: deliver held advertisements whose scan response never // came (unmerged) BEFORE on_scan_end fires, then re-anchor the period clock. void LN882HBLETracker::end_scan_period_(uint32_t now) { - this->flush_pending_adv_(); -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) -#endif + this->merger_.flush(); + this->dispatcher_.on_scan_end(); this->scan_period_start_ = now; } -// Deliver every held advertisement now (scan period/scan is ending): unmerged -// delivery, same as the timeout path in loop(). Main-task only. -void LN882HBLETracker::flush_pending_adv_() { - if (this->pending_count_ == 0) - return; - for (auto &p : this->pending_adv_) { - if (p.used) { - p.used = false; - this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); - } - } - this->pending_count_ = 0; -} - } // namespace esphome::ln882h_ble_tracker #endif // USE_LIBRETINY diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h index 9c0e0b2f1a..2d88b938dd 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -9,6 +9,7 @@ #include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" #include "esphome/components/ln882h_ble/ln882h_ble.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -75,12 +76,10 @@ class LN882HBLETracker : public Component, // ---- ble_device_base::BLEHub contract ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - this->listeners_.push_back(listener); -#endif + this->dispatcher_.register_listener(listener); } void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { - this->raw_advertisement_callback_ = callback; + this->dispatcher_.set_raw_advertisement_callback(callback); } static constexpr ble_device_base::HubCapabilities get_capabilities() { // The LN882H controller supports active scanning; adv + scan response arrive @@ -108,27 +107,11 @@ class LN882HBLETracker : public Component, void on_scan_report(const ln882h_ble::BLEScanReport &report) override; protected: - // Bluedroid-style adv + scan-response merging (ESP-IDF concatenates both into - // one result before ESPHome sees it; the LN controller reports them separately): - // a scannable advertisement is held here briefly, its scan response is appended - // on arrival and the pair is delivered as ONE merged frame. Held entries whose - // scan response never arrives are flushed by loop() after PENDING_ADV_TIMEOUT_MS. - // All of this runs on the main task (the controller queue already crossed tasks), - // so no locking is involved. - void stash_adv_(const ln882h_ble::BLEScanReport &report); - void deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report); - // Dispatch one (possibly merged) advertisement: the raw - // callback, and — unless raw_only — parsing for listeners/triggers. raw_only - // marks unmatched scan-response frames: forwarded on the raw callback only, - // never to local sensors/triggers (HA merges per address). - void process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, - bool raw_only); void start_scan_(); void stop_scan_(); // Close a scan period: flush held advertisements (unmerged) BEFORE // on_scan_end fires, then re-anchor the period clock to `now`. void end_scan_period_(uint32_t now); - void flush_pending_adv_(); bool scan_running_{false}; bool scan_active_{false}; @@ -147,45 +130,13 @@ class LN882HBLETracker : public Component, #endif uint32_t scan_start_time_{0}; - // Pending scannable advertisements awaiting their scan response (active scan). - // 62 bytes = legacy adv (31) + scan response (31), the same merged maximum as - // ESP-IDF delivers on ESP32. Main-task only. - struct PendingAdv { - bool used{false}; - uint8_t mac[6]; - uint8_t addr_type; - int8_t rssi; - uint8_t data_len; // <= sizeof(data) - uint8_t data[62]; - uint32_t stored_ms; - }; - // Sized for the unanswered case: a pair that IS answered normally matches - // within one queue drain, so a slot is held for the full timeout only by - // scannable devices that never reply. 8 concurrent such advertisers before - // the merge degrades (frames still delivered, just unmerged) at ~80 B each. - static constexpr size_t MAX_PENDING_ADV = 8; - // On air a scan response follows its advertisement by T_IFS (150 µs) — the - // timeout only covers HOST-side report queuing in rw_task under WiFi/BLE - // coexistence, measured on-device at up to ~136 ms. 300 ms = >2x that margin, - // while staying below any device's re-advertising period. - static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300; - PendingAdv pending_adv_[MAX_PENDING_ADV]; - // Occupied pending_adv_ slots — lets loop()'s timeout sweep skip the table - // in the common case (empty: passive scan, or every pair already matched). - uint8_t pending_count_{0}; + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main task (the controller queue already crossed + // tasks); the merger is clocked by millis() throughout this tracker. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end() - - ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Parsed-advertisement consumers registered through ble_device_base. - // Codegen-sized: no heap allocation, no std::vector template instantiations. - StaticVector listeners_; - // Per-period "Found device" DEBUG log with MAC dedup — shared implementation - // in ble_device_base, identical output on every tracker backend. Guarded like - // its only writer so a no-listener build does not carry an unused vector. - ble_device_base::DiscoveredDeviceLog discovered_log_{}; -#endif }; } // namespace esphome::ln882h_ble_tracker diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index c9e443cd87..9f2527d9fb 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -883,8 +883,8 @@ void ModbusClientHub::sweep_() { } // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. -bool ModbusClientHub::send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device, - CommandOptions options) { +bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device, + CommandOptions options) { // Requests refused here never enter the machine and get no callback - the false return is it. if (pdu.empty()) { ESP_LOGW(TAG, "Empty PDU refused for address %" PRIu8, address); @@ -995,7 +995,7 @@ void ModbusClientHub::send_raw(const std::vector &payload, ModbusClient ESP_LOGW(TAG, "send_raw() payload too short to contain a PDU, refused"); return; } - this->send_pdu(payload[0], std::span(payload).subspan(1), device); + this->queue_pdu(payload[0], std::span(payload).subspan(1), device); } // Send raw command for server replies immediately. Except CRC everything must be contained in payload @@ -1077,7 +1077,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // - On failure (status engaged) the response is empty by design (see on_error()), so only the request // is validated. bool custom = !helpers::is_client_pdu_standard(request_pdu.data(), request_pdu.size()); - if (!custom && !status.has_value()) { + if (!custom && succeeded(status)) { custom = !helpers::is_server_pdu_standard(response_pdu.data(), response_pdu.size()); if (!custom && helpers::is_function_code_read(static_cast(function_code))) { const bool bits = @@ -1104,7 +1104,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // capacity of RegisterValues); a mismatch was diverted to on_custom_response(), never clamped. On // failure the registers span is empty. RegisterValues registers; - if (!status.has_value()) { + if (succeeded(status)) { for (size_t i = 0; i != count_or_value; i++) { registers.push_back(helpers::get_data(response_pdu.data(), 2 + 2 * i)); } @@ -1124,7 +1124,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // PackedBits::operator[] is unchecked, so size() must never promise bits with no bytes behind them. std::span packed_bytes; uint16_t count = 0; - if (!status.has_value()) { + if (succeeded(status)) { packed_bytes = response_pdu.subspan(2); count = count_or_value; } @@ -1141,7 +1141,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // copy. On an exception the response has no value and the request copy is the only one. case FunctionCode::WRITE_SINGLE_REGISTER: case FunctionCode::WRITE_SINGLE_COIL: { - const uint16_t value = (!status.has_value() && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE) + const uint16_t value = (succeeded(status) && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE) ? helpers::get_data(response_pdu.data(), 3) : count_or_value; if (function_code == FunctionCode::WRITE_SINGLE_REGISTER) { diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 274b10f9b4..3b6028e90a 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -262,19 +262,31 @@ class ModbusClientHub : public Modbus { void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } bool tx_buffer_empty(); bool tx_blocked() override; - ESPDEPRECATED("Use send_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") + ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { - this->send_pdu(address, - helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, - payload_len), - device); + this->queue_pdu(address, + helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, + payload_len), + device); }; - // Queue a request; true once it is a live entry (resolving in one terminal), false if it never - // entered the machine (empty/oversize PDU, full queue, anonymous or over-cap duplicate) - no callback. - bool send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr, - CommandOptions options = {}); - ESPDEPRECATED("Use send_pdu(payload[0], , device) instead. Removed in 2027.2.0", "2026.8.0") + /// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and + /// goes out later from loop(), so a true return means accepted into the machine (it will resolve in + /// exactly one terminal callback), NOT that anything reached the wire - that is on_sent(). False means + /// it never entered the machine at all (empty or oversize PDU, full queue, anonymous or over-cap + /// duplicate) and no callback of any kind will follow; the false return is the whole story. + bool queue_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr, + CommandOptions options = {}); + // Remove before 2027.2.0. Deliberately the signature 2026.7.4 shipped - void, and no CommandOptions: + // the bool return and the options argument arrived after that release, so nothing external can be + // relying on them under this name. Callers who want the queued/refused answer move to queue_pdu(). + ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it " + "reports whether the request was accepted. Removed in 2027.2.0", + "2026.8.0") + void send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr) { + this->queue_pdu(address, pdu, device); + } + ESPDEPRECATED("Use queue_pdu(payload[0], , device) instead. Removed in 2027.2.0", "2026.8.0") void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); // Clear an address's commands; each un-run request resolves via on_not_sent(), but a frame on the // wire still runs to its usual terminal. clear_tx_queue_for_device() instead discards silently. @@ -315,6 +327,12 @@ class ModbusClientHub : public Modbus { // Transaction status: std::nullopt on success, otherwise a Modbus exception code using ResponseStatus = std::optional; +/// True when a transaction carried no exception. The optional holds the exception, so has_value() means +/// the request FAILED - the inverse of how "status" usually reads. Prefer this at the call site; the +/// bare !status.has_value() has already been mistaken for a failure check more than once. Where the code +/// is going to unwrap the exception anyway, status.has_value() followed by status.value() stays clearer. +inline bool succeeded(ResponseStatus status) { return !status.has_value(); } + // Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol // maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by // the capacity of this type. @@ -373,7 +391,7 @@ class ModbusServerHub : public Modbus { /// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data), /// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by -/// clear_tx_queue_for_address before transmission). A request refused at send_pdu() (false return) +/// clear_tx_queue_for_address before transmission). A request refused at queue_pdu() (false return) /// gets none. on_sent() is additional, once per transmission, never for an on_not_sent() request. /// on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, all /// from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from @@ -383,7 +401,7 @@ class ModbusServerHub : public Modbus { /// merges into it). /// /// Invariants: -/// - Public entry points (send_pdu/clear_tx_queue_*) only append to the queue or mutate an existing +/// - Public entry points (queue_pdu/clear_tx_queue_*) only append to the queue or mutate an existing /// entry through its callback-free transition methods. /// - Public entry points can never trigger a callback synchronously. /// - Callbacks are delivered only from within loop(). @@ -485,66 +503,75 @@ class ModbusClientDevice { /// to handle custom traffic (which also silences the warning). virtual void on_custom_response(std::span request_pdu, std::span response_pdu, ResponseStatus status); - ESPDEPRECATED("Use the typed read_*/write_* helpers or send_pdu() instead. Removed in 2027.2.0", "2026.8.0") + ESPDEPRECATED("Use the typed read_*/write_* helpers or queue_pdu() instead. Removed in 2027.2.0", "2026.8.0") void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr) { - this->parent_->send_pdu( + this->parent_->queue_pdu( this->address_, helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len), this); } - /// See ModbusClientHub::send_pdu(): true = accepted (a terminal callback will follow), - /// false = refused at the door (no callback). - bool send_pdu(std::span pdu, CommandOptions options = {}) { - return this->parent_->send_pdu(this->address_, pdu, this, options); + /// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will + /// follow, false = refused at the door and nothing further happens. Neither means the frame is on + /// the wire; on_sent() reports that. + bool queue_pdu(std::span pdu, CommandOptions options = {}) { + return this->parent_->queue_pdu(this->address_, pdu, this, options); } - ESPDEPRECATED("Use send_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0") - bool send_raw(const std::vector &payload) { + // Remove before 2027.2.0. As on the hub, this is the signature 2026.7.4 shipped: void, no options. + ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it " + "reports whether the request was accepted. Removed in 2027.2.0", + "2026.8.0") + void send_pdu(std::span pdu) { this->queue_pdu(pdu); } + ESPDEPRECATED("Use queue_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0") + void send_raw(const std::vector &payload) { if (payload.empty()) - return false; // too short to contain a PDU; refused at the door like any invalid send - return this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); + return; // too short to contain a PDU; refused at the door like any invalid send + this->parent_->queue_pdu(payload[0], std::span(payload).subspan(1), this); } + // The typed request builders below all queue through queue_pdu(), so they share its contract: true + // means the request is queued and will resolve in exactly one terminal callback, false means it was + // refused outright with no callback. Neither says the frame has been transmitted - on_sent() does. // Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which - // create_read_pdu() rejects into an empty PDU and send_pdu() refuses with a false return. + // create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return. bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities, CommandOptions options = {}) { - return this->send_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address, - number_of_entities), - options); + return this->queue_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address, + number_of_entities), + options); } bool read_input_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) { - return this->send_pdu( + return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_INPUT_REGISTERS, start_address, number_of_registers), options); } bool read_holding_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) { - return this->send_pdu( + return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_HOLDING_REGISTERS, start_address, number_of_registers), options); } bool read_coils(uint16_t start_address, uint16_t number_of_coils, CommandOptions options = {}) { - return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options); + return this->queue_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options); } bool read_discrete_inputs(uint16_t start_address, uint16_t number_of_inputs, CommandOptions options = {}) { - return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), - options); + return this->queue_pdu( + helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options); } bool write_single_register(uint16_t start_address, uint16_t value) { - return this->send_pdu(helpers::create_write_single_register_pdu(start_address, value)); + return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value)); } bool write_single_coil(uint16_t address, bool value) { - return this->send_pdu(helpers::create_write_single_coil_pdu(address, value)); + return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value)); } bool write_multiple_registers(uint16_t start_address, std::span values) { - return this->send_pdu(helpers::create_write_registers_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values)); } /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed /// overload. bool write_multiple_coils(uint16_t start_address, std::span values) { - return this->send_pdu(helpers::create_write_coils_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values)); } /// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so /// read-modify-write needs no unpack/repack. bool write_multiple_coils(uint16_t start_address, PackedBits bits) { - return this->send_pdu(helpers::create_write_coils_pdu(start_address, bits)); + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); } inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); } inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index 599be85cb8..f9a00d65f6 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -33,7 +33,11 @@ template class ClientActionBase : public Action, public m /// The frame was written to the wire: fires once per transmission, before any reply, and never for a /// send that ended in on_not_sent. request_pdu is the PDU sent (function code + data). void on_sent(std::span request_pdu) override { this->sent_trigger_.trigger(request_pdu); } - /// Never reached the wire (tx queue full, cleared, or a duplicate write dropped by the hub's dedup). + /// Never reached the wire, from either of two sources. The hub calls this for a request it accepted + /// and then dropped, which happens only when clear_tx_queue_for_address() retires it - a modbus + /// device going offline, say. Everything the hub refuses at the door instead returns false from + /// queue_pdu() with no callback at all, so send_or_resolve_() below turns those into this same + /// callback: a full queue, a duplicate write, or an empty PDU from a rejecting builder. void on_not_sent(std::span request_pdu) override { this->not_sent_trigger_.trigger(request_pdu); } /// A Modbus exception reply. Lives here beside its trigger so every action subclass gets the pairing: /// register_client_action() wires on_error for all of them, so a derived class must not have to @@ -64,7 +68,7 @@ template class ClientActionBase : public Action, public m /// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and /// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call. void send_or_resolve_(std::span pdu) { - if (!this->send_pdu(pdu)) + if (!this->queue_pdu(pdu)) this->on_not_sent(pdu); } @@ -107,7 +111,9 @@ template class ModbusClientSendAction : public ClientActionBase< /// valid for the duration of the trigger. (For a typed-built request the gate can only divert on the /// response, never with an exception status - real device exceptions arrive via on_error, which /// ClientActionBase already routes straight to its trigger, so the typed callbacks below only ever see a -/// success status.) +/// success status.) Each typed callback still checks succeeded() before firing its trigger: that branch +/// is unreachable today, and is kept so a future change to that interception cannot silently deliver an +/// exception as a successful reply. template class TypedClientActionBase : public ClientActionBase { public: Trigger, std::span> *get_custom_response_trigger() { @@ -127,11 +133,6 @@ template class TypedClientActionBase : public ClientActionBase, std::span> custom_response_trigger_; bool custom_response_handled_{false}; }; @@ -154,7 +155,7 @@ template class ReadRegistersAction : public TypedClientActionBas } void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(registers); } @@ -181,7 +182,7 @@ template class ReadBitsAction : public TypedClientActionBaseis_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(bits); } @@ -204,7 +205,7 @@ template class WriteSingleRegisterAction : public TypedClientAct modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...))); } void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } @@ -226,7 +227,7 @@ template class WriteSingleCoilAction : public TypedClientActionB modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...))); } void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } @@ -270,7 +271,7 @@ template class WriteMultipleRegistersAction : public TypedClient } void on_write_multiple_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } @@ -318,7 +319,7 @@ template class WriteMultipleCoilsAction : public TypedClientActi } void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index c4161d454f..da9d29887e 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -160,10 +160,11 @@ void ModbusController::queue_command(ModbusCommandItem command) { } void ModbusController::unqueue_command(const ModbusCommandItem *command) { - // Called as the last action of the command's own callback, and from send() after send_pdu (which may - // synchronously call on_not_sent). Destroying `command` here would leave send() and the hub touching a - // freed object, so we only FLAG it; sweep_completed_one_shots_() erases it later at a safe point. No-op - // for polling commands (they persist and are not in the one-shot list). + // Called as the last action of the command's own callback (on_response/on_error/on_not_sent/ + // on_no_response), which the hub runs from inside its sweep while this entry is still live. + // Destroying `command` here would leave the hub touching a freed object, so we only FLAG it; + // sweep_completed_one_shots_() erases it later at a safe point. No-op for polling commands + // (they persist and are not in the one-shot list). for (auto &item : this->one_shot_command_items_) { if (item.get() == command) { item->pending_removal = true; @@ -494,13 +495,13 @@ ModbusCommandItem ModbusCommandItem::create_custom_command( bool ModbusCommandItem::send() { bool accepted; if (this->function_code_ != FunctionCode::CUSTOM) { - accepted = this->send_pdu(modbus::helpers::create_client_pdu( + accepted = this->queue_pdu(modbus::helpers::create_client_pdu( this->function_code_, this->start_address_, this->register_count_, this->payload.empty() ? nullptr : this->payload.data(), this->payload.size())); } else { // Custom command: the bytes are a complete raw frame (address + PDU). Send the PDU to the frame's own // address (which may differ from this controller's); the hub appends the CRC and routes the response - // back to this item by pointer. (send_raw() is deprecated, so send_pdu() is called with the extracted + // back to this item by pointer. (send_raw() is deprecated, so queue_pdu() is called with the extracted // address. Raw-frame semantics are kept here; the custom_pdu migration is a later step.) std::span frame = this->custom_data_ != nullptr ? std::span(*this->custom_data_) : this->payload; @@ -508,7 +509,7 @@ bool ModbusCommandItem::send() { ESP_LOGW(TAG, "Empty custom command frame, not sent"); accepted = false; } else { - accepted = this->parent_->send_pdu(frame[0], frame.subspan(1), this); + accepted = this->parent_->queue_pdu(frame[0], frame.subspan(1), this); } } // The on_command_sent trigger fires from on_sent() when the frame actually reaches the wire. diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index 5651e07af0..d817888922 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -77,7 +77,7 @@ void PZEMAC::dump_config() { void PZEMAC::reset_energy_() { const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; - this->send_pdu(pdu); + this->queue_pdu(pdu); } } // namespace esphome::pzemac diff --git a/esphome/components/pzemdc/pzemdc.cpp b/esphome/components/pzemdc/pzemdc.cpp index 5e505cde0c..926ad83f09 100644 --- a/esphome/components/pzemdc/pzemdc.cpp +++ b/esphome/components/pzemdc/pzemdc.cpp @@ -65,7 +65,7 @@ void PZEMDC::dump_config() { void PZEMDC::reset_energy() { const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; - this->send_pdu(pdu); + this->queue_pdu(pdu); } } // namespace esphome::pzemdc diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 15c1229a85..99262babce 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -57,6 +57,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. cg.add_define("USE_RP2_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (BTstack delivers the pair + # as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index c2bb93a32e..2a87d617f8 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -24,6 +24,9 @@ void RP2BLETracker::setup() { // Receive the controller's scan reports; the controller queues them from the // BTstack packet handler (IRQ) and delivers here on the main loop. this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_ + // is read at each delivery to decide unclaimed-device logging. + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); #ifdef USE_OTA_STATE_LISTENER // Pause scanning while an OTA update is in flight — the BLE scan competes with // the OTA download on the shared CYW43 radio. Mirrors esp32_ble_tracker. @@ -64,6 +67,10 @@ void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uin void RP2BLETracker::loop() { const uint32_t now = App.get_loop_component_start_time(); + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. + if (!this->merger_.empty()) + this->merger_.sweep(now); if (this->scan_running_ && !this->parent_->is_active()) { // The controller was disabled underneath us (e.g. a lambda calling // rp2040_ble's disable()); the scan died with the stack. Reconcile so the @@ -119,30 +126,32 @@ void RP2BLETracker::dump_config() { YESNO(this->scan_continuous_)); } -void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) { - // Raw callback (the raw-advertisement path). - if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac), - .data = report.data, - .data_len = report.data_len, - .rssi = report.rssi, - .addr_type = report.addr_type}; - this->raw_advertisement_callback_.invoke(adv); - } +// GAP advertising event types as BTstack reports them (Core spec advertising +// report event types; the tracker deliberately does not include BTstack +// headers). ADV_IND and ADV_SCAN_IND are the scannable types. +static constexpr uint8_t ADV_EVENT_TYPE_ADV_IND = 0; +static constexpr uint8_t ADV_EVENT_TYPE_ADV_SCAN_IND = 2; +static constexpr uint8_t ADV_EVENT_TYPE_SCAN_RSP = 4; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - ble_device_base::ESPBTDevice device; - device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len); - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) - found = true; +// Demux advertisements vs scan responses into the shared merger: BTstack +// delivers the pair as separate reports; a scannable advertisement is held +// until its scan response arrives and delivered as one merged frame. +void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) { + if (report.adv_event_type == ADV_EVENT_TYPE_SCAN_RSP) { + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + return; } - // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed - // it and the scan is one-shot (continuous scans would spam). - if (!found && !this->scan_continuous_) - this->discovered_log_.log_device(TAG, device); -#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Stash only while an active scan runs: a passive scan never gets a + // response, and after a stop nothing would sweep the merger, so a late + // report would surface minutes later as a fresh advertisement. + if (this->scan_running_ && this->scan_active_ && + (report.adv_event_type == ADV_EVENT_TYPE_ADV_IND || report.adv_event_type == ADV_EVENT_TYPE_ADV_SCAN_IND)) { + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + App.get_loop_component_start_time()); + return; + } + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); } void RP2BLETracker::start_scan() { @@ -229,11 +238,10 @@ void RP2BLETracker::stop_scan_() { } void RP2BLETracker::fire_scan_end_() { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) -#endif + // Deliver held advertisements whose scan response never came (unmerged) + // BEFORE on_scan_end fires. + this->merger_.flush(); + this->dispatcher_.on_scan_end(); } } // namespace esphome::rp2_ble_tracker diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 054f6a65d2..02bd7dc145 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -4,6 +4,7 @@ #include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" #include "esphome/components/rp2040_ble/rp2040_ble.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -51,25 +52,22 @@ class RP2BLETracker : public Component, // ---- ble_device_base::BLEHub contract ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - this->listeners_.push_back(listener); -#endif + this->dispatcher_.register_listener(listener); } void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { - this->raw_advertisement_callback_ = callback; + this->dispatcher_.set_raw_advertisement_callback(callback); } static constexpr ble_device_base::HubCapabilities get_capabilities() { - // BTstack delivers scan responses as separate advertisement reports rather - // than merging them into the advertisement — consumers relying on - // scan-response fields (device names) get them only where the receiver - // merges per address (Home Assistant does). GATT is available when the - // BTstack connection backend is compiled in (bluetooth_proxy active). + // BTstack delivers scan responses as separate advertisement reports; this + // tracker merges the pair before delivery (shared ScanResponseMerger, + // Bluedroid semantics). GATT is available when the BTstack connection + // backend is compiled in (bluetooth_proxy active). #ifdef USE_BLE_GATT_CLIENT constexpr bool has_gatt = true; #else constexpr bool has_gatt = false; #endif - return {.active_scan = true, .merges_scan_response = false, .gatt = has_gatt, .scan_mode_switch = true}; + return {.active_scan = true, .merges_scan_response = true, .gatt = has_gatt, .scan_mode_switch = true}; } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants. @@ -104,16 +102,13 @@ class RP2BLETracker : public Component, bool scan_pending_before_ota_{false}; // one-shot scan in flight at OTA start, resumed on OTA failure #endif - ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Parsed-advertisement consumers registered through ble_device_base. - // Codegen-sized: no heap allocation, no std::vector template instantiations. - StaticVector listeners_; - // Per-period "Found device" DEBUG log with MAC dedup — shared implementation - // in ble_device_base, identical output on every tracker backend. Guarded like - // its only writer so a no-listener build does not carry an unused vector. - ble_device_base::DiscoveredDeviceLog discovered_log_{}; -#endif + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main loop. Merger clock: stash_adv() reads the + // PARENT's cached loop time (on_scan_report runs inside rp2040_ble's queue + // drain), sweep() this component's — same App.loop() pass, so the delta + // stays non-negative and the 300 ms timeout holds. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; }; } // namespace esphome::rp2_ble_tracker diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index e20925f323..d0c2112ba9 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -198,7 +198,7 @@ async def to_code(config: ConfigType) -> None: psram.request_external_task_stack() # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.1") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index f2ab5a4bc1..cd6d858067 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -29,6 +29,7 @@ from esphome.const import ( UNIT_WATT, ) from esphome.core import CORE, HexInt +from esphome.happy_eyeballs import ensure_happy_eyeballs DOMAIN = "shelly_dimmer" AUTO_LOAD = ["sensor"] @@ -81,6 +82,7 @@ def get_firmware(value): def dl(url): try: + ensure_happy_eyeballs() req = requests.get(url, timeout=30) req.raise_for_status() except requests.exceptions.RequestException as e: diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index e5b3ebb84d..1a5f4f2cf5 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -321,14 +321,18 @@ LAMBDA_PROG = re.compile(r"\bid\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\)(\.?)") class Lambda: def __init__(self, value): - from esphome.cpp_generator import Expression, statement - # pylint: disable=protected-access if isinstance(value, Lambda): self._value = value._value - elif isinstance(value, Expression): - self._value = str(statement(value)) + elif isinstance(value, str): + # The validated-config cache revives Lambdas from strings on the + # upload/logs fast path; keep codegen off that path. + self._value = value else: + from esphome.cpp_generator import Expression, statement + + if isinstance(value, Expression): + value = str(statement(value)) self._value = value self._parts = None self._requires_ids = None diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 84af39a01d..3630b74159 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -472,6 +472,7 @@ #define USE_RP2_BLE_TRACKER #define RP2040_BLE_SCAN_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define USE_BLE_SCAN_RESPONSE_MERGER #define USE_BLE_GATT_CLIENT #define ESPHOME_BLE_GATT_CLIENT_COUNT 1 #define ESPHOME_BLE_CLIENT_MAX_NODES 1 @@ -504,6 +505,7 @@ #define USE_BK72XX_BLE_TRACKER #endif #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define USE_BLE_SCAN_RESPONSE_MERGER #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_SOCKET_IMPL_LWIP_SOCKETS diff --git a/esphome/external_files.py b/esphome/external_files.py index 69423d3999..160a2b6c29 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -14,6 +14,7 @@ import requests import esphome.config_validation as cv from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__ from esphome.core import CORE, EsphomeError, TimePeriodSeconds +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.helpers import write_file from esphome.types import ConfigType @@ -92,6 +93,7 @@ def _write_etag(local_file_path: Path, etag: str | None) -> None: def has_remote_file_changed( url: str, local_file_path: Path, timeout: int = NETWORK_TIMEOUT ) -> bool: + ensure_happy_eyeballs() if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) try: @@ -158,6 +160,7 @@ def compute_local_file_dir(domain: str) -> Path: def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes: + ensure_happy_eyeballs() if CORE.skip_external_update and path.exists(): _LOGGER.debug("Skipping update for %s (refresh disabled)", url) return path.read_bytes() @@ -231,6 +234,7 @@ def download_content_many( seen: dict[Path, str] = {path: url for url, path in items} if not seen: return + ensure_happy_eyeballs() _LOGGER.info("Checking %d %s for updates", len(seen), description) if len(seen) == 1: path, url = next(iter(seen.items())) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 202d4a2bfb..6ed608b171 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -13,6 +13,7 @@ import sys import time from typing import IO, TYPE_CHECKING +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.helpers import ProgressBar, rmtree if TYPE_CHECKING: @@ -755,6 +756,8 @@ def download_with_resume( from esphome.core import EsphomeError + ensure_happy_eyeballs() + dest = Path(dest) part = dest.with_name(dest.name + ".part") meta = part.with_name(part.name + ".meta") @@ -922,6 +925,8 @@ def download_from_mirrors( from esphome.core import EsphomeError + ensure_happy_eyeballs() + # 1. Classify the target: filesystem path or open file object path_target: Path | None = None f: IO[bytes] | None = None diff --git a/esphome/happy_eyeballs.py b/esphome/happy_eyeballs.py new file mode 100644 index 0000000000..ebfb94f1f9 --- /dev/null +++ b/esphome/happy_eyeballs.py @@ -0,0 +1,136 @@ +"""Happy Eyeballs (RFC 8305) connection support for requests/urllib3. + +urllib3 tries each resolved address in sequence with the full connect +timeout, so a network advertising IPv6 DNS without IPv6 connectivity stalls +every download for the whole timeout before IPv4 is tried. +``ensure_happy_eyeballs()`` swaps urllib3's ``create_connection`` for one +that races address families with a short stagger via aiohappyeyeballs, run +on a daemon-thread event loop so callers stay synchronous. +""" + +from __future__ import annotations + +import logging +import socket +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + +_LOGGER = logging.getLogger(__name__) + +# RFC 8305 recommended delay between staggered connection attempts. +HAPPY_EYEBALLS_DELAY = 0.25 + +# Extra seconds the connect thread gets beyond the connect timeout before +# the caller gives up waiting for it. +_THREAD_WAIT_BUFFER = 5.0 + + +def ensure_happy_eyeballs() -> None: + """Make urllib3 (and therefore requests) connect with Happy Eyeballs. + + Idempotent; call before performing requests-based downloads. + """ + stock: Callable[..., socket.socket] | None = None + try: + import urllib3.util.connection + + stock = urllib3.util.connection.create_connection + if getattr(stock, "_esphome_patched", False): + return + + urllib3.util.connection.create_connection = _make_create_connection() + except (ImportError, AttributeError) as err: # urllib3 internals moved + # WARNING: degraded mode brings back the stalls this module prevents. + _LOGGER.warning( + "Happy Eyeballs unavailable (%s); downloads use the slower stock " + "urllib3 connect", + err, + ) + _LOGGER.debug("Happy Eyeballs fallback traceback", exc_info=True) + if stock is not None: + # Latch so the warning fires once, not per download. + stock._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access + + +def _make_create_connection() -> Callable[..., socket.socket]: + """Build a drop-in replacement for urllib3's ``create_connection``.""" + # Deferred so runs that never download skip the ~30 ms asyncio import. + import asyncio + + from aiohappyeyeballs import start_connection + from urllib3.exceptions import LocationParseError + from urllib3.util.connection import ( # noqa: PLC2701 + _set_socket_options, + allowed_gai_family, + ) + from urllib3.util.timeout import _DEFAULT_TIMEOUT # noqa: PLC2701 + + from esphome import async_thread + + def create_connection( + address: tuple[str, int], + timeout: Any = _DEFAULT_TIMEOUT, + source_address: tuple[str, int] | None = None, + socket_options: Any = None, + ) -> socket.socket: + host, port = address + if host.startswith("["): + host = host.strip("[]") + try: + host.encode("idna") + except UnicodeError: + raise LocationParseError(f"'{host}', label empty or too long") from None + + addr_infos = socket.getaddrinfo( + host, port, allowed_gai_family(), socket.SOCK_STREAM + ) + if not addr_infos: + # Same error as stock urllib3. + raise OSError("getaddrinfo returns an empty list") + connect_timeout = ( + socket.getdefaulttimeout() if timeout is _DEFAULT_TIMEOUT else timeout + ) + + def socket_factory(addr_info: Any) -> socket.socket: + family, type_, proto, _, _ = addr_info + sock = socket.socket(family, type_, proto) + try: + _set_socket_options(sock, socket_options) + if source_address: + sock.bind(source_address) + except BaseException: + sock.close() + raise + return sock + + async def connect() -> socket.socket: + return await asyncio.wait_for( + start_connection( + addr_infos, + happy_eyeballs_delay=HAPPY_EYEBALLS_DELAY, + interleave=1, + socket_factory=socket_factory, + ), + connect_timeout, + ) + + wait = ( + None if connect_timeout is None else connect_timeout + _THREAD_WAIT_BUFFER + ) + # on_orphan closes a socket won after the timeout so it cannot leak. + sock = async_thread.run_async( + connect, timeout=wait, on_orphan=socket.socket.close + ) + # aiohappyeyeballs leaves the winning socket non-blocking; restore the + # blocking-with-timeout behavior urllib3 callers expect. + try: + sock.settimeout(connect_timeout) + except BaseException: + sock.close() + raise + return sock + + create_connection._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access + return create_connection diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index b4b20cf221..9448b93cc9 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.0 + version: 0.7.1 lvgl/lvgl: version: 9.5.0 fastled/FastLED: diff --git a/esphome/log.py b/esphome/log.py index b120c930d0..1f208bb909 100644 --- a/esphome/log.py +++ b/esphome/log.py @@ -1,5 +1,7 @@ from enum import Enum import logging +import sys +from typing import TextIO from esphome.core import CORE @@ -72,13 +74,30 @@ class ESPHomeLogFormatter(logging.Formatter): return message +def _is_tty(stream: TextIO | None) -> bool: + # A stream can be missing, closed, or not a real file object; colorama + # tolerates all three, so treat them like a redirect and let its own + # handling apply. + if stream is None or getattr(stream, "closed", True): + return False + return hasattr(stream, "isatty") and stream.isatty() + + def setup_log( log_level: int = logging.INFO, include_timestamp: bool = False, ) -> None: - import colorama + # colorama translates ANSI escapes for old Windows consoles and strips + # them from redirected output. POSIX terminals render ANSI natively, and + # dashboard runs escape their color codes before printing, so both would + # use colorama as a plain passthrough; skip the import there (it pulls + # in ctypes, ~3ms on every CLI invocation). + if sys.platform == "win32" or not ( + CORE.dashboard or (_is_tty(sys.stdout) and _is_tty(sys.stderr)) + ): + import colorama - colorama.init() + colorama.init() # Setup logging - will map log level from string to constant logging.basicConfig(level=log_level) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 981e508d5d..d3c6caf60b 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1349,6 +1349,8 @@ class ESPHomeDumper(yaml.SafeDumper): return super().increase_indent(flow, False) +# Mirrored by compiled_config._json_default: a new representer that keeps a +# type round-trippable (like Lambda's) needs a sentinel there too. ESPHomeDumper.add_multi_representer( dict, lambda dumper, value: dumper.represent_mapping("tag:yaml.org,2002:map", value) ) diff --git a/requirements.txt b/requirements.txt index d56a8daec1..4501b733a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ platformio==6.1.19 esptool==5.3.1 click==8.3.3 aioesphomeapi==45.7.0 +aiohappyeyeballs==2.6.2 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import diff --git a/script/build_language_schema.py b/script/build_language_schema.py index f6dcf00851..2b64cb0256 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1134,13 +1134,29 @@ def convert_keys(converted, schema, path): else: converted["key"] = "String" key_string_match = re.search( - r"", str(k), re.IGNORECASE + r"", str(k), re.IGNORECASE ) if key_string_match: converted["key_type"] = key_string_match.group(1) else: converted["key_type"] = str(k) + # A marker-wrapped callable key (e.g. script.execute's + # ``cv.Optional(validate_parameter_name)``) is a wildcard matcher; + # ``str(marker)`` is the function repr, whose heap address would + # churn the dump every build. Normalize like the bare-callable + # branch above: record the validator name in ``key_type`` and file + # the config var under ``string``. + key_name = str(k) + if isinstance(k, vol.Marker) and callable(k.schema): + key_string_match = re.search( + r"", key_name, re.IGNORECASE + ) + result["key_type"] = ( + key_string_match.group(1) if key_string_match else key_name + ) + key_name = "string" + # ``cv.OnlyWith`` / ``cv.OnlyWithout`` expose ``default`` as # a property that returns ``vol.UNDEFINED`` when the gating # component isn't loaded — and at schema-generation time @@ -1220,7 +1236,7 @@ def convert_keys(converted, schema, path): for base_k, base_v in get_overridden_config(k, converted).items(): if base_k in result and base_v == result[base_k]: result.pop(base_k) - converted["schema"][S_CONFIG_VARS][str(k)] = result + converted["schema"][S_CONFIG_VARS][key_name] = result if "key" in converted and converted["key"] == "String": config_vars = converted["schema"]["config_vars"] assert len(config_vars) == 1 diff --git a/tests/benchmarks/python/test_compiled_config_bench.py b/tests/benchmarks/python/test_compiled_config_bench.py index 5c8892f8d0..4d7821f704 100644 --- a/tests/benchmarks/python/test_compiled_config_bench.py +++ b/tests/benchmarks/python/test_compiled_config_bench.py @@ -52,7 +52,7 @@ def _prime_cache(yaml_path: Path) -> None: Mirrors ``esphome compile``: ``read_config`` populates ``CORE.config``, then ``update_storage_json`` writes both the StorageJSON sidecar and - the ``.validated.yaml`` compiled-config cache. + the ``.validated.json`` compiled-config cache. """ CORE.config_path = yaml_path config = read_config({}, skip_external_update=True) diff --git a/tests/component_tests/ld6002b/test_final_validate.py b/tests/component_tests/ld6002b/test_final_validate.py index 49fa35eb13..0bb091533b 100644 --- a/tests/component_tests/ld6002b/test_final_validate.py +++ b/tests/component_tests/ld6002b/test_final_validate.py @@ -1,30 +1,62 @@ -"""Tests for the wake button's wakeup_pin requirement in ld6002b.""" +"""Tests for the ld6002b validators that reach across platforms. + +wake needs a pin on its own hub, apply_area needs a select on its own hub, and +area_config needs both a button and a select on its own hub. Every one of them +is a same-instance check, which is the half that breaks quietly. +""" from __future__ import annotations import pytest -from esphome.components.ld6002b.button import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +from esphome.components.ld6002b.button import ( + CONFIG_SCHEMA as BUTTON_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as BUTTON_FINAL_VALIDATE_SCHEMA, +) +from esphome.components.ld6002b.const import CONF_AREA_CONFIG, CONF_Z_MIN +from esphome.components.ld6002b.number import ( + CONFIG_SCHEMA as NUMBER_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as NUMBER_FINAL_VALIDATE_SCHEMA, +) from esphome.config import Config import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_WAKEUP_PIN, PlatformFramework +from esphome.const import ( + CONF_AREA_ID, + CONF_BUTTON, + CONF_ID, + CONF_WAKEUP_PIN, + PlatformFramework, +) from esphome.core import ID from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable HUB_ID = "ld6002b_hub" +OTHER_HUB_ID = "ld6002b_other" -def _full_config(hub: ConfigType) -> Config: +def _full_config( + hub: ConfigType, + *, + selects: list[ConfigType] | None = None, + buttons: list[ConfigType] | None = None, +) -> Config: """A full config carrying one ld6002b hub, as the ID pass leaves it. final_validate resolves the hub through get_path_for_id, so the declaring path has to be registered the way validate_config registers it: the path of the id value itself, whose parent is the hub's own config. + + The platform lists are what the cross-platform validators scan, so a test can + say which of them exist and which hub each one names. """ full = Config() full["ld6002b"] = [hub] full.declare_ids.append((hub[CONF_ID], ["ld6002b", 0, CONF_ID])) + if selects is not None: + full["select"] = selects + if buttons is not None: + full[CONF_BUTTON] = buttons return full @@ -44,10 +76,33 @@ def _buttons(**buttons: str) -> ConfigType: return config +def _select(*, hub_id: str = HUB_ID) -> ConfigType: + """A select platform config naming area_id on the given hub.""" + return { + "ld6002b_id": ID(hub_id, is_declaration=False, type="ld6002b"), + CONF_AREA_ID: {"name": "Area ID"}, + } + + +def _area_numbers(*, hub_id: str = HUB_ID) -> ConfigType: + """A number platform config carrying one area_config bound.""" + return { + "ld6002b_id": ID(hub_id, is_declaration=False, type="ld6002b"), + CONF_AREA_CONFIG: {CONF_Z_MIN: {"name": "Area Z Min"}}, + } + + def _validated(config: ConfigType) -> ConfigType: """Run the button schema, then the final validation the hub is checked in.""" - config = CONFIG_SCHEMA(config) - FINAL_VALIDATE_SCHEMA(config) + config = BUTTON_CONFIG_SCHEMA(config) + BUTTON_FINAL_VALIDATE_SCHEMA(config) + return config + + +def _validated_numbers(config: ConfigType) -> ConfigType: + """The same two passes for the number platform.""" + config = NUMBER_CONFIG_SCHEMA(config) + NUMBER_FINAL_VALIDATE_SCHEMA(config) return config @@ -80,3 +135,82 @@ def test_other_buttons_do_not_need_the_pin( ) _validated(_buttons(get_delay="Get Delay")) + + +def test_apply_area_without_select_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """apply_area sends the staged bounds to whichever area the select names.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False)) + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^apply_area requires select\.area_id for the same ld6002b instance" + r" @ data\['apply_area'\]$" + ), + ): + _validated(_buttons(apply_area="Apply Area")) + + +def test_apply_area_select_on_another_hub_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """A select exists, but on a second ld6002b -- which cannot serve this one.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config( + _hub(wakeup_pin=False), selects=[_select(hub_id=OTHER_HUB_ID)] + ), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^apply_area requires select\.area_id for the same ld6002b instance" + r" @ data\['apply_area'\]$" + ), + ): + _validated(_buttons(apply_area="Apply Area")) + + +def test_area_config_without_apply_area_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The six numbers only stage a write; apply_area is what sends it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config(_hub(wakeup_pin=False), selects=[_select()]), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^area_config requires button\.apply_area for the same ld6002b instance" + r" @ data\['area_config'\]$" + ), + ): + _validated_numbers(_area_numbers()) + + +def test_area_config_without_select_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The validator's other half: the staged bounds also need an area to land in.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config( + _hub(wakeup_pin=False), buttons=[_buttons(apply_area="Apply Area")] + ), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^area_config requires select\.area_id for the same ld6002b instance" + r" @ data\['area_config'\]$" + ), + ): + _validated_numbers(_area_numbers()) diff --git a/tests/components/ble_device_base/__init__.py b/tests/components/ble_device_base/__init__.py index c7745063f4..b4f6a36012 100644 --- a/tests/components/ble_device_base/__init__.py +++ b/tests/components/ble_device_base/__init__.py @@ -6,10 +6,14 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # resolve_irk() is compiled only when a sensor configures irk: # (request_irk_support() emits USE_BLE_DEVICE_IRK). The unit-test build has # no sensors, so emit the define here to put the real IRK path under test. + # Likewise the scan-response merger (emitted by the split-report trackers) + # and the listener vector it dispatches into (codegen-sized by consumers). async def to_code_testing(config): cg.add_define("USE_BLE_DEVICE_IRK") # The gatt contract test exercises the gated lookup helpers; compile # their definitions (ble_gatt_client.cpp) into the test build. cg.add_define("USE_BLE_GATT_CLIENT") + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") + cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", 4) manifest.to_code = to_code_testing diff --git a/tests/components/ble_device_base/test_scan_response_merger.cpp b/tests/components/ble_device_base/test_scan_response_merger.cpp new file mode 100644 index 0000000000..013c7bf8f9 --- /dev/null +++ b/tests/components/ble_device_base/test_scan_response_merger.cpp @@ -0,0 +1,183 @@ +// The host test build gets this from the manifest override; clang-tidy does not. +#ifndef USE_BLE_SCAN_RESPONSE_MERGER +#define USE_BLE_SCAN_RESPONSE_MERGER +#endif + +#include + +#include +#include +#include + +#include "esphome/components/ble_device_base/scan_response_merger.h" + +namespace esphome::ble_device_base::testing { +namespace { + +// Pins the merge policy three trackers share (ln882h, rp2, bk72xx): slot +// bookkeeping, the same-device reuse path, the table-full fallback, the +// 62-byte truncation, the advertisement-RSSI choice and the raw_only gate. +// Delivery is observed through a real AdvDispatcher: the raw callback sees +// every frame (including raw_only), a listener only the parsed ones. + +struct DeliveredFrame { + uint64_t address; + std::vector data; + int8_t rssi; +}; + +struct RawCapture { + std::vector frames; + + static void trampoline(void *self, const RawAdvertisement &adv) { + auto *capture = static_cast(self); + capture->frames.push_back({adv.address, std::vector(adv.data, adv.data + adv.data_len), adv.rssi}); + } +}; + +class CountingListener : public ESPBTDeviceListener { + public: + bool parse_device(const ESPBTDevice &device) override { + this->parsed++; + return true; // claimed: keeps the discovered log quiet + } + int parsed{0}; +}; + +class ScanResponseMergerTest : public ::testing::Test { + protected: + void SetUp() override { + this->dispatcher_.set_raw_advertisement_callback({&this->raw_, &RawCapture::trampoline}); + this->dispatcher_.register_listener(&this->listener_); + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, "test"); + } + + void stash_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill, uint32_t now = 0) { + std::vector data(data_len, fill); + this->merger_.stash_adv(mac, rssi, 0, data.data(), data_len, now); + } + + void scan_rsp_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill) { + std::vector data(data_len, fill); + this->merger_.submit_scan_rsp(mac, rssi, 0, data.data(), data_len); + } + + ScanResponseMerger merger_; + AdvDispatcher dispatcher_; + RawCapture raw_; + CountingListener listener_; + bool scan_continuous_{true}; +}; + +constexpr uint8_t MAC_A[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; +constexpr uint8_t MAC_B[6] = {0x11, 0x12, 0x13, 0x14, 0x15, 0x16}; + +TEST_F(ScanResponseMergerTest, MatchedPairDeliversOneMergedFrameWithAdvRssi) { + this->stash_(MAC_A, -40, 20, 0xAA); + EXPECT_TRUE(this->raw_.frames.empty()); // held, not delivered + + this->scan_rsp_(MAC_A, -70, 10, 0xBB); + ASSERT_EQ(this->raw_.frames.size(), 1u); + const auto &frame = this->raw_.frames[0]; + ASSERT_EQ(frame.data.size(), 30u); // adv + response as ONE frame + EXPECT_EQ(frame.data[0], 0xAA); + EXPECT_EQ(frame.data[19], 0xAA); + EXPECT_EQ(frame.data[20], 0xBB); + // The advertisement's RSSI, never the scan response's. + EXPECT_EQ(frame.rssi, -40); + EXPECT_EQ(this->listener_.parsed, 1); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, ReAdvertisementDeliversHeldFrameAndReusesSlot) { + this->stash_(MAC_A, -40, 20, 0xAA); + this->stash_(MAC_A, -45, 22, 0xCC); // same device again: first frame is delivered + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 20u); + EXPECT_EQ(this->raw_.frames[0].rssi, -40); + EXPECT_FALSE(this->merger_.empty()); // the second advertisement now holds the slot + + this->scan_rsp_(MAC_A, -70, 5, 0xBB); + ASSERT_EQ(this->raw_.frames.size(), 2u); + EXPECT_EQ(this->raw_.frames[1].data.size(), 27u); // 22 + 5, merged from the reused slot + EXPECT_EQ(this->raw_.frames[1].rssi, -45); +} + +TEST_F(ScanResponseMergerTest, FullTableDegradesToUnmergedDelivery) { + uint8_t mac[6] = {0x20, 0x00, 0x00, 0x00, 0x00, 0x00}; + for (uint8_t i = 0; i < 8; i++) { + mac[5] = i; + this->stash_(mac, -50, 10, i); + } + EXPECT_TRUE(this->raw_.frames.empty()); // 8 slots, all held + + mac[5] = 8; + this->stash_(mac, -50, 10, 8); // 9th device: no slot left + ASSERT_EQ(this->raw_.frames.size(), 1u); // delivered immediately, unmerged + EXPECT_EQ(this->raw_.frames[0].data.size(), 10u); + + this->merger_.flush(); // the 8 held frames are all still intact + EXPECT_EQ(this->raw_.frames.size(), 9u); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, MergeTruncatesAtBufferCapacity) { + this->stash_(MAC_A, -40, 31, 0xAA); + this->scan_rsp_(MAC_A, -70, 40, 0xBB); // only 31 bytes of room remain + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 62u); + EXPECT_EQ(this->raw_.frames[0].data[31], 0xBB); + EXPECT_EQ(this->raw_.frames[0].data[61], 0xBB); +} + +TEST_F(ScanResponseMergerTest, UnmatchedScanResponseIsRawOnly) { + this->scan_rsp_(MAC_B, -60, 12, 0xDD); + ASSERT_EQ(this->raw_.frames.size(), 1u); // still forwarded on the raw path + EXPECT_EQ(this->raw_.frames[0].rssi, -60); + EXPECT_EQ(this->listener_.parsed, 0); // but never parsed for listeners +} + +TEST_F(ScanResponseMergerTest, AddrTypeIsPartOfTheMatchKey) { + std::vector adv(20, 0xAA); + this->merger_.stash_adv(MAC_A, -40, /*addr_type=*/0, adv.data(), adv.size(), 0); + std::vector rsp(10, 0xBB); + this->merger_.submit_scan_rsp(MAC_A, -70, /*addr_type=*/1, rsp.data(), rsp.size()); + // Same MAC, different addr_type: no merge — the response goes out raw_only. + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 10u); + EXPECT_EQ(this->listener_.parsed, 0); + EXPECT_FALSE(this->merger_.empty()); // the advertisement is still held +} + +TEST_F(ScanResponseMergerTest, SweepDeliversOnlyPastTheTimeout) { + this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000); + this->merger_.sweep(1300); // exactly 300 ms: not yet past the timeout + EXPECT_TRUE(this->raw_.frames.empty()); + this->merger_.sweep(1301); + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].rssi, -40); + EXPECT_EQ(this->listener_.parsed, 1); // timeout delivery is a full parse, not raw_only + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, FlushDeliversEverythingImmediately) { + this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000); + this->stash_(MAC_B, -50, 15, 0xBB, /*now=*/1000); + this->merger_.flush(); + EXPECT_EQ(this->raw_.frames.size(), 2u); + EXPECT_EQ(this->listener_.parsed, 2); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, UnboundMergerDropsInsteadOfCrashing) { + ScanResponseMerger unbound; + std::vector data(20, 0xAA); + unbound.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0); + unbound.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size()); + unbound.sweep(1000); + unbound.flush(); // no null jump anywhere + EXPECT_TRUE(unbound.empty()); +} + +} // namespace +} // namespace esphome::ble_device_base::testing diff --git a/tests/components/ld6002b/common.yaml b/tests/components/ld6002b/common.yaml index e31af49aec..ee881bd787 100644 --- a/tests/components/ld6002b/common.yaml +++ b/tests/components/ld6002b/common.yaml @@ -42,6 +42,32 @@ sensor: name: Target-3 Dop cluster_id: name: Target-3 Cluster + interference_area_0: + x_min: + name: Interference-0 X Min + x_max: + name: Interference-0 X Max + y_min: + name: Interference-0 Y Min + y_max: + name: Interference-0 Y Max + z_min: + name: Interference-0 Z Min + z_max: + name: Interference-0 Z Max + detection_area_0: + x_min: + name: Detection-0 X Min + x_max: + name: Detection-0 X Max + y_min: + name: Detection-0 Y Min + y_max: + name: Detection-0 Y Max + z_min: + name: Detection-0 Z Min + z_max: + name: Detection-0 Z Max binary_sensor: - platform: ld6002b @@ -50,6 +76,8 @@ binary_sensor: name: Presence target_1: name: Target-1 Presence + detection_area_0: + name: Detection Area-0 Presence text_sensor: - platform: ld6002b @@ -70,6 +98,19 @@ number: name: Z Max low_power_sleep_time: name: Low Power Sleep + area_config: + x_min: + name: Area X Min + x_max: + name: Area X Max + y_min: + name: Area Y Min + y_max: + name: Area Y Max + z_min: + name: Area Z Min + z_max: + name: Area Z Max select: - platform: ld6002b @@ -80,6 +121,8 @@ select: name: Trigger Speed installation_mode: name: Installation + area_id: + name: Area ID switch: - platform: ld6002b @@ -94,6 +137,16 @@ switch: button: - platform: ld6002b ld6002b_id: ld6002b_radar + apply_area: + name: Apply Area + auto_interference: + name: Auto Interference + get_areas: + name: Get Areas + clear_interference: + name: Clear Interference + reset_detection_area: + name: Reset Detection get_delay: name: Get Delay get_sensitivity: diff --git a/tests/components/modbus/heap_probe_test.cpp b/tests/components/modbus/heap_probe_test.cpp index ddf905a8df..869b280b0d 100644 --- a/tests/components/modbus/heap_probe_test.cpp +++ b/tests/components/modbus/heap_probe_test.cpp @@ -134,7 +134,7 @@ TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { size_t total = 0; for (int i = 0; i != n; i++) { req[2] = static_cast(i); // distinct start addresses: identical frames would dedup, not enqueue - total += sample([&] { device.send_pdu(req); }).count; + total += sample([&] { device.queue_pdu(req); }).count; } printf("HEAPPROBE queue_%d_typical_commands total_allocs=%zu\n", n, total); EXPECT_EQ(total, 0u); @@ -151,11 +151,11 @@ TEST(HeapProbe, WriteBehindQueuedReadsAppendsAllocationFree) { req.assign(read_pdu, read_pdu + sizeof(read_pdu)); for (int i = 0; i != 3; i++) { req[2] = static_cast(i); // distinct start addresses: identical frames would dedup, not enqueue - device.send_pdu(req); + device.queue_pdu(req); } const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - Sample append = sample([&] { device.send_pdu(write_pdu); }); + Sample append = sample([&] { device.queue_pdu(write_pdu); }); printf("HEAPPROBE write_append count=%zu bytes=%zu\n", append.count, append.bytes); EXPECT_EQ(append.count, 0u); } @@ -180,7 +180,7 @@ TEST(HeapProbe, ResponseHandlingIsAllocationFreeAfterWarmup) { const uint8_t small_resp[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; auto round_trip = [&](std::span response_pdu) { - device.send_pdu(req); + device.queue_pdu(req); hub.loop(); // transmit; the tx queue is empty during the measured receive below uart.inject_frame(0x02, response_pdu); return sample([&] { hub.loop(); }); // receive + parse + match + dispatch diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 4d5b4e7ee8..c2a36c0da7 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -116,7 +116,7 @@ TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); ASSERT_EQ(hub.queued_frames(), 1u); hub.force_send_next(); ASSERT_EQ(hub.queued_frames(), 0u); @@ -141,7 +141,7 @@ TEST(ModbusClientHubNoResponse, NoRetryDropsWaitingFrame) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.timeout_waiting(); @@ -157,7 +157,7 @@ TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) { NoResponseProbeHub hub; { RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // device destructor clears its queue entries, including the waiting frame's device pointer } @@ -177,7 +177,7 @@ TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch. @@ -204,7 +204,7 @@ TEST(ModbusClientHubNoResponse, InterruptedShellDeclinedRetryRetiresOnRelease) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; @@ -229,7 +229,7 @@ TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) { NoResponseProbeHub hub; ClearingRetryDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.timeout_waiting(); @@ -246,9 +246,9 @@ TEST(ModbusClientHubPriority, WritesSendBeforeQueuedReads) { const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(read_a); - device.send_pdu(read_b); - device.send_pdu(write_pdu); + device.queue_pdu(read_a); + device.queue_pdu(read_b); + device.queue_pdu(write_pdu); ASSERT_EQ(hub.queued_frames(), 3u); hub.force_send_next(); @@ -266,8 +266,8 @@ TEST(ModbusClientHubPriority, DuplicateQueuedFrameAbsorbedNotDuplicated) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).pending, 2u); // one entry standing for two accepted requests @@ -280,9 +280,9 @@ TEST(ModbusClientHubPriority, InFlightDuplicateRunsOnceMore) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); - device.send_pdu(read_pdu()); // duplicate of the waiting frame + device.queue_pdu(read_pdu()); // duplicate of the waiting frame EXPECT_EQ(hub.queued_frames(), 0u); // not queued twice EXPECT_EQ(hub.waiting_command().pending, 2u); @@ -303,9 +303,9 @@ TEST(ModbusClientHubPriority, AbsorbedRequestSurvivesDeviceRetry) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); - device.send_pdu(read_pdu()); // duplicate of the waiting frame -> absorbed + device.queue_pdu(read_pdu()); // duplicate of the waiting frame -> absorbed ASSERT_EQ(hub.waiting_command().pending, 2u); hub.timeout_waiting(); // no response; the device requests a retry @@ -459,8 +459,8 @@ TEST(ModbusClientHubPriority, WritesThenOneShotReadsThenContinuousPolls) { device.read_holding_registers(0x100, 2, {.continuous = true}); const uint8_t one_shot[] = {0x03, 0x02, 0x00, 0x00, 0x01}; const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(one_shot); - device.send_pdu(write_pdu); + device.queue_pdu(one_shot); + device.queue_pdu(write_pdu); ASSERT_EQ(hub.queued_frames(), 3u); EXPECT_EQ(hub.queued(0).priority(), CommandPriority::CONTINUOUS); EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); @@ -482,7 +482,7 @@ TEST(ModbusClientHubPriority, ContinuousIgnoredForWrites) { RetryingDevice device(&hub, 0x02, /*retry=*/false); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(write_pdu, {.continuous = true}); + device.queue_pdu(write_pdu, {.continuous = true}); ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); EXPECT_FALSE(hub.queued(0).continuous); @@ -530,8 +530,8 @@ TEST(ModbusClientHubPriority, DuplicateQueuedWriteRefused) { SentCountingDevice device(&hub, 0x02); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - EXPECT_TRUE(device.send_pdu(write_pdu)); - EXPECT_FALSE(device.send_pdu(write_pdu)); // duplicate write: refused + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); // duplicate write: refused hub.sweep_for_test(); ASSERT_EQ(hub.queued_frames(), 1u); @@ -547,8 +547,8 @@ TEST(ModbusClientHubPriority, DuplicateCustomFunctionCodeRefused) { SentCountingDevice device(&hub, 0x02); const uint8_t custom_pdu[] = {0x41, 0x01, 0x02}; // user-defined function code - EXPECT_TRUE(device.send_pdu(custom_pdu)); - EXPECT_FALSE(device.send_pdu(custom_pdu)); // duplicate custom command: refused + EXPECT_TRUE(device.queue_pdu(custom_pdu)); + EXPECT_FALSE(device.queue_pdu(custom_pdu)); // duplicate custom command: refused hub.sweep_for_test(); ASSERT_EQ(hub.queued_frames(), 1u); @@ -562,8 +562,8 @@ TEST(ModbusClientHubPriority, AnonymousDuplicateDroppedNotPromoted) { NoResponseProbeHub hub; const uint8_t read[] = {0x03, 0x01, 0x00, 0x00, 0x02}; - hub.send_pdu(0x02, read); - hub.send_pdu(0x02, read); // anonymous duplicate: dropped + hub.queue_pdu(0x02, read); + hub.queue_pdu(0x02, read); // anonymous duplicate: dropped ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).pending, 1u); // never absorbed for a null owner @@ -575,13 +575,13 @@ TEST(ModbusClientHubPriority, RetriedReadGoesBehindFreshReads) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); - hub.force_send_next(); // the frame that will time out and retry - device.send_pdu(read_pdu()); // waiting duplicate: absorbed into the waiting entry + device.queue_pdu(read_pdu()); + hub.force_send_next(); // the frame that will time out and retry + device.queue_pdu(read_pdu()); // waiting duplicate: absorbed into the waiting entry const uint8_t fresh_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t fresh_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - device.send_pdu(fresh_a); - device.send_pdu(fresh_b); + device.queue_pdu(fresh_a); + device.queue_pdu(fresh_b); ASSERT_EQ(hub.queued_frames(), 2u); hub.timeout_waiting(); // device retries; the entry returns to READY behind the fresh reads @@ -608,9 +608,9 @@ TEST(ModbusClientHubPriority, AbsorbedDuplicateKeepsPlaceInLine) { const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - device.send_pdu(read_a); - device.send_pdu(read_b); - device.send_pdu(read_a); // duplicate of the older entry: absorbed, place unchanged + device.queue_pdu(read_a); + device.queue_pdu(read_b); + device.queue_pdu(read_a); // duplicate of the older entry: absorbed, place unchanged ASSERT_EQ(hub.queued_frames(), 2u); const ModbusDeviceCommand *next = hub.next_ready(); @@ -626,14 +626,14 @@ TEST(ModbusClientHubPriority, RetriedWriteKeepsWritePriorityAndStaysNonRequeueab RetryingDevice device(&hub, 0x02, /*retry=*/true); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(write_pdu); + device.queue_pdu(write_pdu); hub.force_send_next(); hub.timeout_waiting(); // no response -> device requests retry -> back to READY ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); // retry preserves the WRITE class - device.send_pdu(write_pdu); // duplicate of the retried write + device.queue_pdu(write_pdu); // duplicate of the retried write ASSERT_EQ(hub.queued_frames(), 1u); // still not queued twice... EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); hub.sweep_for_test(); @@ -655,7 +655,7 @@ TEST(ModbusClientHubSent, BlockedHubDefersInsteadOfFailing) { AlwaysBlockedHub hub; SentCountingDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); hub.send_next_for_test(); EXPECT_EQ(device.sent_count_, 0); @@ -673,7 +673,7 @@ TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) { hub.setup(); // frame timing derives from the baud rate SentCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); EXPECT_EQ(device.sent_count_, 0); // queued only - nothing sent yet hub.send_next_for_test(); @@ -703,7 +703,7 @@ TEST(ModbusClientHubSent, SendRejectedAfterDelayLeavesFrameReady) { hub.setup(); SentCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); // gate passes, send_frame_ rejects on the post-delay re-check EXPECT_EQ(device.sent_count_, 0); // nothing transmitted @@ -766,7 +766,7 @@ TEST(ModbusClientHubCallbackCount, SingleReadSingleCallback) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); drain_with_responses(hub, OK_RESPONSE); EXPECT_EQ(device.data_count_, 1); @@ -781,8 +781,8 @@ TEST(ModbusClientHubCallbackCount, DuplicateReadExactlyTwoCallbacks) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); int cycles = drain_with_responses(hub, OK_RESPONSE); EXPECT_EQ(cycles, 2); @@ -812,8 +812,8 @@ TEST(ModbusClientHubCallbackCount, ClearFromResponseResolvesDuplicateWithNotSent NoResponseProbeHub hub; ClearOnFirstResponseDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_TRUE(device.send_pdu(read_pdu())); // absorbed: one entry, pending 2 + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); // absorbed: one entry, pending 2 hub.force_send_next(); hub.receive_frame_for_test(0x02, OK_RESPONSE); // response -> on_response -> clear, then sweep @@ -829,9 +829,9 @@ TEST(ModbusClientHubCallbackCount, TripleReadRefusesTheThird) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_FALSE(device.send_pdu(read_pdu())); // the entry is already at its cap + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_FALSE(device.queue_pdu(read_pdu())); // the entry is already at its cap hub.sweep_for_test(); EXPECT_EQ(device.not_sent_count_, 0); // refused synchronously, nothing owed int cycles = drain_with_responses(hub, OK_RESPONSE); @@ -849,8 +849,8 @@ TEST(ModbusClientHubCallbackCount, DuplicateWriteRefusedWithoutLifecycle) { DataCountingDevice device(&hub, 0x02); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - EXPECT_TRUE(device.send_pdu(write_pdu)); - EXPECT_FALSE(device.send_pdu(write_pdu)); + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); hub.sweep_for_test(); EXPECT_EQ(device.terminals(), 0); // the accepted write has not resolved; the other never existed @@ -867,7 +867,7 @@ TEST(ModbusClientHubCallbackCount, ErrorResponseIsSoleTerminal) { hub.setup(); DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); const uint8_t exception_response[] = {0x83, 0x02}; hub.receive_frame_for_test(0x02, exception_response); @@ -886,7 +886,7 @@ TEST(ModbusClientHubCallbackCount, NoResponseIsSoleTerminalAndNotSentHasNoSent) hub.setup(); DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); hub.timeout_waiting(); EXPECT_EQ(device.no_response_count_, 1); @@ -896,8 +896,8 @@ TEST(ModbusClientHubCallbackCount, NoResponseIsSoleTerminalAndNotSentHasNoSent) // Unabsorbable duplicate: the second identical write is refused at the door - no lifecycle, no // terminal, nothing sent. const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - EXPECT_TRUE(device.send_pdu(write_pdu)); - EXPECT_FALSE(device.send_pdu(write_pdu)); + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); hub.sweep_for_test(); EXPECT_EQ(device.not_sent_count_, 0); EXPECT_EQ(device.terminals(), 1); // still just the read's timeout @@ -920,7 +920,7 @@ TEST(ModbusClientHubCallbackCount, RetryLifecyclesEachGetSentAndTerminal) { DataCountingDevice device(&hub, 0x02); device.retries_ = 1; // ask for exactly one retry - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); hub.timeout_waiting(); // lifecycle 1: sent + no_response (retry requested -> re-queued) ASSERT_EQ(hub.queued_frames(), 1u); @@ -946,13 +946,13 @@ TEST(ModbusClientHubCallbackCount, RetryIsNeverRefusedByFullQueue) { device.retries_ = 1; SentCountingDevice filler(&hub, 0x05); - device.send_pdu(read_pdu()); - hub.force_send_next(); // waiting - device.send_pdu(read_pdu()); // absorbed: two requests pending + device.queue_pdu(read_pdu()); + hub.force_send_next(); // waiting + device.queue_pdu(read_pdu()); // absorbed: two requests pending // Fill the remaining live capacity with distinct frames. for (uint16_t i = 0; hub.entries() < MODBUS_TX_BUFFER_SIZE; i++) { const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; - filler.send_pdu(fill); + filler.queue_pdu(fill); } hub.timeout_waiting(); // retry requested; the entry flips back to READY regardless of capacity @@ -991,10 +991,10 @@ TEST(ModbusClientHubQueue, SendRawTooShortIsRefusedAtTheDoor) { NotSentCountingRawDevice device(&hub, 0x02); #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" - EXPECT_FALSE(device.send_raw({})); // too short to contain a PDU + device.send_raw({}); // too short to contain a PDU; the deprecated void spelling cannot report it #pragma GCC diagnostic pop - EXPECT_EQ(device.not_sent_count_, 0); // refusals are returned, never delivered - EXPECT_TRUE(hub.tx_buffer_empty()); + EXPECT_EQ(device.not_sent_count_, 0); // refused at the door: no callback delivered + EXPECT_TRUE(hub.tx_buffer_empty()); // the only evidence of the refusal is that nothing queued } // A continuous read: every wire transmission pairs one sent with one terminal, ending on the error. @@ -1040,7 +1040,7 @@ class ChainOnSentDevice : public ModbusClientDevice { if (!this->chained_) { this->chained_ = true; const uint8_t follow[] = {0x03, 0x00, 0x09, 0x00, 0x01}; // read holding 0x0009 x1 - this->send_pdu(follow); + this->queue_pdu(follow); } } bool chained_{false}; @@ -1059,9 +1059,9 @@ TEST(ModbusClientHubQueue, ClearAddressQueueNotifiesEveryOwner) { const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; const uint8_t read_c[] = {0x03, 0x03, 0x00, 0x00, 0x02}; - controller_like.send_pdu(read_a); - bystander_same.send_pdu(read_b); - bystander_other.send_pdu(read_c); + controller_like.queue_pdu(read_a); + bystander_same.queue_pdu(read_b); + bystander_other.queue_pdu(read_c); ASSERT_EQ(hub.queued_frames(), 3u); controller_like.clear_tx_queue_for_address(); @@ -1083,8 +1083,8 @@ TEST(ModbusClientHubQueue, ClearAddressDeliversOneTerminalPerAcceptedRequest) { SentCountingDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x01, 0x00, 0x00, 0x02}; - device.send_pdu(read); - device.send_pdu(read); // duplicate: absorbed into the queued entry + device.queue_pdu(read); + device.queue_pdu(read); // duplicate: absorbed into the queued entry ASSERT_EQ(hub.queued_frames(), 1u); ASSERT_EQ(hub.queued(0).pending, 2u); @@ -1103,8 +1103,8 @@ TEST(ModbusClientHubQueue, ClearSentOnInFlightDuplicateStillNotifiesTheDuplicate NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); // absorbed: one entry, pending 2 + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); // absorbed: one entry, pending 2 ASSERT_EQ(hub.queued(0).pending, 2u); hub.force_send_next(); // the frame is sent (WAITING); pending still 2 ASSERT_TRUE(hub.waiting()); @@ -1122,7 +1122,7 @@ TEST(ModbusClientHubQueue, ClearWhileInFlightStillDeliversTheResponse) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // sent, now WAITING ASSERT_TRUE(hub.waiting()); @@ -1151,7 +1151,7 @@ class ResendOnNotSentDevice : public ModbusClientDevice { this->not_sent_count_++; if (this->not_sent_count_ == 1) { const uint8_t again[] = {0x06, 0x00, 0x40, 0x00, 0x01}; // a write: ranked first at selection, not by position - this->send_pdu(again); + this->queue_pdu(again); } } int not_sent_count_{0}; @@ -1165,7 +1165,7 @@ TEST(ModbusClientHubQueue, ClearAddressReentrantResendSurvives) { ResendOnNotSentDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - device.send_pdu(read); + device.queue_pdu(read); ASSERT_EQ(hub.queued_frames(), 1u); hub.clear_tx_queue_for_address(0x02); @@ -1187,8 +1187,8 @@ TEST(ModbusClientHubQueue, ClearAddressReentrantResendNotSwept) { const uint8_t read_victim[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_other[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - resender.send_pdu(read_victim); - bystander_other.send_pdu(read_other); + resender.queue_pdu(read_victim); + bystander_other.queue_pdu(read_other); ASSERT_EQ(hub.queued_frames(), 2u); hub.clear_tx_queue_for_address(0x02); @@ -1212,13 +1212,14 @@ class AlwaysResendDevice : public ModbusClientDevice { void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; const uint8_t again[] = {0x03, 0x00, 0x50, 0x00, 0x01}; - this->send_pdu(again); + this->queue_pdu(again); } int not_sent_count_{0}; }; -// From inside on_not_sent, clears ANOTHER address - those victims must still be notified (the per-device -// guard suppresses deliveries only to a device already inside its own on_not_sent()). +// From inside on_not_sent, clears ANOTHER address - those victims must still be notified. Nothing +// suppresses that: a re-entrant clear only flips states, retire() is a no-op on an already-retired +// entry, and each entry still owes one notification per un-run request until pending reaches zero. class ClearOtherOnNotSentDevice : public ModbusClientDevice { public: ClearOtherOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} @@ -1238,9 +1239,9 @@ TEST(ModbusClientHubQueue, PendingNeverExceedsTheServableCap) { AlwaysResendDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x50, 0x00, 0x01}; - EXPECT_TRUE(device.send_pdu(read)); - EXPECT_TRUE(device.send_pdu(read)); - EXPECT_FALSE(device.send_pdu(read)); // at the cap: refused + EXPECT_TRUE(device.queue_pdu(read)); + EXPECT_TRUE(device.queue_pdu(read)); + EXPECT_FALSE(device.queue_pdu(read)); // at the cap: refused ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).pending, 2u); @@ -1260,12 +1261,12 @@ TEST(ModbusClientHubQueue, FullQueueRefusesWithoutCallbacks) { // Fill the queue with distinct frames (distinct start addresses keep the dedup from absorbing them). for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) { const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; - filler.send_pdu(fill); + filler.queue_pdu(fill); } ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - EXPECT_FALSE(device.send_pdu(read)); // refused synchronously + EXPECT_FALSE(device.queue_pdu(read)); // refused synchronously hub.sweep_for_test(); EXPECT_EQ(device.not_sent_count_, 0); // nothing was accepted, so nothing is owed @@ -1298,13 +1299,13 @@ TEST(ModbusClientHubQueue, SelfClearFromNotSentResolvesEveryRequest) { const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; const uint8_t read_c[] = {0x03, 0x00, 0x30, 0x00, 0x01}; - clearer.send_pdu(read_a); - clearer.send_pdu(read_b); - bystander.send_pdu(read_c); + clearer.queue_pdu(read_a); + clearer.queue_pdu(read_b); + bystander.queue_pdu(read_c); ASSERT_EQ(hub.queued_frames(), 3u); - EXPECT_FALSE(clearer.send_pdu(std::span{})); // empty: refused, no callback - clearer.clear_tx_queue_for_address(); // the clear the handler used to make + EXPECT_FALSE(clearer.queue_pdu(std::span{})); // empty: refused, no callback + clearer.clear_tx_queue_for_address(); // the clear the handler used to make hub.sweep_for_test(); @@ -1323,8 +1324,8 @@ TEST(ModbusClientHubQueue, NestedClearFromNotSentStillNotifiesVictims) { const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - clearer.send_pdu(read_a); - victim.send_pdu(read_b); + clearer.queue_pdu(read_a); + victim.queue_pdu(read_b); ASSERT_EQ(hub.queued_frames(), 2u); hub.clear_tx_queue_for_address(0x02); // clearer's on_not_sent clears address 0x03 in turn @@ -1345,7 +1346,7 @@ class ResendSecondFrameDevice : public ModbusClientDevice { this->not_sent_count_++; if (this->not_sent_count_ == 1) { const uint8_t same_as_r2[] = {0x03, 0x00, 0x22, 0x00, 0x01}; - this->send_pdu(same_as_r2); + this->queue_pdu(same_as_r2); } } int not_sent_count_{0}; @@ -1360,8 +1361,8 @@ TEST(ModbusClientHubQueue, SweepDedupSkipsDeletedFrames) { const uint8_t r1[] = {0x03, 0x00, 0x21, 0x00, 0x01}; const uint8_t r2[] = {0x03, 0x00, 0x22, 0x00, 0x01}; - device.send_pdu(r1); - device.send_pdu(r2); + device.queue_pdu(r1); + device.queue_pdu(r2); ASSERT_EQ(hub.queued_frames(), 2u); hub.clear_tx_queue_for_address(0x02); @@ -1383,7 +1384,7 @@ class ResendAndClearOnNotSentDevice : public ModbusClientDevice { void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; const uint8_t again[] = {0x03, 0x00, 0x70, 0x00, 0x01}; - this->send_pdu(again); + this->queue_pdu(again); this->clear_tx_queue_for_address(); } int not_sent_count_{0}; @@ -1397,7 +1398,7 @@ TEST(ModbusClientHubQueue, ResendAndClearFromNotSentCannotExtendTheSweep) { ResendAndClearOnNotSentDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x70, 0x00, 0x01}; - device.send_pdu(read); + device.queue_pdu(read); hub.clear_tx_queue_for_address(0x02); hub.sweep_for_test(); @@ -1421,8 +1422,8 @@ TEST(ModbusClientHubQueue, ClearDeviceQueueDropsSilently) { const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; - device.send_pdu(read_a); - device.send_pdu(read_b); + device.queue_pdu(read_a); + device.queue_pdu(read_b); ASSERT_EQ(hub.queued_frames(), 2u); device.clear_tx_queue_for_device(); @@ -1431,7 +1432,7 @@ TEST(ModbusClientHubQueue, ClearDeviceQueueDropsSilently) { EXPECT_EQ(device.not_sent_count_, 0); // silent drop: no terminal callback } -// A send_pdu() from inside on_sent() enqueues behind the waiting frame rather than sending +// A queue_pdu() from inside on_sent() enqueues behind the waiting frame rather than sending // immediately or corrupting the waiting transaction. TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) { NullUART uart; @@ -1440,7 +1441,7 @@ TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) { hub.setup(); ChainOnSentDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); // first frame is sent -> on_sent chains a follow-up EXPECT_TRUE(hub.waiting()); // first frame is waiting @@ -1507,34 +1508,69 @@ class LegacyNameDevice : public ModbusClientDevice { #pragma GCC diagnostic pop } // namespace +// send_pdu() was renamed queue_pdu() because the call queues a request rather than transmitting one. +// The old spelling stays for the deprecation window with the signature 2026.7.4 shipped - void, no +// CommandOptions - so a component built against a real release still compiles and still queues. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +TEST(ModbusClientHubCompat, DeprecatedSendPduStillQueues) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + device.send_pdu(read); // deprecated device spelling: void, as 2026.7.4 shipped it + EXPECT_EQ(hub.queued_frames(), 1u); + + // A refusal is invisible to this spelling - no return value and no callback - so the only evidence + // is that nothing was queued. Reporting the refusal is exactly what moving to queue_pdu() buys. + device.send_pdu(std::span()); + EXPECT_EQ(hub.queued_frames(), 1u); + + // The deprecated hub spelling queues the same way, addressed explicitly. + const uint8_t other[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + hub.send_pdu(0x03, other, &device); + EXPECT_EQ(hub.queued_frames(), 2u); + + // Both frames resolve to the same owner. Drain them in turn: the device-spelling frame first (FIFO), + // then the hub-spelling frame - addressed to 0x03 yet owned by &device, so reaching device's + // on_no_response proves the request routes by owner pointer, not by address. + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); // device-spelling frame (address 0x02) + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 2); // hub-spelling frame (address 0x03, &device routing) +} +#pragma GCC diagnostic pop + TEST(ModbusClientHubCompat, LegacyCallbackNamesStillForward) { NoResponseProbeHub hub; LegacyNameDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - device.send_pdu(read); + device.queue_pdu(read); hub.force_send_next(); hub.timeout_waiting(); // no reply -> on_no_response -> forwards to on_modbus_no_response EXPECT_EQ(device.legacy_no_response_, 1); // A refused send returns false with no callback, so exercise the forward through an accepted // request instead: a cleared queue entry delivers on_not_sent(), which forwards to the old name. - EXPECT_FALSE(device.send_pdu(std::span())); // empty PDU: refused at the door + EXPECT_FALSE(device.queue_pdu(std::span())); // empty PDU: refused at the door EXPECT_EQ(device.legacy_not_sent_, 0); const uint8_t queued[] = {0x03, 0x00, 0x11, 0x00, 0x01}; - EXPECT_TRUE(device.send_pdu(queued)); + EXPECT_TRUE(device.queue_pdu(queued)); hub.clear_tx_queue_for_address(0x02); hub.sweep_for_test(); EXPECT_EQ(device.legacy_not_sent_, 1); } -// The send_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU +// The queue_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU // 256-byte limit, so it is refused up front - false at the call site, no entry, no callback. TEST(ModbusClientHub, OversizedPduIsRefusedAtTheDoor) { NoResponseProbeHub hub; LegacyNameDevice device(&hub, 0x02); std::vector big(MAX_PDU_SIZE + 1, 0x41); - EXPECT_FALSE(device.send_pdu(big)); + EXPECT_FALSE(device.queue_pdu(big)); EXPECT_EQ(device.legacy_not_sent_, 0); // refusals are returned, never delivered EXPECT_TRUE(hub.tx_buffer_empty()); EXPECT_EQ(hub.entries(), 0u); @@ -1568,7 +1604,7 @@ TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) { // Read response: on_modbus_data() historically received the payload after the function code and // the byte-count byte, as an owning vector. const uint8_t read_req[] = {0x03, 0x00, 0x10, 0x00, 0x02}; - device.send_pdu(read_req); + device.queue_pdu(read_req); hub.force_send_next(); const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; hub.receive_frame_for_test(0x02, response); @@ -1577,14 +1613,14 @@ TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) { // Write echo: no byte-count byte, so the payload is everything after the function code. const uint8_t write_req[] = {0x06, 0x00, 0x10, 0x00, 0x2A}; - device.send_pdu(write_req); + device.queue_pdu(write_req); hub.force_send_next(); hub.receive_frame_for_test(0x02, write_req); // single-write responses echo the request const std::vector expected_echo{0x00, 0x10, 0x00, 0x2A}; EXPECT_EQ(device.last_data_, expected_echo); // Exception response: on_modbus_error() received the masked function code and the exception code. - device.send_pdu(read_req); + device.queue_pdu(read_req); hub.force_send_next(); const uint8_t error[] = {0x83, 0x02}; hub.receive_frame_for_test(0x02, error); @@ -1675,9 +1711,9 @@ class ResendOnDataDevice : public ModbusClientDevice { public: ResendOnDataDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} void on_response(std::span request_pdu, std::span response_pdu) override { - this->send_pdu(std::vector(request_pdu.begin(), request_pdu.end())); + this->queue_pdu(std::vector(request_pdu.begin(), request_pdu.end())); } - void send_pdu(const std::vector &pdu) { ModbusClientDevice::send_pdu(pdu); } + void queue_pdu(const std::vector &pdu) { ModbusClientDevice::queue_pdu(pdu); } }; } // namespace @@ -1704,8 +1740,8 @@ TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) { SentCountingDevice device(&hub, 0x02); const uint8_t weird[] = {0x83, 0x01, 0x00, 0x00, 0x02}; // read-shaped but exception-flagged - EXPECT_TRUE(device.send_pdu(weird)); - EXPECT_FALSE(device.send_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused + EXPECT_TRUE(device.queue_pdu(weird)); + EXPECT_FALSE(device.queue_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused hub.sweep_for_test(); ASSERT_EQ(hub.queued_frames(), 1u); @@ -1715,7 +1751,7 @@ TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) { // The write-shaped twin (0x86 masks to WRITE_SINGLE_REGISTER) must not take WRITE-class // ordering either: exception-flagged codes are excluded from the mutates classification. const uint8_t weird_write[] = {0x86, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(weird_write); + device.queue_pdu(weird_write); ASSERT_EQ(hub.queued_frames(), 2u); EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); // not WRITE const ModbusDeviceCommand *next = hub.next_ready(); @@ -1732,7 +1768,7 @@ class ResendInFlightOnNotSentDevice : public ModbusClientDevice { this->not_sent_count_++; if (this->not_sent_count_ == 1) { const uint8_t same_as_waiting[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // == READ_PDU - this->send_pdu(same_as_waiting); + this->queue_pdu(same_as_waiting); } } int not_sent_count_{0}; @@ -1746,10 +1782,10 @@ TEST(ModbusClientHubQueue, SweepResendAfterClearQueuesFreshNotAbsorbedIntoShell) NoResponseProbeHub hub; ResendInFlightOnNotSentDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // READ_PDU now waiting const uint8_t queued_read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - device.send_pdu(queued_read); // a queued frame for the sweep to notify + device.queue_pdu(queued_read); // a queued frame for the sweep to notify ASSERT_EQ(hub.queued_frames(), 1u); hub.clear_tx_queue_for_address(0x02); @@ -1787,7 +1823,7 @@ TEST(ModbusClientHubNoResponse, SelfClearFromNoResponseDoesNotDoubleResolve) { NoResponseProbeHub hub; ClearAddressOnNoResponseDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.timeout_waiting(); @@ -1802,8 +1838,8 @@ TEST(ModbusClientHubNoResponse, SelfClearFromNoResponseResolvesTheAbsorbedReques NoResponseProbeHub hub; ClearAddressOnNoResponseDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_TRUE(device.send_pdu(read_pdu())); // absorbed: one entry, two requests + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); // absorbed: one entry, two requests hub.force_send_next(); hub.timeout_waiting(); @@ -1818,7 +1854,7 @@ TEST(ModbusClientHubQueue, ClearedShellReleasesTheBusOnLateResponse) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.clear_tx_queue_for_address(0x02); ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); @@ -1836,7 +1872,7 @@ TEST(ModbusClientHubQueue, ClearedShellReleasesTheBusOnTimeout) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.clear_tx_queue_for_address(0x02); ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); @@ -1856,7 +1892,7 @@ TEST(ModbusClientHubQueue, ClearInterruptedFrameGetsNoResponseAtTimeout) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); // declines the retry (retries_ == 0) - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; hub.receive_frame_for_test(0x07, stray_pdu); // wrong address: interrupts the transaction @@ -1887,7 +1923,7 @@ TEST(ModbusClientHubQueue, InterruptAfterClearStillDistrusts) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.clear_tx_queue_for_address(0x02); ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); @@ -1912,8 +1948,8 @@ TEST(ModbusClientHubQueue, ClearedInFlightDuplicateTimesOutWithoutRerunning) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); // absorbed: one entry, pending 2 + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); // absorbed: one entry, pending 2 ASSERT_EQ(hub.queued(0).pending, 2u); hub.force_send_next(); // sent, pending still 2 hub.clear_tx_queue_for_address(0x02); @@ -1936,9 +1972,9 @@ TEST(ModbusClientHubCallbackCount, AbsorbedRequestRunsAfterErrorResponse) { hub.setup(); DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); - device.send_pdu(read_pdu()); // waiting duplicate: absorbed + device.queue_pdu(read_pdu()); // waiting duplicate: absorbed const uint8_t exception_response[] = {0x83, 0x02}; hub.receive_frame_for_test(0x02, exception_response); // error terminal for request 1 @@ -1954,8 +1990,8 @@ TEST(ModbusClientHubPriority, ReadModifyWritesRankAsWrites) { const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t mask_write[] = {0x16, 0x00, 0x10, 0x00, 0xFF, 0x00, 0x01}; - device.send_pdu(read); - device.send_pdu(mask_write); + device.queue_pdu(read); + device.queue_pdu(mask_write); ASSERT_EQ(hub.queued_frames(), 2u); const ModbusDeviceCommand *next = hub.next_ready(); diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 8bbaa2773a..f3d4bbcba6 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -3,11 +3,13 @@ from __future__ import annotations import ast +from collections.abc import Callable import importlib.util import json from pathlib import Path import subprocess import sys +from typing import Any import pytest @@ -205,6 +207,47 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None: assert "sensitive_source" not in entry +def _wildcard_validator(value: Any) -> Any: + return value + + +def test_convert_keys_marker_wrapped_callable_key_normalizes() -> None: + converted: dict = {} + _bls.convert_keys(converted, {cv.Optional(_wildcard_validator): cv.string}, "/root") + + config_vars = converted["schema"]["config_vars"] + assert set(config_vars) == {"string"} + assert config_vars["string"]["key"] == "Optional" + assert config_vars["string"]["key_type"] == "_wildcard_validator" + + +def test_convert_keys_marker_wrapped_callable_beside_fixed_keys() -> None: + converted: dict = {} + _bls.convert_keys( + converted, + {cv.Required("id"): cv.string, cv.Optional(_wildcard_validator): cv.string}, + "/root", + ) + + assert set(converted["schema"]["config_vars"]) == {"id", "string"} + + +def test_convert_keys_bare_callable_dotted_qualname() -> None: + def make_validator() -> Callable[[Any], Any]: + def validator(value: Any) -> Any: + return value + + return validator + + converted: dict = {} + _bls.convert_keys(converted, {make_validator(): cv.string}, "/root") + + assert converted["key"] == "String" + assert converted["key_type"].endswith("make_validator..validator") + assert "at 0x" not in converted["key_type"] + assert set(converted["schema"]["config_vars"]) == {"string"} + + # --------------------------------------------------------------------------- # Regression tests for the lvgl schema dump. # diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 13450b10f0..9de8f715ef 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -10,6 +10,7 @@ not be part of a unit test suite. """ from collections.abc import Generator +import os from pathlib import Path import sys from unittest.mock import Mock, patch @@ -40,6 +41,19 @@ def fixture_path() -> Path: return here / "fixtures" +@pytest.fixture +def probe_env() -> dict[str, str]: + """Environment for running fixture probe scripts as subprocesses. + + Running a script file drops the cwd from sys.path, so prepend the + repo root for the child. + """ + python_path = str(package_root) + if ambient := os.environ.get("PYTHONPATH"): + python_path = os.pathsep.join((python_path, ambient)) + return os.environ | {"PYTHONPATH": python_path} + + @pytest.fixture def setup_core(tmp_path: Path) -> Path: """Set up CORE with test paths.""" diff --git a/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py index f0df08aa4e..f70a3f85ac 100644 --- a/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py +++ b/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py @@ -2,12 +2,13 @@ Executed as a subprocess by test_lazy_imports.py: heavy module names come in on argv, the ones found in sys.modules afterwards go out on stdout. -Covers both fast-path claims: the bundle suffix check in run_esphome reads -BUNDLE_EXTENSION from esphome.const without importing esphome.bundle, and -the real validated-config cache parse, include resolution included, stays -voluptuous free. +Covers three fast-path claims: the bundle suffix check in run_esphome reads +BUNDLE_EXTENSION from esphome.const without importing esphome.bundle, the +validated-config cache parse stays voluptuous free, and the JSON cache +(lambda sentinel included) resolves without pyyaml or esphome.yaml_util. """ +import json import os from pathlib import Path import sys @@ -16,7 +17,6 @@ from unittest.mock import patch from _leak_report import print_leaked_modules from _storage import make_storage -import yaml # Everything imported past this point is the code under test; the pop # below must only drop what the setup itself preloaded, or it would @@ -24,8 +24,10 @@ import yaml _FIXTURE_PRELOADED = frozenset(sys.modules) from esphome import __main__ as main_mod # noqa: E402 +from esphome.const import __version__ as ESPHOME_VERSION # noqa: E402 CONFIG_TEXT = "esphome:\n name: t\n" +LAMBDA_BODY = 'ESP_LOGD("t", "x");' # An ambient data-dir override would relocate the storage tree away # from the tmp config dir this fixture builds. @@ -39,13 +41,23 @@ with tempfile.TemporaryDirectory() as _td: storage_dir = tmp / ".esphome" / "storage" storage_dir.mkdir(parents=True) - # The cache is a top-level !include so loading it resolves an - # IncludeFile for real on the fast path. The sidecar is written to the - # layout ext_storage_path resolves once run_esphome sets - # CORE.config_path; going through CORE here would be circular. - (storage_dir / "inc.yaml").write_text(CONFIG_TEXT) - cache_path = storage_dir / "test.yaml.validated.yaml" - cache_path.write_text("!include inc.yaml\n") + # The cache carries a lambda sentinel so loading revives a real Lambda + # on the fast path. The sidecar is written to the layout + # ext_storage_path resolves once run_esphome sets CORE.config_path; + # going through CORE here would be circular. + cache_path = storage_dir / "test.yaml.validated.json" + cache_path.write_text( + json.dumps( + { + "v": 1, + "esphome": ESPHOME_VERSION, + "config": { + "esphome": {"name": "t"}, + "script": [{"lambda": {"__esphome_lambda__": LAMBDA_BODY}}], + }, + } + ) + ) os.utime(cache_path) # keep the cache at least as fresh as the source make_storage().save(storage_dir / "test.yaml.json") @@ -76,7 +88,13 @@ with tempfile.TemporaryDirectory() as _td: # asserts so PYTHONOPTIMIZE in the ambient environment can't strip them. if exit_code != 0: sys.exit(f"run_esphome exited {exit_code} before dispatching upload") - if dispatched.get("config") != yaml.safe_load(CONFIG_TEXT): - sys.exit(f"cache include did not resolve through the fast path: {dispatched!r}") + config = dispatched.get("config") + if config is None or config.get("esphome") != {"name": "t"}: + sys.exit(f"cache did not resolve through the fast path: {dispatched!r}") + from esphome.core import Lambda + + revived = config["script"][0]["lambda"] + if not isinstance(revived, Lambda) or revived.value != LAMBDA_BODY: + sys.exit(f"lambda sentinel did not revive: {revived!r}") print_leaked_modules() diff --git a/tests/unit_tests/fixtures/log/setup_log_probe.py b/tests/unit_tests/fixtures/log/setup_log_probe.py new file mode 100644 index 0000000000..b9e2e02a8c --- /dev/null +++ b/tests/unit_tests/fixtures/log/setup_log_probe.py @@ -0,0 +1,21 @@ +"""Report whether setup_log() pulled in colorama, then print a colored line. + +Executed as a subprocess by test_log.py because module imports are +process-global: the parent prints ``colorama_loaded=True/False`` plus an +ANSI colored line so the caller can observe whether the codes survive to +the stream. Pass ``--dashboard`` to simulate a dashboard-spawned run. +""" + +import sys + +from esphome.core import CORE +from esphome.log import setup_log + +if "--dashboard" in sys.argv: + CORE.dashboard = True + +setup_log() + +print(f"colorama_loaded={'colorama' in sys.modules}") +print("\033[31mred\033[0m end") +sys.stdout.flush() diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index b852d2d596..b3c2170c3f 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -2,15 +2,20 @@ from __future__ import annotations +from ipaddress import IPv4Address, IPv4Network import json import os from pathlib import Path +from typing import Any from unittest.mock import patch +from uuid import UUID import pytest +from esphome import const, yaml_util from esphome.__main__ import run_esphome from esphome.compiled_config import ( + _LAMBDA_KEY, compiled_config_path, load_compiled_config, save_compiled_config, @@ -24,30 +29,26 @@ from esphome.const import ( KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, KEY_VARIANT, + Toolchain, ) -from esphome.core import CORE -from esphome.yaml_util import ESPHomeDataBase +from esphome.core import CORE, ID, HexInt, Lambda, MACAddress, TimePeriodMilliseconds +from esphome.util import OrderedDict -_VALIDATED_CONFIG_YAML = """\ -esphome: - name: lite_test - friendly_name: Lite Test Device -esp32: - board: nodemcu-32s -logger: - baud_rate: 115200 -api: - port: 6053 - encryption: - key: 6dGhpcyBpcyBhIHRlc3Q= -ota: - - platform: esphome - port: 3232 - password: secret -wifi: - ssid: ssid - use_address: 192.168.1.42 -""" +_VALIDATED_CONFIG = { + "esphome": {"name": "lite_test", "friendly_name": "Lite Test Device"}, + "esp32": {"board": "nodemcu-32s"}, + "logger": {"baud_rate": 115200}, + "api": {"port": 6053, "encryption": {"key": "6dGhpcyBpcyBhIHRlc3Q="}}, + "ota": [{"platform": "esphome", "port": 3232, "password": "secret"}], + "wifi": {"ssid": "ssid", "use_address": "192.168.1.42"}, +} + + +def _cache_body(config: dict | None = None) -> str: + """Render the JSON envelope the production save writes.""" + return json.dumps( + {"v": 1, "esphome": const.__version__, "config": config or _VALIDATED_CONFIG} + ) def _write_storage( @@ -79,10 +80,10 @@ def _write_storage( storage_path.write_text(json.dumps(data), encoding="utf-8") -def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path: +def _write_cache(cache_path: Path, body: str | None = None) -> Path: """Write the cache file and return it.""" cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(body, encoding="utf-8") + cache_path.write_text(body if body is not None else _cache_body(), encoding="utf-8") return cache_path @@ -96,24 +97,28 @@ def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None: @pytest.fixture -def fresh_cache_files(tmp_path: Path) -> Path: - """YAML + StorageJSON + cache, all consistent and fresh.""" +def primed_storage(tmp_path: Path) -> Path: + """YAML + StorageJSON sidecar, no cache yet.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path - - storage_dir = tmp_path / ".esphome" / "storage" - _write_storage(storage_dir / "lite_test.yaml.json") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") - _set_cache_mtime(cache, yaml_path, offset=5) - + _write_storage(tmp_path / ".esphome" / "storage" / "lite_test.yaml.json") return yaml_path +@pytest.fixture +def fresh_cache_files(primed_storage: Path) -> Path: + """YAML + StorageJSON + cache, all consistent and fresh.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") + _set_cache_mtime(cache, primed_storage, offset=5) + return primed_storage + + def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None: """The cache file shape is predictable from the YAML filename.""" path = compiled_config_path("device.yaml") - assert path.name == "device.yaml.validated.yaml" + assert path.name == "device.yaml.validated.json" assert path.parent.name == "storage" @@ -126,9 +131,8 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" assert config["ota"][0]["password"] == "secret" - # The fast path loads without per-node source ranges (the full - # contract lives in test_yaml_util; this checks the flag is wired up). - assert not isinstance(config[CONF_ESPHOME][CONF_NAME], ESPHomeDataBase) + # The fast path loads plain scalars; no per-node source ranges exist. + assert type(config[CONF_ESPHOME][CONF_NAME]) is str # apply_to_core populated exactly what upload/logs read off CORE. assert CORE.name == "lite_test" @@ -147,7 +151,7 @@ def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None: storage_dir = tmp_path / ".esphome" / "storage" _write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=5) assert load_compiled_config(yaml_path) is not None @@ -168,7 +172,7 @@ def test_load_compiled_config_skips_esp32_block_for_other_platforms( esp_platform="ESP8266", core_platform="esp8266", ) - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=5) assert load_compiled_config(yaml_path) is not None @@ -185,7 +189,7 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path storage_dir = tmp_path / ".esphome" / "storage" - cache_path = storage_dir / "lite_test.yaml.validated.yaml" + cache_path = storage_dir / "lite_test.yaml.validated.json" sidecar_path = storage_dir / "lite_test.yaml.json" if scenario == "missing_cache": @@ -196,7 +200,7 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: elif scenario == "corrupt_cache": _write_storage(sidecar_path) _set_cache_mtime( - _write_cache(cache_path, "not: valid: yaml: ["), yaml_path, offset=5 + _write_cache(cache_path, '{"v": 1, "config": {'), yaml_path, offset=5 ) elif scenario == "missing_sidecar": # Cache fresh + parseable, but no StorageJSON → can't populate CORE. @@ -205,6 +209,108 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: assert load_compiled_config(yaml_path) is None +@pytest.mark.parametrize( + "body", + [ + pytest.param( + json.dumps( + {"v": 999, "esphome": const.__version__, "config": {"esphome": {}}} + ), + id="wrong_version", + ), + pytest.param( + json.dumps({"esphome": const.__version__, "config": {"esphome": {}}}), + id="missing_version", + ), + pytest.param( + json.dumps({"v": 1, "esphome": "2020.1.0", "config": {"esphome": {}}}), + id="other_esphome_version", + ), + pytest.param( + json.dumps({"v": 1, "config": {"esphome": {}}}), + id="missing_esphome_version", + ), + pytest.param( + json.dumps( + { + "v": 1, + "esphome": const.__version__, + "config": ["not", "a", "dict"], + } + ), + id="non_dict_config", + ), + pytest.param( + json.dumps({"v": 1, "esphome": const.__version__}), id="missing_config" + ), + pytest.param(json.dumps(["not", "an", "envelope"]), id="non_dict_envelope"), + ], +) +def test_load_compiled_config_rejects_bad_envelope( + primed_storage: Path, body: str +) -> None: + """A foreign or future cache shape falls back instead of half-loading.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json", body) + _set_cache_mtime(cache, primed_storage, offset=5) + + assert load_compiled_config(primed_storage) is None + + +def test_load_ignores_legacy_yaml_cache(primed_storage: Path) -> None: + """A fresh pre-JSON ``.validated.yaml`` alone can't drive the fast path.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + legacy = _write_cache( + storage_dir / "lite_test.yaml.validated.yaml", "esphome:\n name: lite_test\n" + ) + _set_cache_mtime(legacy, primed_storage, offset=5) + + assert load_compiled_config(primed_storage) is None + + +def test_save_removes_stale_legacy_yaml_cache(tmp_path: Path) -> None: + """A successful save leaves only the JSON cache behind.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text("esphome:\n name: lite_test\n") + + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert compiled_config_path("lite_test.yaml").is_file() + assert not legacy.exists() + + +def test_save_removes_legacy_yaml_even_when_write_fails(tmp_path: Path) -> None: + """The secret-bearing legacy cache goes away regardless of write outcome.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text("esphome:\n name: lite_test\n") + + with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")): + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert not legacy.exists() + assert not compiled_config_path("lite_test.yaml").exists() + + +def test_save_warns_when_legacy_cache_unremovable( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A secret-bearing legacy file that won't unlink warns; the write proceeds.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.mkdir() # unlink() on a directory raises OSError + + with caplog.at_level("WARNING", logger="esphome.compiled_config"): + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert "legacy validated-config cache" in caplog.text + assert compiled_config_path("lite_test.yaml").is_file() + + @pytest.mark.parametrize("command", ["upload", "logs"]) def test_run_esphome_upload_and_logs_use_cache_when_fresh( command: str, @@ -258,7 +364,7 @@ def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( ) -> 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) YAML would be inert and + so writing the rendered (secret-resolved) config would be inert and leak secrets to disk for nothing.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") @@ -293,7 +399,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( storage_dir = tmp_path / ".esphome" / "storage" _write_storage(storage_dir / "lite_test.yaml.json") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=-60) # stale fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}} @@ -386,28 +492,161 @@ def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None def test_save_compiled_config_writes_cache(tmp_path: Path) -> None: - """`save_compiled_config` writes the dumped YAML next to the sidecar.""" + """`save_compiled_config` writes the JSON envelope next to the sidecar.""" CORE.config_path = tmp_path / "lite_test.yaml" save_compiled_config({"esphome": {"name": "lite_test"}, "logger": {}}) cache_path = compiled_config_path("lite_test.yaml") assert cache_path.is_file() - body = cache_path.read_text() - assert "name: lite_test" in body - assert "logger:" in body + envelope = json.loads(cache_path.read_text()) + assert envelope["v"] == 1 + assert envelope["esphome"] == const.__version__ + assert envelope["config"] == {"esphome": {"name": "lite_test"}, "logger": {}} -def test_save_compiled_config_swallows_dump_errors( +def test_save_compiled_config_swallows_write_errors( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """Failures during the dump are non-fatal -- a bad cache just means + """Failures during the write are non-fatal -- a bad cache just means the next fast path falls back to read_config().""" CORE.config_path = tmp_path / "lite_test.yaml" - with patch("esphome.yaml_util.dump", side_effect=RuntimeError("boom")): + with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")): save_compiled_config({"esphome": {"name": "lite_test"}}) assert not compiled_config_path("lite_test.yaml").exists() +def test_save_stringifies_unknown_values(tmp_path: Path) -> None: + """A type with no dedicated encoding stores its string form.""" + + class Weird: + def __str__(self) -> str: + return "weird-str" + + CORE.config_path = tmp_path / "lite_test.yaml" + save_compiled_config({"esphome": {"name": "lite_test", "weird": Weird()}}) + envelope = json.loads(compiled_config_path("lite_test.yaml").read_text()) + assert envelope["config"]["esphome"]["weird"] == "weird-str" + + +def test_save_skips_cache_on_unserializable_key(tmp_path: Path) -> None: + """A non-basic dict key aborts the write; the fast path falls back.""" + CORE.config_path = tmp_path / "lite_test.yaml" + save_compiled_config({"esphome": {("a", "b"): "lite_test"}}) + assert not compiled_config_path("lite_test.yaml").exists() + + +def _normalize(value: Any) -> Any: + """Make Lambda comparable; everything else compares by value already.""" + if isinstance(value, Lambda): + return ("__lambda__", value.value) + if isinstance(value, dict): + return {k: _normalize(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_normalize(v) for v in value] + return value + + +def _round_trip_config() -> OrderedDict: + """A post-validation shaped config exercising every representer type.""" + return OrderedDict( + { + "esphome": OrderedDict( + { + "name": "lite_test", + "build_path": Path("/build/lite_test"), + "on_boot": [ + OrderedDict( + { + "trigger_id": ID("trigger_1", type="Trigger"), + "then": [{"lambda": Lambda('ESP_LOGD("t", "x");')}], + } + ) + ], + } + ), + "wifi": OrderedDict( + { + "id": ID("wifi_id", type="WiFiComponent"), + "reboot_timeout": TimePeriodMilliseconds(milliseconds=900000), + "use_address": IPv4Address("192.168.1.42"), + "subnet": IPv4Network("192.168.1.0/24"), + "mac": MACAddress(0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01), + } + ), + "misc": OrderedDict( + { + "uuid": UUID("12345678-1234-5678-1234-567812345678"), + "toolchain": Toolchain.PLATFORMIO, + "hex": HexInt(0x1234), + "levels": (1, 2.5, True, None), + "empty": {}, + } + ), + } + ) + + +def test_cache_round_trip_matches_yaml_cache(primed_storage: Path) -> None: + """The JSON cache loads the same tree the YAML cache used to.""" + config = _round_trip_config() + save_compiled_config(config) + from_json = load_compiled_config(primed_storage) + assert from_json is not None + + yaml_cache = primed_storage.parent / "dumped.yaml" + yaml_cache.write_text(yaml_util.dump(config, show_secrets=True)) + from_yaml = yaml_util.load_yaml( + yaml_cache, clear_secrets=False, track_document_range=False + ) + + assert _normalize(from_json) == _normalize(from_yaml) + + +def test_lambda_sentinel_round_trips(primed_storage: Path) -> None: + """A !lambda body comes back as a Lambda with the same source.""" + body = 'id(sensor_1).publish_state(42);\nreturn "multi\\nline";' + save_compiled_config( + { + "esphome": {"name": "lite_test"}, + "script": [{"then": [{"lambda": Lambda(body)}]}], + } + ) + + config = load_compiled_config(primed_storage) + assert config is not None + revived = config["script"][0]["then"][0]["lambda"] + assert isinstance(revived, Lambda) + assert revived.value == body + + +def test_object_hook_requires_exact_shape(primed_storage: Path) -> None: + """Only the exact one-key string-valued sentinel revives a Lambda.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + config = { + "esphome": {"name": "lite_test"}, + "extra_key": {_LAMBDA_KEY: "x", "y": 1}, + "non_str": {_LAMBDA_KEY: 5}, + } + cache = _write_cache( + storage_dir / "lite_test.yaml.validated.json", _cache_body(config) + ) + _set_cache_mtime(cache, primed_storage, offset=5) + + loaded = load_compiled_config(primed_storage) + assert loaded is not None + assert loaded["extra_key"] == {_LAMBDA_KEY: "x", "y": 1} + assert loaded["non_str"] == {_LAMBDA_KEY: 5} + + +def test_int_keys_coerce_to_strings(primed_storage: Path) -> None: + """Non-str basic keys stringify; validated configs only use string keys.""" + save_compiled_config({"esphome": {"name": "lite_test"}, "table": {1: "a", 2: "b"}}) + + config = load_compiled_config(primed_storage) + assert config is not 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.""" @@ -426,7 +665,7 @@ def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> Non '"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, ' '"framework": null, "core_platform": null}' ) - cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache_path, yaml_path, offset=5) assert load_compiled_config(yaml_path) is None diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 0cb0c1f62d..7f00d00ef7 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -1,5 +1,7 @@ import os from pathlib import Path +import subprocess +import sys from unittest.mock import patch from hypothesis import given @@ -213,6 +215,31 @@ class TestLambda: assert str(target) is value.value + def test_init__expression_initializer(self): + from esphome.cpp_generator import RawExpression + + target = core.Lambda(RawExpression("foo()")) + + assert target.value == "foo();" + + def test_init__other_initializer(self): + target = core.Lambda(123) + + assert target.value == 123 + + def test_init_from_str_does_not_import_codegen(self): + """The validated-config cache revives Lambdas on the upload fast path.""" + # sys.exit rather than assert so ambient PYTHONOPTIMIZE can't strip it. + check = ( + "import sys; from esphome.core import Lambda; " + "Lambda('return 1;'); " + "sys.exit('codegen leaked' if 'esphome.cpp_generator' in sys.modules else 0)" + ) + result = subprocess.run( + [sys.executable, "-c", check], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr + def test_parts(self): target = core.Lambda(SAMPLE_LAMBDA.strip()) diff --git a/tests/unit_tests/test_happy_eyeballs.py b/tests/unit_tests/test_happy_eyeballs.py new file mode 100644 index 0000000000..3335a8a3e3 --- /dev/null +++ b/tests/unit_tests/test_happy_eyeballs.py @@ -0,0 +1,325 @@ +"""Tests for the Happy Eyeballs urllib3 shim.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Generator +import socket +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from esphome.happy_eyeballs import _make_create_connection, ensure_happy_eyeballs + + +def _addr_info(host: str, port: int) -> tuple[Any, ...]: + """Build a getaddrinfo-style result tuple for an IPv4 address.""" + return (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (host, port)) + + +@pytest.fixture +def create_connection() -> Any: + """A freshly built Happy Eyeballs create_connection replacement.""" + return _make_create_connection() + + +@pytest.fixture +def listener() -> Generator[tuple[str, int]]: + """A listening TCP socket on localhost; yields its address.""" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(("127.0.0.1", 0)) + server.listen(5) + yield server.getsockname() + server.close() + + +@pytest.fixture +def mock_gai(listener: tuple[str, int]) -> Generator[Any]: + """Resolve every host to two copies of the listener's address.""" + with patch("socket.getaddrinfo", return_value=[_addr_info(*listener)] * 2) as mock: + yield mock + + +def test_ensure_happy_eyeballs_patches_and_is_idempotent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The shim replaces urllib3's create_connection exactly once.""" + import urllib3.util.connection + + def stock(*args: Any, **kwargs: Any) -> None: + pass + + monkeypatch.setattr(urllib3.util.connection, "create_connection", stock) + + ensure_happy_eyeballs() + patched = urllib3.util.connection.create_connection + assert patched is not stock + assert patched._esphome_patched + + ensure_happy_eyeballs() + assert urllib3.util.connection.create_connection is patched + + +def test_connects_and_restores_socket_state( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """The winning socket comes back blocking, with timeout and options set.""" + sock = create_connection( + ("example.com", listener[1]), + timeout=5, + socket_options=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)], + ) + + try: + assert sock.getpeername() == listener + assert sock.gettimeout() == 5 + assert sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) != 0 + finally: + sock.close() + + +def test_single_address_connects( + create_connection: Any, listener: tuple[str, int] +) -> None: + """A host resolving to one address connects through the same path.""" + with patch("socket.getaddrinfo", return_value=[_addr_info(*listener)]): + sock = create_connection(("example.com", listener[1]), timeout=5) + + try: + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_falls_back_to_working_address( + create_connection: Any, listener: tuple[str, int], monkeypatch: pytest.MonkeyPatch +) -> None: + """An unreachable first address does not block the working one.""" + from esphome import happy_eyeballs + + # 192.0.2.1 (TEST-NET-1) blackholes or fails fast depending on the + # network; either way the second address must win well within the + # timeout instead of waiting out the first. A short stagger keeps the + # test's duration network independent. + monkeypatch.setattr(happy_eyeballs, "HAPPY_EYEBALLS_DELAY", 0.01) + addr_infos = [_addr_info("192.0.2.1", 9), _addr_info(*listener)] + + with patch("socket.getaddrinfo", return_value=addr_infos): + sock = create_connection(("example.com", listener[1]), timeout=10) + + try: + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_bracketed_ipv6_host_is_stripped( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """A bracketed IPv6 literal is unbracketed before resolution.""" + sock = create_connection(("[::1]", listener[1]), timeout=5) + + try: + assert mock_gai.call_args[0][0] == "::1" + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_source_address_is_bound( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """The socket binds to the requested source address before connecting.""" + sock = create_connection( + ("example.com", listener[1]), + timeout=5, + source_address=("127.0.0.1", 0), + ) + + try: + assert sock.getsockname()[0] == "127.0.0.1" + finally: + sock.close() + + +def test_socket_factory_failure_closes_socket( + listener: tuple[str, int], mock_gai: Any +) -> None: + """A socket-option failure fails the connect instead of leaking sockets. + + Instrumented at ``_set_socket_options`` (which the factory calls with + the just-created socket) rather than by patching ``socket.socket``, + which is platform dependent: the event loop's internal socketpair use + differs between platforms. + """ + created: list[socket.socket] = [] + + def failing_set_options(sock: socket.socket, options: Any) -> None: + created.append(sock) + raise OSError("bad socket option") + + # Patch before building the closure; it binds _set_socket_options at + # creation time. + with patch("urllib3.util.connection._set_socket_options", new=failing_set_options): + create_connection = _make_create_connection() + with pytest.raises(OSError): + create_connection( + ("example.com", listener[1]), + timeout=5, + socket_options=[(999999, 999999, 1)], + ) + + assert created, "socket factory never ran" + assert all(sock.fileno() == -1 for sock in created), "socket leaked open" + + +def test_default_timeout_yields_blocking_socket( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """Without an explicit timeout the socket follows the global default.""" + sock = create_connection(("example.com", listener[1])) + + try: + assert sock.gettimeout() is socket.getdefaulttimeout() + finally: + sock.close() + + +def test_settimeout_failure_closes_socket( + create_connection: Any, mock_gai: Any +) -> None: + """A failure restoring socket state closes the winner instead of leaking.""" + bad_sock = Mock() + bad_sock.settimeout.side_effect = OSError("bad timeout") + + with ( + patch("esphome.async_thread.run_async", return_value=bad_sock), + pytest.raises(OSError, match="bad timeout"), + ): + create_connection(("example.com", 80), timeout=5) + + bad_sock.close.assert_called_once() + + +def test_connect_timeout_raises() -> None: + """A connect that never completes raises within the timeout.""" + + async def never(*args: Any, **kwargs: Any) -> None: + await asyncio.sleep(60) + + addr_infos = [_addr_info("192.0.2.1", 9), _addr_info("192.0.2.2", 9)] + + # Patch before building the closure; it binds start_connection at + # creation time. + with patch("aiohappyeyeballs.start_connection", new=never): + create_connection = _make_create_connection() + with ( + patch("socket.getaddrinfo", return_value=addr_infos), + pytest.raises(TimeoutError), + ): + create_connection(("example.com", 80), timeout=0.1) + + +def test_invalid_host_raises_location_parse_error(create_connection: Any) -> None: + """Hostnames urllib3 would reject are still rejected.""" + from urllib3.exceptions import LocationParseError + + with pytest.raises(LocationParseError): + create_connection(("a" * 300, 80)) + + +def test_empty_getaddrinfo_raises_oserror(create_connection: Any) -> None: + """An empty resolution matches stock urllib3's OSError, not ValueError.""" + with ( + patch("socket.getaddrinfo", return_value=[]), + pytest.raises(OSError, match="empty"), + ): + create_connection(("example.com", 80), timeout=5) + + +def test_ensure_falls_back_to_stock_when_internals_move( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """If urllib3 private names disappear, downloads keep the stock connect + and the warning is latched to fire once, not per download.""" + import urllib3.util.connection + + from esphome import happy_eyeballs + + def stock(*args: Any, **kwargs: Any) -> None: + pass + + factory = Mock(side_effect=ImportError("gone")) + monkeypatch.setattr(urllib3.util.connection, "create_connection", stock) + monkeypatch.setattr(happy_eyeballs, "_make_create_connection", factory) + + ensure_happy_eyeballs() + ensure_happy_eyeballs() + assert urllib3.util.connection.create_connection is stock + assert factory.call_count == 1 + assert caplog.text.count("Happy Eyeballs unavailable") == 1 + + +def test_ensure_survives_missing_urllib3( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """An unimportable urllib3 degrades with a warning instead of raising.""" + import sys + + with patch.dict(sys.modules, {"urllib3.util.connection": None}): + ensure_happy_eyeballs() + assert "Happy Eyeballs unavailable" in caplog.text + + +def test_requests_routes_through_shim(monkeypatch: pytest.MonkeyPatch) -> None: + """Patching urllib3's create_connection actually reroutes requests.""" + from http.server import BaseHTTPRequestHandler, HTTPServer + import threading + + import requests + import urllib3.util.connection + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(200) + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *args: Any) -> None: + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + host, port = server.server_address + + calls: list[Any] = [] + shim = _make_create_connection() + + def counting(*args: Any, **kwargs: Any) -> Any: + calls.append(args) + return shim(*args, **kwargs) + + counting._esphome_patched = True + monkeypatch.setattr(urllib3.util.connection, "create_connection", counting) + + real_getaddrinfo = socket.getaddrinfo + + def fake_getaddrinfo(h: str, p: int, *args: Any, **kwargs: Any) -> Any: + if h == "shim-test.invalid": + return [_addr_info(host, port), _addr_info(host, port)] + return real_getaddrinfo(h, p, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + + try: + with requests.Session() as session: + session.trust_env = False + resp = session.get(f"http://shim-test.invalid:{port}/", timeout=5) + assert resp.status_code == 200 + assert resp.content == b"ok" + assert calls, "requests did not go through the patched create_connection" + finally: + server.shutdown() + server.server_close() diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 8358f4b781..b6878c33a2 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -15,7 +15,6 @@ test pins down *which* heavy modules must stay out entirely. from __future__ import annotations import importlib.util -import os from pathlib import Path import subprocess import sys @@ -46,6 +45,11 @@ API_HEAVY_MODULES = ("aioesphomeapi",) # never pays for the bundle machinery and its tarfile chain. BUNDLE_HEAVY_MODULES = ("esphome.bundle", "tarfile") +# Heavy only for a cache-hit upload/logs run: the JSON cache parse must +# not resolve pyyaml or the yaml_util chain (the read_config fallback +# still uses both). +CACHE_HIT_HEAVY_MODULES = ("esphome.yaml_util", "yaml") + # Stdlib modules deferred out of the dispatch fast path: a cache-hit # upload/logs run never writes a file (tempfile), spawns a process # (subprocess), parses a URL (urllib.parse), or prints a serial @@ -56,8 +60,6 @@ STDLIB_FAST_PATH_MODULES = ( "tempfile", "subprocess", "getpass", - # Pins the module-level contract only: PyYAML's constructor loads - # datetime during the cache parse until the JSON cache lands. "datetime", *(("urllib.parse",) if sys.version_info >= (3, 13) else ()), ) @@ -108,6 +110,7 @@ def test_watched_heavy_modules_exist() -> None: FAST_PATH_HEAVY_MODULES + API_HEAVY_MODULES + BUNDLE_HEAVY_MODULES + + CACHE_HIT_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES ): assert importlib.util.find_spec(module) is not None, ( @@ -116,18 +119,17 @@ def test_watched_heavy_modules_exist() -> None: def _leaked_from_fixture( - fixture_path: Path, script_name: str, extra: tuple[str, ...] = () + fixture_path: Path, + env: dict[str, str], + script_name: str, + extra: tuple[str, ...] = (), ) -> str: """Run a fixture script with the watched modules on argv. - Running a script file drops the cwd from sys.path, so prepend the - repo root for the child; a non-zero exit surfaces the child's stderr. + ``env`` comes from the ``probe_env`` fixture so the child can import + the repo checkout; a non-zero exit surfaces the child's stderr. """ script = fixture_path / "lazy_imports" / script_name - python_path = str(Path(__file__).parents[2]) - if ambient := os.environ.get("PYTHONPATH"): - python_path = os.pathsep.join((python_path, ambient)) - env = os.environ | {"PYTHONPATH": python_path} result = subprocess.run( [sys.executable, str(script), *FAST_PATH_HEAVY_MODULES, *extra], capture_output=True, @@ -141,12 +143,13 @@ def _leaked_from_fixture( def test_storage_json_fast_path_does_not_import_heavy_modules( fixture_path: Path, + probe_env: dict[str, str], ) -> None: """``apply_to_core`` runs on the upload/logs fast path for every platform; parsing the stored framework version must not drag in the validation stack or the esp32 component package. """ - leaked = _leaked_from_fixture(fixture_path, "storage_json_fast_path.py") + leaked = _leaked_from_fixture(fixture_path, probe_env, "storage_json_fast_path.py") assert not leaked, ( f"storage_json.apply_to_core pulls in heavy modules: {leaked}. " "The upload/logs fast path skips validation; importing the " @@ -156,12 +159,15 @@ def test_storage_json_fast_path_does_not_import_heavy_modules( def test_esptool_upload_fast_path_does_not_import_heavy_modules( fixture_path: Path, + probe_env: dict[str, str], ) -> None: """The esptool serial upload reads the esp32 variant from CORE.data; resolving it must not drag in the esp32 component package or the validation stack. """ - leaked = _leaked_from_fixture(fixture_path, "esptool_upload_fast_path.py") + leaked = _leaked_from_fixture( + fixture_path, probe_env, "esptool_upload_fast_path.py" + ) assert not leaked, ( f"upload_using_esptool pulls in heavy modules: {leaked}. " "The upload fast path skips validation; importing the validation " @@ -262,6 +268,7 @@ def test_yaml_util_does_not_import_heavy_modules() -> None: def test_upload_command_path_does_not_import_heavy_modules( fixture_path: Path, + probe_env: dict[str, str], ) -> None: """The single-config dispatch path checks the bundle suffix on every run; reading it from esphome.const must not drag in esphome.bundle @@ -269,14 +276,15 @@ def test_upload_command_path_does_not_import_heavy_modules( """ leaked = _leaked_from_fixture( fixture_path, + probe_env, "upload_command_fast_path.py", - extra=BUNDLE_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES, + extra=BUNDLE_HEAVY_MODULES + CACHE_HIT_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES, ) assert not leaked, ( f"the upload dispatch path pulls in heavy modules: {leaked}. " "An ordinary run only needs the bundle suffix constant, and the " - "cache parse must not resolve voluptuous; keep the esphome.bundle " - "import inside the branch that extracts one, the Invalid import " - "inside the branch that raises it, and the deferred stdlib " - "imports inside the write/spawn/serial helpers that use them." + "JSON cache parse must not resolve voluptuous or pyyaml; keep the " + "esphome.bundle import inside the branch that extracts one, the " + "yaml_util imports inside the read_config fallback, and the " + "deferred stdlib imports inside the write/spawn/serial helpers." ) diff --git a/tests/unit_tests/test_log.py b/tests/unit_tests/test_log.py index 02798f1029..194b38209b 100644 --- a/tests/unit_tests/test_log.py +++ b/tests/unit_tests/test_log.py @@ -1,6 +1,44 @@ +from collections.abc import Generator +import errno +import io +import logging +import os +from pathlib import Path +import select +import subprocess +import sys +import time + import pytest -from esphome.log import AnsiFore, AnsiStyle, color +from esphome.core import CORE +from esphome.log import AnsiFore, AnsiStyle, color, setup_log + + +class _FakeTty(io.StringIO): + def isatty(self) -> bool: + return True + + +@pytest.fixture +def restore_logging_state() -> Generator[None, None, None]: + """Undo the global logging changes setup_log() makes.""" + root = logging.getLogger() + handlers = root.handlers[:] + formatters = [handler.formatter for handler in handlers] + level = root.level + urllib3_level = logging.getLogger("urllib3").level + yield + root.handlers[:] = handlers + for handler, formatter in zip(handlers, formatters, strict=True): + handler.setFormatter(formatter) + root.setLevel(level) + logging.getLogger("urllib3").setLevel(urllib3_level) + + +def _probe_command(fixture_path: Path, *args: str) -> list[str]: + """Build the command line for the setup_log probe fixture script.""" + return [sys.executable, str(fixture_path / "log" / "setup_log_probe.py"), *args] def test_color_keep_returns_unchanged_message() -> None: @@ -78,3 +116,230 @@ def test_ansi_fore_keep_is_enum_member() -> None: assert bool(AnsiFore.KEEP) is True # But the value itself is still an empty string assert AnsiFore.KEEP.value == "" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_redirected_output_strips_ansi( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A redirected run must keep colorama so ANSI codes are stripped.""" + result = subprocess.run( + _probe_command(fixture_path), + capture_output=True, + text=True, + timeout=60, + check=False, + env=probe_env, + ) + assert result.returncode == 0, result.stderr + assert "colorama_loaded=True" in result.stdout + assert "red end" in result.stdout + assert "\033" not in result.stdout + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_dashboard_skips_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """Dashboard runs escape their color codes, so colorama must not load.""" + result = subprocess.run( + _probe_command(fixture_path, "--dashboard"), + capture_output=True, + text=True, + timeout=60, + check=False, + env=probe_env, + ) + assert result.returncode == 0, result.stderr + assert "colorama_loaded=False" in result.stdout + # Codes pass through untouched for the dashboard to handle. + assert "\033[31mred\033[0m end" in result.stdout + + +def _run_probe_on_pty( + fixture_path: Path, probe_env: dict[str, str], *, stderr_to_pty: bool +) -> str: + """Run the probe with stdout on a pty and return the decoded pty output. + + With ``stderr_to_pty=False`` stderr goes to a pipe instead, giving the + mixed tty/redirect stream combination while keeping any traceback + available for the exit assertion. + """ + # Unix-only; a module-level import would break test collection on + # Windows, where all the callers are skipped anyway. + import pty + + controller, follower = pty.openpty() + proc = None + output = b"" + deadline = time.monotonic() + 60 + try: + try: + proc = subprocess.Popen( + _probe_command(fixture_path), + stdout=follower, + stderr=follower if stderr_to_pty else subprocess.PIPE, + stdin=follower, + env=probe_env, + ) + finally: + os.close(follower) + while True: + timeout = deadline - time.monotonic() + if timeout <= 0 or not select.select([controller], [], [], timeout)[0]: + pytest.fail(f"pty probe produced no EOF in time; got {output!r}") + try: + chunk = os.read(controller, 1024) + except OSError as err: + # macOS raises EIO once the child closes its end of the pty; + # anything else is a real failure, not end-of-stream. + if err.errno != errno.EIO: + raise + break + if not chunk: + break + output += chunk + stderr_text = "" + if proc.stderr is not None: + stderr_text = proc.stderr.read().decode(errors="replace") + proc.stderr.close() + assert proc.wait(60) == 0, stderr_text + finally: + os.close(controller) + if proc is not None and proc.poll() is None: + proc.kill() + proc.wait() + return output.decode() + + +@pytest.mark.skipif( + sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows" +) +def test_setup_log_tty_skips_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A terminal run must skip colorama and keep ANSI codes intact.""" + text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=True) + assert "colorama_loaded=False" in text + assert "\033[31mred\033[0m end" in text + + +@pytest.mark.skipif( + sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows" +) +def test_setup_log_mixed_streams_init_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A tty stdout with a redirected stderr must still initialize colorama. + + The guard requires both streams to be a tty; collapsing it to a + single-stream check would stop stripping ANSI from a redirected + stderr while stdout is a terminal. + """ + text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=False) + assert "colorama_loaded=True" in text + # stdout is a tty, so colorama leaves its codes alone. + assert "\033[31mred\033[0m end" in text + + +@pytest.fixture +def colorama_probe( + monkeypatch: pytest.MonkeyPatch, restore_logging_state: None +) -> Generator[None, None, None]: + """Shared preamble for the in-process guard-branch tests. + + Clears colorama from sys.modules so the assertions prove what + setup_log() itself did, and snapshots CORE.verbose/quiet, which is + not a no-op: CORE.reset() does not restore them, so without the + snapshot setup_log()'s log-level side effects would leak into later + tests. + """ + monkeypatch.delitem(sys.modules, "colorama", raising=False) + monkeypatch.setattr(CORE, "verbose", CORE.verbose) + monkeypatch.setattr(CORE, "quiet", CORE.quiet) + yield + # init() rebinds sys.stdout/stderr; restore them before monkeypatch + # puts the originals back. + if (colorama := sys.modules.get("colorama")) is not None: + colorama.deinit() + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_dashboard_branch_skips_colorama_import( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The dashboard side of the guard must not import colorama.""" + monkeypatch.setattr(CORE, "dashboard", True) + setup_log() + assert "colorama" not in sys.modules + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_tty_branch_skips_colorama_import( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The tty side of the guard must not import colorama.""" + monkeypatch.setattr(sys, "stdout", _FakeTty()) + monkeypatch.setattr(sys, "stderr", _FakeTty()) + setup_log() + assert "colorama" not in sys.modules + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_redirected_branch_imports_colorama( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """Redirected streams must keep importing and initializing colorama.""" + monkeypatch.setattr(sys, "stdout", io.StringIO()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + setup_log() + assert "colorama" in sys.modules + + +@pytest.mark.parametrize("broken", ["missing", "closed"]) +def test_setup_log_broken_streams_import_colorama( + broken: str, monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """A missing or closed stream counts as a redirect and must not crash. + + colorama tolerates both, so setup_log() has to reach its init rather + than raise inside the tty probe. + """ + if broken == "missing": + stream = None + else: + stream = io.StringIO() + stream.close() + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + setup_log() + assert "colorama" in sys.modules + + +def test_setup_log_win32_always_imports_colorama( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The Windows clause must init colorama even when both streams are ttys. + + Old Windows consoles need colorama to translate ANSI escapes, so the + platform check has to win over the tty check. colorama itself keys + off os.name, so on a POSIX host its init/deinit pair is a + passthrough. + """ + monkeypatch.setattr(sys, "platform", "win32") + # Both streams are ttys: without the platform clause this combination + # would skip colorama. + monkeypatch.setattr(sys, "stdout", _FakeTty()) + monkeypatch.setattr(sys, "stderr", _FakeTty()) + setup_log() + assert "colorama" in sys.modules