Merge remote-tracking branch 'upstream/dev' into neutral-ble-client

# Conflicts:
#	esphome/components/ble_device_base/ble_gatt_client.h
#	esphome/components/bluetooth_connection/__init__.py
#	esphome/components/bluetooth_connection/bluetooth_connection.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection.h
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
#	esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_hub.h
#	esphome/components/bluetooth_proxy/__init__.py
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
#	esphome/core/defines.h
#	tests/component_tests/bluetooth_proxy/test_platform_gates.py
#	tests/components/ble_device_base/__init__.py
#	tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml
This commit is contained in:
J. Nick Koston
2026-08-10 10:50:00 -05:00
59 changed files with 3478 additions and 634 deletions
+38
View File
@@ -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}"
+30 -6
View File
@@ -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
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.4
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5
RUN \
platformio settings set enable_telemetry No \
+11 -9
View File
@@ -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
+89 -29
View File
@@ -1,48 +1,69 @@
"""Validated-config cache for the upload/logs fast path.
compile dumps the validated config to <data_dir>/storage/<file>.validated.yaml;
compile dumps the validated config to <data_dir>/storage/<file>.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
@@ -0,0 +1,150 @@
#include "scan_response_merger.h"
#ifdef USE_BLE_SCAN_RESPONSE_MERGER
#include <cstring>
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
@@ -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 <cstdint>
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<ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> 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
@@ -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:
+12 -2
View File
@@ -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))
+2
View File
@@ -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:
+2 -1
View File
@@ -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
+31 -15
View File
@@ -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))
+39 -1
View File
@@ -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,
+12
View File
@@ -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
+575 -13
View File
@@ -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<uint32_t>(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<unsigned>(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<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(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<int32_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>();
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;
+171 -5
View File
@@ -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<TargetSensors, MAX_TARGETS> targets_{};
sensor::Sensor *target_count_sensor_{nullptr};
sensor::Sensor *point_count_sensor_{nullptr};
std::array<AreaSensors, AREA_COUNT> interference_areas_{};
std::array<AreaSensors, AREA_COUNT> detection_areas_{};
#endif
#ifdef USE_BINARY_SENSOR
binary_sensor::BinarySensor *presence_binary_sensor_{nullptr};
std::array<binary_sensor::BinarySensor *, MAX_TARGETS> target_presence_{};
std::array<binary_sensor::BinarySensor *, AREA_COUNT> 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<AreaConfig, AREA_COUNT> interference_area_values_{};
std::array<AreaConfig, AREA_COUNT> 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<int32_t, MAX_TARGETS> 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};
@@ -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))
+15 -1
View File
@@ -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),
)
+82 -8
View File
@@ -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))
@@ -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)
@@ -3,7 +3,6 @@
#include "ln882h_ble_tracker.h"
#include <cinttypes>
#include <cstring>
#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
@@ -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<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> 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
+7 -7
View File
@@ -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<const uint8_t> pdu, ModbusClientDevice *device,
CommandOptions options) {
bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> 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<uint8_t> &payload, ModbusClient
ESP_LOGW(TAG, "send_raw() payload too short to contain a PDU, refused");
return;
}
this->send_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), device);
this->queue_pdu(payload[0], std::span<const uint8_t>(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<const uint8_t> 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<uint8_t>(function_code))) {
const bool bits =
@@ -1104,7 +1104,7 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> 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<uint16_t>(response_pdu.data(), 2 + 2 * i));
}
@@ -1124,7 +1124,7 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu
// PackedBits::operator[] is unchecked, so size() must never promise bits with no bytes behind them.
std::span<const uint8_t> 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<const uint8_t> 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<uint16_t>(response_pdu.data(), 3)
: count_or_value;
if (function_code == FunctionCode::WRITE_SINGLE_REGISTER) {
+63 -36
View File
@@ -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<const uint8_t> pdu, ModbusClientDevice *device = nullptr,
CommandOptions options = {});
ESPDEPRECATED("Use send_pdu(payload[0], <pdu bytes>, 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<const uint8_t> 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<const uint8_t> pdu, ModbusClientDevice *device = nullptr) {
this->queue_pdu(address, pdu, device);
}
ESPDEPRECATED("Use queue_pdu(payload[0], <pdu bytes>, device) instead. Removed in 2027.2.0", "2026.8.0")
void send_raw(const std::vector<uint8_t> &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<ExceptionCode>;
/// 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<const uint8_t> request_pdu, std::span<const uint8_t> 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<const uint8_t> 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<const uint8_t> 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<uint8_t> &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<const uint8_t> 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<uint8_t> &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<const uint8_t>(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<const uint8_t>(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<const uint16_t> 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<bool> cannot bind to std::span<const bool>; use a contiguous bool container or the packed
/// overload.
bool write_multiple_coils(uint16_t start_address, std::span<const bool> 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); }
@@ -33,7 +33,11 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, 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<const uint8_t> 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<const uint8_t> 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<typename... Ts> class ClientActionBase : public Action<Ts...>, 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<const uint8_t> pdu) {
if (!this->send_pdu(pdu))
if (!this->queue_pdu(pdu))
this->on_not_sent(pdu);
}
@@ -107,7 +111,9 @@ template<typename... Ts> 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<typename... Ts> class TypedClientActionBase : public ClientActionBase<Ts...> {
public:
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> *get_custom_response_trigger() {
@@ -127,11 +133,6 @@ template<typename... Ts> class TypedClientActionBase : public ClientActionBase<T
}
protected:
/// Defensive assertion, not a live branch: ClientActionBase::on_error intercepts every exception reply
/// before the dispatch runs, so a typed callback below is only ever reached with a success status. Kept
/// so a future change to that interception cannot silently deliver an exception as a successful reply.
bool is_success_(modbus::ResponseStatus status) { return !status.has_value(); }
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> custom_response_trigger_;
bool custom_response_handled_{false};
};
@@ -154,7 +155,7 @@ template<typename... Ts> class ReadRegistersAction : public TypedClientActionBas
}
void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override {
if (this->is_success_(status))
if (modbus::succeeded(status))
this->response_trigger_.trigger(registers);
}
@@ -181,7 +182,7 @@ template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts.
}
void on_read_bits(modbus::EntityType entity_type, uint16_t start_address, modbus::PackedBits bits,
modbus::ResponseStatus status) override {
if (this->is_success_(status))
if (modbus::succeeded(status))
this->response_trigger_.trigger(bits);
}
@@ -204,7 +205,7 @@ template<typename... Ts> 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<typename... Ts> 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<typename... Ts> class WriteMultipleRegistersAction : public TypedClient
}
void on_write_multiple_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override {
if (this->is_success_(status))
if (modbus::succeeded(status))
this->response_trigger_.trigger();
}
@@ -318,7 +319,7 @@ template<typename... Ts> 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();
}
@@ -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<const uint8_t> frame =
this->custom_data_ != nullptr ? std::span<const uint8_t>(*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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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)
@@ -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
@@ -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<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> 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
+1 -1
View File
@@ -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
@@ -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:
+8 -4
View File
@@ -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
+2
View File
@@ -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
+4
View File
@@ -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()))
+5
View File
@@ -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
+136
View File
@@ -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
+1 -1
View File
@@ -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:
+21 -2
View File
@@ -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)
+2
View File
@@ -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)
)
+1
View File
@@ -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
+18 -2
View File
@@ -1134,13 +1134,29 @@ def convert_keys(converted, schema, path):
else:
converted["key"] = "String"
key_string_match = re.search(
r"<function (\w*) at \w*>", str(k), re.IGNORECASE
r"<function ([^ ]+) at \w+>", 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"<function ([^ ]+) at \w+>", 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
@@ -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)
@@ -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())
@@ -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
@@ -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 <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include <vector>
#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<uint8_t> data;
int8_t rssi;
};
struct RawCapture {
std::vector<DeliveredFrame> frames;
static void trampoline(void *self, const RawAdvertisement &adv) {
auto *capture = static_cast<RawCapture *>(self);
capture->frames.push_back({adv.address, std::vector<uint8_t>(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<uint8_t> 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<uint8_t> 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<uint8_t> adv(20, 0xAA);
this->merger_.stash_adv(MAC_A, -40, /*addr_type=*/0, adv.data(), adv.size(), 0);
std::vector<uint8_t> 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<uint8_t> 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
+53
View File
@@ -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:
+4 -4
View File
@@ -134,7 +134,7 @@ TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) {
size_t total = 0;
for (int i = 0; i != n; i++) {
req[2] = static_cast<uint8_t>(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<uint8_t>(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<const uint8_t> 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
+161 -125
View File
@@ -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<uint8_t>(i >> 8), static_cast<uint8_t>(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<const uint8_t> 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<uint8_t>(i >> 8), static_cast<uint8_t>(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<const uint8_t>{})); // empty: refused, no callback
clearer.clear_tx_queue_for_address(); // the clear the handler used to make
EXPECT_FALSE(clearer.queue_pdu(std::span<const uint8_t>{})); // 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<const uint8_t> 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<const uint8_t>());
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<const uint8_t>())); // empty PDU: refused at the door
EXPECT_FALSE(device.queue_pdu(std::span<const uint8_t>())); // 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<uint8_t> 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<uint8_t> 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<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->send_pdu(std::vector<uint8_t>(request_pdu.begin(), request_pdu.end()));
this->queue_pdu(std::vector<uint8_t>(request_pdu.begin(), request_pdu.end()));
}
void send_pdu(const std::vector<uint8_t> &pdu) { ModbusClientDevice::send_pdu(pdu); }
void queue_pdu(const std::vector<uint8_t> &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();
@@ -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.<locals>.validator")
assert "at 0x" not in converted["key_type"]
assert set(converted["schema"]["config_vars"]) == {"string"}
# ---------------------------------------------------------------------------
# Regression tests for the lvgl schema dump.
#
+14
View File
@@ -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."""
@@ -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()
@@ -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()
+289 -50
View File
@@ -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
+27
View File
@@ -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())
+325
View File
@@ -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()
+25 -17
View File
@@ -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."
)
+266 -1
View File
@@ -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