mirror of
https://github.com/esphome/esphome.git
synced 2026-08-24 07:06:20 +00:00
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:
@@ -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}"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user