Compare commits

...
25 changed files with 525 additions and 210 deletions

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
name: Install apt packages (cached)
description: >-
Installs libsdl2-dev, libmbedtls-dev and ccache from one shared cache. The cache key comes
only from the package list and version, so both live here and nowhere else.
runs:
using: "composite"
steps:
- name: Keep a failed install out of the cache
# cache-apt-pkgs ignores install failures and saved an empty cache when a stale package
# list got a 404 (upstream awalsh128/cache-apt-pkgs-action#221). It uses an apt-fast
# already on PATH: this one refreshes the lists and retries when the install fails and,
# if that fails too, removes the folder the action would save. Only a cache miss installs.
shell: bash
run: |
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
Acquire::Retries "1";
Acquire::http::Timeout "15";
Acquire::https::Timeout "15";
EOF
sudo tee /usr/local/bin/apt-fast > /dev/null <<'EOF'
#!/bin/bash
if [[ " $* " != *" install "* ]]; then
exec apt-get "$@"
fi
touch /tmp/apt-fast-standin-ran
apt-get "$@" && exit 0
apt-get update -qq >&2
# The action pins each package to the version the stale lists named
args=()
for arg in "$@"; do
if [[ "${arg}" == -* ]]; then args+=("${arg}"); else args+=("${arg%%=*}"); fi
done
apt-get "${args[@]}" && exit 0
# The action writes our output to install.log inside the folder it saves
log="$(readlink -f "/proc/$$/fd/1")"
if [ "$(basename "${log}")" = install.log ]; then
rm -rf "$(dirname "${log}")"
else
touch /tmp/apt-fast-cleanup-missed
fi
exit 1
EOF
sudo chmod +x /usr/local/bin/apt-fast
- name: Install apt packages (cached)
id: apt
# Pinned on purpose and not watched by Dependabot: the step above relies on how this
# release calls apt-fast and where it writes install.log.
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
# 1.3 drops the empty caches saved before the step above existed
version: 1.3
- name: Check the packages are installed
shell: bash
env:
CACHE_HIT: ${{ steps.apt.outputs.cache-hit }}
run: |
if [ "${CACHE_HIT}" != "true" ] && [ ! -e /tmp/apt-fast-standin-ran ]; then
echo "::error::cache-apt-pkgs did not use the apt-fast stand-in; check the pinned release"
exit 1
fi
if [ -e /tmp/apt-fast-cleanup-missed ]; then
echo "::error::the failed install may have been cached; check the pinned release and bump the version"
exit 1
fi
# The action restores files without registering them with dpkg
for cmd in ccache sdl2-config; do
if ! command -v "${cmd}" > /dev/null; then
echo "::error::${cmd} is missing after the cached install"
exit 1
fi
done
if [ ! -f /usr/include/mbedtls/version.h ]; then
echo "::error::libmbedtls-dev is missing after the cached install"
exit 1
fi
+18 -29
View File
@@ -73,16 +73,17 @@ jobs:
runs-on: ubuntu-24.04
# PR-branch cache saves are invisible to other PRs, so dev/beta/release
# pushes seed the one shared entry PR jobs restore. The key is derived
# only from the package list and version; keep both identical in every
# step that restores it. In ci-status needs so a broken seed fails dev.
# only from the package list and version, kept in the apt-packages action.
# In ci-status needs so a broken seed fails dev.
if: github.event_name == 'push'
timeout-minutes: 10
steps:
- name: Install apt packages (cached)
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
sparse-checkout: .github
- name: Install apt packages (cached)
uses: ./.github/actions/apt-packages
determine-jobs:
name: Determine which jobs to run
@@ -412,14 +413,10 @@ jobs:
- name: Install apt packages (cached)
# ccache speeds up the host compiles. A cache hit never touches apt
# (mirror outages cannot hang the job); the timeout bounds the cold
# path. Packages and version must match seed-apt-cache exactly.
# libsdl2-dev is needed by the headless display tests, which capture
# screenshots.
# path. libsdl2-dev is needed by the headless display tests, which
# capture screenshots.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
uses: ./.github/actions/apt-packages
- name: Set up Python 3.13
id: python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
@@ -718,13 +715,9 @@ jobs:
- name: Install apt packages (cached)
if: matrix.pio_cache_key == 'tidyhost'
# libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev
# by dsmr and dlms_meter. Packages and version must match seed-apt-cache
# exactly.
# by dsmr and dlms_meter.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
uses: ./.github/actions/apt-packages
- name: Restore Python
id: restore-python
@@ -1111,20 +1104,16 @@ jobs:
- name: List components
run: echo ${{ matrix.batch.components }}
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install apt packages (cached)
# A cache hit (seeded on dev by seed-apt-cache) never touches apt,
# so mirror outages cannot hang this PR-only job; the timeout bounds
# the cold path. Packages and version must match seed-apt-cache
# exactly. The action has no --no-install-recommends; same package
# set this job used before #17463.
# the cold path. The action has no --no-install-recommends; same
# package set this job used before #17463.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: ./.github/actions/apt-packages
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -1,6 +1,7 @@
import logging
import esphome.codegen as cg
from esphome.components.const import CONF_ON_NUMERIC_COMPARISON_REQUEST
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT
from esphome.components.ota import (
BASE_OTA_SCHEMA,
@@ -12,10 +13,7 @@ from esphome.components.ota import (
)
from esphome.components.zephyr import zephyr_data
from esphome.components.zephyr.const import KEY_BOOTLOADER
from esphome.components.zephyr_ble_server import (
CONF_ON_NUMERIC_COMPARISON_REQUEST,
DOMAIN as ZEPHYR_BLE_SERVER_DOMAIN,
)
from esphome.components.zephyr_ble_server import DOMAIN as ZEPHYR_BLE_SERVER_DOMAIN
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_ON_ERROR, Framework
from esphome.core import CoroPriority, coroutine_with_priority
+1 -2
View File
@@ -5,6 +5,7 @@ from esphome import automation
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
from esphome.components.const import CONF_ACCEPT, CONF_ON_NUMERIC_COMPARISON_REQUEST
from esphome.components.esp32_ble import BTLoggers
import esphome.config_validation as cv
from esphome.const import (
@@ -101,10 +102,8 @@ BLERemoveBondAction = ble_client_ns.class_(
)
CONF_PASSKEY = "passkey"
CONF_ACCEPT = "accept"
CONF_ON_PASSKEY_REQUEST = "on_passkey_request"
CONF_ON_PASSKEY_NOTIFICATION = "on_passkey_notification"
CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request"
CONF_AUTO_CONNECT = "auto_connect"
MULTI_CONF = True
+2
View File
@@ -8,6 +8,7 @@ BYTE_ORDER_BIG = "big_endian"
CONF_ACCELEROMETER_ODR = "accelerometer_odr"
CONF_ACCELEROMETER_RANGE = "accelerometer_range"
CONF_ACCEPT = "accept"
CONF_ADC_AVERAGING = "adc_averaging"
CONF_ADC_TIME = "adc_time"
CONF_ALLOWED_IPS = "allowed_ips"
@@ -58,6 +59,7 @@ CONF_MAX_PACKET_SIZE = "max_packet_size"
CONF_MODEM = "modem"
CONF_MODULATION = "modulation"
CONF_NOX_INDEX = "nox_index"
CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request"
CONF_ON_PACKET = "on_packet"
CONF_ON_RECEIVE = "on_receive"
CONF_ON_SCAN_END = "on_scan_end"
+73
View File
@@ -25,16 +25,86 @@ extern "C" {
#include <freertos/FreeRTOSConfig.h>
#include <freertos/task.h>
#include <cstring>
#ifdef USE_ARDUINO
// Prevent Arduino from releasing BT memory at startup (esp32-hal-misc.c).
// Without this, esp_bt_controller_init() fails with ESP_ERR_INVALID_STATE.
extern "C" bool btInUse() { return true; } // NOLINT(readability-identifier-naming)
#endif
#if !defined(CONFIG_BT_CONTROLLER_DISABLED) && \
(defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3))
// The ESP32-C3 and ESP32-S3 share one controller (lib_esp32c3_family, RivieraWaves link layer).
#define USE_ESP32_BLE_TERMINATE_PATCH
// Controller internals, reached through r_ip_funcs_p: a table btdm_controller_init() copies into RAM.
extern "C" {
extern void *r_ip_funcs_p;
const char *btdm_controller_get_compile_version();
uint8_t r_lld_con_terminate_max_evt_update(uint8_t link_id, uint8_t acked);
uint8_t *r_lld_con_cntl_pkt_info_get();
}
#endif
namespace esphome::esp32_ble {
ESPHOME_LOG_TAG(TAG, "esp32_ble");
#ifdef USE_ESP32_BLE_TERMINATE_PATCH
// The controller ends its terminate procedure after 6 connection events without an acknowledgement and reports
// the link closed. A peer using peripheral latency listens only every (latency + 1) events, so it can miss all
// six LL_TERMINATE_IND: it keeps the link and searches for the central at full receive current until its own
// supervision timeout. The Core spec keeps the procedure going until the acknowledgement or the supervision
// timeout; this does that, by holding the controller's no-acknowledgement count below its limit. A peer that
// never answers still ends the link at the supervision timeout.
static constexpr size_t TERMINATE_MAX_EVT_SLOT = 0x7dc / sizeof(void *);
static constexpr uint8_t TERMINATE_MAX_EVT = 5; // the controller gives up once its count exceeds this
static uint8_t IRAM_ATTR terminate_until_acked(uint8_t link_id, uint8_t acked) {
// Per link: [2] terminate in progress, [3] connection events without an acknowledgement.
uint8_t *info = r_lld_con_cntl_pkt_info_get() + link_id * 4;
if (!acked && info[2] != 0 && info[3] >= TERMINATE_MAX_EVT)
info[3] = TERMINATE_MAX_EVT - 1;
return r_lld_con_terminate_max_evt_update(link_id, acked);
}
// The controller builds whose r_lld_con_terminate_max_evt_update and r_lld_con_cntl_pkt_info_get the wrapper was
// checked against. The table slot alone does not prove the pkt_info layout, so an unknown build is left alone.
// btdm_controller_get_compile_version() is what ESP-IDF logs at init as "BT controller compile version [...]".
// Recheck on every ESP-IDF bump: add the new build once both functions are confirmed unchanged, or drop the patch once
// espressif/esp-idf#19189 is fixed.
static const char *const TERMINATE_PATCH_CONTROLLER_BUILDS[] = {
"51d9dfd", // ESP-IDF 5.5.5-5.5.6, Arduino 3.3.10-3.3.12
"b7de11e", // ESP-IDF 5.5.4, Arduino 3.3.8-3.3.9: both functions identical to 51d9dfd's
};
static void patch_terminate_until_acked() {
auto **table = static_cast<void **>(r_ip_funcs_p);
if (table == nullptr) {
ESP_LOGW(TAG, "Controller layout not recognised; disconnects keep the controller's 6-event limit");
return;
}
const char *build = btdm_controller_get_compile_version();
bool known_build = false;
for (const char *known : TERMINATE_PATCH_CONTROLLER_BUILDS)
known_build = known_build || strcmp(build, known) == 0;
if (!known_build) {
// Expected after an ESP-IDF bump, and nothing a user can act on
ESP_LOGD(TAG,
"Controller build %s not checked for the terminate patch; disconnects keep the controller's 6-event limit",
build);
return;
}
if (table[TERMINATE_MAX_EVT_SLOT] == reinterpret_cast<void *>(&terminate_until_acked))
return; // already patched: the controller stayed enabled across a setup
if (table[TERMINATE_MAX_EVT_SLOT] != reinterpret_cast<void *>(&r_lld_con_terminate_max_evt_update)) {
ESP_LOGW(TAG, "Controller build %s has an unexpected terminate handler; terminate patch skipped", build);
return;
}
table[TERMINATE_MAX_EVT_SLOT] = reinterpret_cast<void *>(&terminate_until_acked);
}
#endif
#ifdef CONFIG_BT_CONTROLLER_DISABLED
// Bringing up the remote BT controller issues synchronous RPCs to the
// co-processor with 5 second response timeouts, and the default task watchdog
@@ -223,6 +293,9 @@ bool ESP32BLE::ble_setup_() {
return false;
}
}
#ifdef USE_ESP32_BLE_TERMINATE_PATCH
patch_terminate_until_acked();
#endif
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT);
#else
+27 -2
View File
@@ -1,5 +1,4 @@
#include "ld2410s.h"
#include "ld2410s_number.h"
#include <cstring>
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
@@ -40,6 +39,11 @@ static constexpr uint8_t CMD_FRAME_MAX_SIZE = LONG_PAYLOAD_POS + 2 + PARAMETERS_
#ifdef USE_LD2410S_PARAMETERS
static constexpr uint16_t PARAMETER_KEYS[] PROGMEM = {0x0005, 0x000A, 0x0006, 0x0002, 0x000C, 0x000B};
static_assert(sizeof(PARAMETER_KEYS) / sizeof(PARAMETER_KEYS[0]) == PARAMETER_COUNT, "one key per parameter");
#ifdef USE_NUMBER
// Module units to number units: 0.7 m gates, seconds, tenths of a hertz (number.py uses the same)
static constexpr float PARAMETER_SCALES[] PROGMEM = {0.7f, 0.7f, 1.0f, 0.1f, 0.1f, 1.0f};
static_assert(sizeof(PARAMETER_SCALES) / sizeof(PARAMETER_SCALES[0]) == PARAMETER_COUNT, "one scale per parameter");
#endif
#endif
// The version answer: 4 bytes device type, then major, minor and patch as 16-bit words
static constexpr uint8_t VERSION_TYPE_SIZE = 4;
@@ -136,6 +140,27 @@ bool LD2410SComponent::set_parameter(Parameter parameter, uint8_t value) {
this->parameters_dirty_ = true; // written by loop() once no sequence is running
return true;
}
#ifdef USE_NUMBER
void LD2410SComponent::control_number(number::Number *number, float value) {
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
if (this->numbers_[i] != number)
continue;
// Show the step the module got, not a value between two steps
const uint8_t raw = lroundf(value / PARAMETER_SCALES[i]); // the schema keeps every parameter within a byte
if (this->set_parameter(static_cast<Parameter>(i), raw))
number->publish_state(raw * PARAMETER_SCALES[i]);
return;
}
}
#endif
#ifdef USE_SELECT
void LD2410SComponent::control_response_speed(select::Select *select, size_t index) {
if (this->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index])))
select->publish_state(index);
}
#endif
#endif
void LD2410SComponent::restart_init_(uint32_t at) {
@@ -502,7 +527,7 @@ void LD2410SComponent::publish_parameters_() {
#ifdef USE_NUMBER
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
if (this->numbers_[i] != nullptr) {
this->numbers_[i]->publish_raw(this->parameters_[i]);
this->numbers_[i]->publish_state(this->parameters_[i] * PARAMETER_SCALES[i]);
}
}
#endif
+10 -6
View File
@@ -14,6 +14,9 @@
#ifdef USE_LD2410S_VERSION
#include "esphome/components/text_sensor/text_sensor.h"
#endif
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
#include "esphome/components/number/number.h"
#endif
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
#include "esphome/components/select/select.h"
#endif
@@ -39,10 +42,6 @@ static constexpr uint8_t PARAMETER_COUNT = static_cast<uint8_t>(Parameter::PARAM
static constexpr uint8_t RESPONSE_SPEEDS[] PROGMEM = {5, 10};
static constexpr uint8_t RESPONSE_SPEED_COUNT = sizeof(RESPONSE_SPEEDS);
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
class LD2410SNumber;
#endif
class LD2410SComponent final : public Component, public uart::UARTDevice {
#ifdef USE_BINARY_SENSOR
SUB_BINARY_SENSOR(target)
@@ -67,9 +66,14 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
#ifdef USE_LD2410S_PARAMETERS
bool set_parameter(Parameter parameter, uint8_t value);
#ifdef USE_NUMBER
void set_number(Parameter parameter, LD2410SNumber *number) {
void set_number(Parameter parameter, number::Number *number) {
this->numbers_[static_cast<uint8_t>(parameter)] = number;
}
/// Writes the number's parameter and publishes the step the module got.
void control_number(number::Number *number, float value);
#endif
#ifdef USE_SELECT
void control_response_speed(select::Select *select, size_t index);
#endif
#endif
@@ -109,7 +113,7 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
#endif
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
LD2410SNumber *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number
number::Number *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number
#endif
const uint16_t *sequence_{nullptr}; // PROGMEM; null while nothing is being sent
@@ -1,32 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
#include "ld2410s.h"
#include "esphome/components/number/number.h"
namespace esphome::ld2410s {
/// Edits one common parameter; scale converts the module's units to the number's.
class LD2410SNumber final : public number::Number, public Parented<LD2410SComponent> {
public:
LD2410SNumber(Parameter parameter, float scale) : parameter_(parameter), scale_(scale) {}
void publish_raw(uint8_t raw) { this->publish_state(raw * this->scale_); }
protected:
void control(float value) override {
// Show the step the module got, not a value between two steps
const uint8_t raw = lroundf(value / this->scale_); // the schema keeps every parameter within a byte
if (this->parent_->set_parameter(this->parameter_, raw))
this->publish_raw(raw);
}
const Parameter parameter_;
const float scale_;
};
} // namespace esphome::ld2410s
#endif // USE_NUMBER && USE_LD2410S_PARAMETERS
@@ -1,21 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
#include "ld2410s.h"
#include "esphome/components/select/select.h"
namespace esphome::ld2410s {
class LD2410SSelect final : public select::Select, public Parented<LD2410SComponent> {
protected:
void control(size_t index) override {
if (this->parent_->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index])))
this->publish_state(index);
}
};
} // namespace esphome::ld2410s
#endif // USE_SELECT && USE_LD2410S_PARAMETERS
+9 -15
View File
@@ -16,7 +16,7 @@ from esphome.const import (
)
from esphome.types import ConfigType
from . import CONF_LD2410S_ID, LD2410SComponent, Parameter, ld2410s_ns
from . import CONF_LD2410S_ID, LD2410SComponent, Parameter
DEPENDENCIES = ["ld2410s"]
@@ -26,9 +26,8 @@ CONF_MIN_DISTANCE = "min_distance"
CONF_NO_DELAY = "no_delay"
CONF_STATUS_REPORTING_FREQUENCY = "status_reporting_frequency"
LD2410SNumber = ld2410s_ns.class_("LD2410SNumber", number.Number)
# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz
# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz;
# PARAMETER_SCALES in ld2410s.cpp converts with the same factors
GATE_WIDTH_M = 0.7
FREQUENCY_SCALE = 0.1
_DISTANCE_RANGE = {"min_value": 0, "max_value": 16 * GATE_WIDTH_M, "step": GATE_WIDTH_M}
@@ -37,7 +36,7 @@ _FREQUENCY_RANGE = {"min_value": 0.5, "max_value": 8, "step": 0.5}
def _number_schema(device_class: str, unit: str, icon: str) -> cv.Schema:
return number.number_schema(
LD2410SNumber,
number.ApplyNumber,
device_class=device_class,
entity_category=ENTITY_CATEGORY_CONFIG,
unit_of_measurement=unit,
@@ -72,22 +71,19 @@ async def to_code(config: ConfigType) -> None:
await numbers(
CONF_MAX_DISTANCE,
partial(hub.set_number, Parameter.PARAMETER_MAX_DISTANCE),
Parameter.PARAMETER_MAX_DISTANCE,
GATE_WIDTH_M,
apply="control_number(entity, value)",
**_DISTANCE_RANGE,
)
await numbers(
CONF_MIN_DISTANCE,
partial(hub.set_number, Parameter.PARAMETER_MIN_DISTANCE),
Parameter.PARAMETER_MIN_DISTANCE,
GATE_WIDTH_M,
apply="control_number(entity, value)",
**_DISTANCE_RANGE,
)
await numbers(
CONF_NO_DELAY,
partial(hub.set_number, Parameter.PARAMETER_NO_DELAY),
Parameter.PARAMETER_NO_DELAY,
1,
apply="control_number(entity, value)",
min_value=1,
max_value=120,
step=1,
@@ -95,14 +91,12 @@ async def to_code(config: ConfigType) -> None:
await numbers(
CONF_STATUS_REPORTING_FREQUENCY,
partial(hub.set_number, Parameter.PARAMETER_STATUS_FREQUENCY),
Parameter.PARAMETER_STATUS_FREQUENCY,
FREQUENCY_SCALE,
apply="control_number(entity, value)",
**_FREQUENCY_RANGE,
)
await numbers(
CONF_DISTANCE_REPORTING_FREQUENCY,
partial(hub.set_number, Parameter.PARAMETER_DISTANCE_FREQUENCY),
Parameter.PARAMETER_DISTANCE_FREQUENCY,
FREQUENCY_SCALE,
apply="control_number(entity, value)",
**_FREQUENCY_RANGE,
)
+6 -5
View File
@@ -4,18 +4,16 @@ import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG
from esphome.types import ConfigType
from . import CONF_LD2410S_ID, LD2410SComponent, ld2410s_ns
from . import CONF_LD2410S_ID, LD2410SComponent
DEPENDENCIES = ["ld2410s"]
CONF_RESPONSE_SPEED = "response_speed"
LD2410SSelect = ld2410s_ns.class_("LD2410SSelect", select.Select)
CONFIG_SCHEMA = {
cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent),
cv.Required(CONF_RESPONSE_SPEED): select.select_schema(
LD2410SSelect,
select.ApplySelect,
entity_category=ENTITY_CATEGORY_CONFIG,
icon="mdi:speedometer",
),
@@ -27,5 +25,8 @@ async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_LD2410S_ID])
selects = select.sub_selects(config, parent=hub)
await selects(
CONF_RESPONSE_SPEED, hub.set_response_speed_select, options=["Normal", "Fast"]
CONF_RESPONSE_SPEED,
hub.set_response_speed_select,
options=["Normal", "Fast"],
apply="control_response_speed(entity, index)",
)
@@ -12,7 +12,7 @@ void PipsolarOutput::write_state(float state) {
if (std::find(this->possible_values_.begin(), this->possible_values_.end(), state) != this->possible_values_.end()) {
ESP_LOGD(TAG, "Will write: %s out of value %f / %02.0f", tmp, state, state);
this->parent_->queue_command(std::string(tmp));
this->parent_->queue_command(tmp);
} else {
ESP_LOGD(TAG, "Will not write: %s as it is not in list of allowed values", tmp);
}
+9 -3
View File
@@ -224,17 +224,23 @@ bool Pipsolar::send_next_poll_() {
return false;
}
void Pipsolar::queue_command(const std::string &command) {
void Pipsolar::write_switch(bool state, const char *on, const char *off) {
const char *command = state ? on : off;
if (command != nullptr)
this->queue_command(command);
}
void Pipsolar::queue_command(const char *command) {
uint8_t next_position = command_queue_position_;
for (uint8_t i = 0; i < COMMAND_QUEUE_LENGTH; i++) {
uint8_t testposition = (next_position + i) % COMMAND_QUEUE_LENGTH;
if (command_queue_[testposition].empty()) {
command_queue_[testposition] = command;
ESP_LOGD(TAG, "Command queued successfully: %s at position %d", command.c_str(), testposition);
ESP_LOGD(TAG, "Command queued successfully: %s at position %d", command, testposition);
return;
}
}
ESP_LOGD(TAG, "Command queue full dropping command: %s", command.c_str());
ESP_LOGD(TAG, "Command queue full dropping command: %s", command);
}
void Pipsolar::handle_poll_response_(ENUMPollingCommand polling_command, const char *message) {
+4 -1
View File
@@ -194,7 +194,10 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent {
PIPSOLAR_SWITCH(pv_ok_condition_for_parallel_switch, QPIRI)
PIPSOLAR_SWITCH(pv_power_balance_switch, QPIRI)
void queue_command(const std::string &command);
void queue_command(const char *command);
void queue_command(const std::string &command) { this->queue_command(command.c_str()); }
// A switch write; off is nullptr when the inverter has no off command
void write_switch(bool state, const char *on, const char *off);
void setup() override;
void loop() override;
void dump_config() override;
+58 -24
View File
@@ -1,10 +1,14 @@
from collections.abc import Callable
from typing import NamedTuple
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import ICON_POWER
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns
from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA
DEPENDENCIES = ["uart"]
@@ -16,21 +20,55 @@ CONF_INPUT_VOLTAGE_RANGE = "input_voltage_range"
CONF_PV_OK_CONDITION_FOR_PARALLEL = "pv_ok_condition_for_parallel"
CONF_PV_POWER_BALANCE = "pv_power_balance"
class _PipSwitch(NamedTuple):
setter: Callable[[MockObj], MockObj]
on: str
off: str | None # None when the inverter has no off command
# Each switch's hub setter and its on and off commands
TYPES = {
CONF_OUTPUT_SOURCE_PRIORITY_UTILITY: ("POP00", None),
CONF_OUTPUT_SOURCE_PRIORITY_SOLAR: ("POP01", None),
CONF_OUTPUT_SOURCE_PRIORITY_BATTERY: ("POP02", None),
CONF_OUTPUT_SOURCE_PRIORITY_HYBRID: ("POP03", None),
CONF_INPUT_VOLTAGE_RANGE: ("PGR01", "PGR00"),
CONF_PV_OK_CONDITION_FOR_PARALLEL: ("PPVOKC1", "PPVOKC0"),
CONF_PV_POWER_BALANCE: ("PSPB1", "PSPB0"),
CONF_OUTPUT_SOURCE_PRIORITY_UTILITY: _PipSwitch(
lambda hub: hub.set_output_source_priority_utility_switch,
"POP00",
None,
),
CONF_OUTPUT_SOURCE_PRIORITY_SOLAR: _PipSwitch(
lambda hub: hub.set_output_source_priority_solar_switch,
"POP01",
None,
),
CONF_OUTPUT_SOURCE_PRIORITY_BATTERY: _PipSwitch(
lambda hub: hub.set_output_source_priority_battery_switch,
"POP02",
None,
),
CONF_OUTPUT_SOURCE_PRIORITY_HYBRID: _PipSwitch(
lambda hub: hub.set_output_source_priority_hybrid_switch,
"POP03",
None,
),
CONF_INPUT_VOLTAGE_RANGE: _PipSwitch(
lambda hub: hub.set_input_voltage_range_switch,
"PGR01",
"PGR00",
),
CONF_PV_OK_CONDITION_FOR_PARALLEL: _PipSwitch(
lambda hub: hub.set_pv_ok_condition_for_parallel_switch,
"PPVOKC1",
"PPVOKC0",
),
CONF_PV_POWER_BALANCE: _PipSwitch(
lambda hub: hub.set_pv_power_balance_switch,
"PSPB1",
"PSPB0",
),
}
PipsolarSwitch = pipsolar_ns.class_("PipsolarSwitch", switch.Switch, cg.Component)
PIPSWITCH_SCHEMA = switch.switch_schema(
PipsolarSwitch, icon=ICON_POWER, block_inverted=True
).extend(cv.COMPONENT_SCHEMA)
switch.ApplySwitch, icon=ICON_POWER, block_inverted=True
)
CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend(
{cv.Optional(type): PIPSWITCH_SCHEMA for type in TYPES}
@@ -38,15 +76,11 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend(
async def to_code(config: ConfigType) -> None:
paren = await cg.get_variable(config[CONF_PIPSOLAR_ID])
for type, (on, off) in TYPES.items():
if type in config:
conf = config[type]
var = await switch.new_switch(conf)
await cg.register_component(var, conf)
cg.add(getattr(paren, f"set_{type}_switch")(var))
cg.add(var.set_parent(paren))
cg.add(var.set_on_command(on))
if off is not None:
cg.add(var.set_off_command(off))
hub = await cg.get_variable(config[CONF_PIPSOLAR_ID])
switches = switch.sub_switches(config, parent=hub)
# The hub polls the inverter and publishes each switch's state
for key, sw in TYPES.items():
off_arg = "nullptr" if sw.off is None else f'"{sw.off}"'
await switches(
key, sw.setter(hub), apply=f'write_switch(state, "{sw.on}", {off_arg})'
)
@@ -1,17 +0,0 @@
#include "pipsolar_switch.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
namespace esphome::pipsolar {
ESPHOME_LOG_TAG(TAG, "pipsolar.switch");
void PipsolarSwitch::dump_config() { LOG_SWITCH("", "Pipsolar Switch", this); }
void PipsolarSwitch::write_state(bool state) {
const char *command = state ? this->on_command_ : this->off_command_;
if (command != nullptr) {
this->parent_->queue_command(command);
}
}
} // namespace esphome::pipsolar
@@ -1,26 +0,0 @@
#pragma once
#include "../pipsolar.h"
#include "esphome/components/switch/switch.h"
#include "esphome/core/component.h"
namespace esphome::pipsolar {
class Pipsolar;
class PipsolarSwitch final : public switch_::Switch, public Component {
public:
void set_parent(Pipsolar *parent) { this->parent_ = parent; }
void set_on_command(const char *command) { this->on_command_ = command; }
void set_off_command(const char *command) { this->off_command_ = command; }
/// Prevent accidental use of std::string which would dangle
void set_on_command(const std::string &command) = delete;
void set_off_command(const std::string &command) = delete;
void dump_config() override;
protected:
void write_state(bool state) override;
const char *on_command_{nullptr};
const char *off_command_{nullptr};
Pipsolar *parent_;
};
} // namespace esphome::pipsolar
@@ -2,6 +2,7 @@ from dataclasses import dataclass
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_ACCEPT, CONF_ON_NUMERIC_COMPARISON_REQUEST
from esphome.components.nrf52.framework import (
bluetooth_west_projects,
include_west_project,
@@ -54,8 +55,6 @@ def request_ble_l2cap_mtu(l2cap_mtu: int) -> None:
zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server")
BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component)
CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request"
CONF_ACCEPT = "accept"
CONFIG_SCHEMA = cv.All(
cv.Schema(
@@ -0,0 +1,18 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
sendspin:
switch:
- platform: sendspin
type: enabled
id: sendspin_enabled
name: Sendspin Enabled
@@ -0,0 +1,19 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
sendspin:
switch:
- platform: sendspin
type: enabled
id: sendspin_enabled
name: Sendspin Enabled
restore_mode: DISABLED
@@ -0,0 +1,45 @@
"""Code generation tests for the enabled switch.
The client stays stopped until the hub has an enabled state: codegen sets it when
no enabled switch is configured, and the switch answers from its restore otherwise.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
GenerateMain = Callable[[str | Path], str]
ConfigPath = Callable[[str], Path]
def test_without_switch_codegen_enables_the_client(
generate_main: GenerateMain, component_config_path: ConfigPath
) -> None:
"""With no enabled switch, nothing else would start the client."""
main_cpp = generate_main(component_config_path("device_info_default.yaml"))
assert "->set_enabled(true);" in main_cpp
@pytest.mark.parametrize(
("config", "restore_mode"),
[
("switch_enabled.yaml", "SWITCH_RESTORE_DEFAULT_ON"),
("switch_enabled_disabled_restore.yaml", "SWITCH_RESTORE_DISABLED"),
],
)
def test_switch_answers_the_hub(
generate_main: GenerateMain,
component_config_path: ConfigPath,
config: str,
restore_mode: str,
) -> None:
"""The hub waits for the switch's restore instead of being enabled by codegen."""
main_cpp = generate_main(component_config_path(config))
assert "->set_enabled(true);" not in main_cpp
assert f"sendspin_enabled->set_restore_mode(switch_::{restore_mode});" in main_cpp
assert "App.register_switch(sendspin_enabled," in main_cpp
@@ -74,6 +74,22 @@ uart_mock:
inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x70, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
# Then the response speed Fast: the same values with speed 10, acknowledged
- expect_tx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x26, 0x00, 0x70, 0x00, 0x05, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x0A, 0x00,
0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x02, 0x00, 0x50, 0x00, 0x00, 0x00,
0x0C, 0x00, 0x50, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01,
]
inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x70, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
# Then no delay 20 s, rejected with ack 1: the component reads the module's values back
- expect_tx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x26, 0x00, 0x70, 0x00, 0x05, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x0A, 0x00,
0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x14, 0x00, 0x00, 0x00, 0x02, 0x00, 0x50, 0x00, 0x00, 0x00,
0x0C, 0x00, 0x50, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01,
]
inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x70, 0x01, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01]
injections:
# Short frame, state 2 (target), distance 300 -> presence on
- delay: 100ms
@@ -137,6 +153,14 @@ number:
name: "Max Distance"
no_delay:
name: "No Delay"
status_reporting_frequency:
name: "Status Reporting Frequency"
select:
- platform: ld2410s
ld2410s_id: ld2410s_dev
response_speed:
name: "Response Speed"
button:
- platform: template
@@ -92,6 +92,19 @@ binary_sensor:
has_target:
name: "Has Target"
# Commanded before the module answered: the changes are ignored and nothing is published
number:
- platform: ld2410s
ld2410s_id: ld2410s_dev
max_distance:
name: "Max Distance"
select:
- platform: ld2410s
ld2410s_id: ld2410s_dev
response_speed:
name: "Response Speed"
button:
- platform: template
name: "Start Scenario"
+109 -20
View File
@@ -5,9 +5,15 @@ test_uart_mock_ld2410s:
switch and config-mode end go out in order, each after the previous one is
acknowledged, then the component logs "Setup done" and clears its warning;
the version and parameter answers are parsed and logged
1b. Setting the max distance number to 7 m writes all six parameters (gate 10
1a. The numbers show the values read at init in their own units (gates of 0.7 m,
seconds, tenths of a hertz) and the response speed select shows Normal
1b. Setting the max distance number to 7.3 m writes all six parameters (gate 10
plus the values read at init) inside a configuration-mode session, and the
number reports 7 m
number reports the 7 m the module got, not 7.3 m
1c. Selecting the Fast response speed writes speed 10 with the same values and
the select reports Fast
1d. Setting no delay to 20 s reports 20 s at once; the module rejects the write,
so the values are read back and every entity shows the module's own value again
2. Presence from short frames (states 0/1 off, 2/3 on) and from a standard
data frame
3. Resync - a noise byte or a partial header directly before a short frame
@@ -16,6 +22,8 @@ test_uart_mock_ld2410s:
dropped without publishing, and the short frame after each still publishes
test_uart_mock_ld2410s_recovery (the mock never answers the init commands):
0. A number or select changed before the module answered logs that the change
is ignored and publishes nothing
1. Config-mode start is resent after the acknowledgement timeout
2. Once the retries run out, config-mode end is sent and the component warns
that it is starting over
@@ -36,9 +44,19 @@ test_uart_mock_ld2410s_link_lost:
from __future__ import annotations
import asyncio
from collections.abc import Callable
import re
from aioesphomeapi import APIClient, ButtonInfo, EntityState, NumberInfo, NumberState
from aioesphomeapi import (
APIClient,
ButtonInfo,
EntityInfo,
EntityState,
NumberInfo,
NumberState,
SelectInfo,
SelectState,
)
import pytest
from .log_utils import LineWaiter
@@ -66,12 +84,20 @@ INIT_TX = [
OUTPUT_MODE_TX,
CONFIG_END_TX,
]
# All six parameters: max gate 10, min gate 0, no delay 10, both frequencies 80, speed 5
PARAMETERS_WRITE_TX = (
"FD:FC:FB:FA:26:00:70:00:05:00:0A:00:00:00:0A:00:00:00:00:00:06:00:0A:00:00:00:02:00:50:00:00:00:"
"0C:00:50:00:00:00:0B:00:05:00:00:00:04:03:02:01"
)
WRITE_TX = [CONFIG_START_TX, PARAMETERS_WRITE_TX, CONFIG_END_TX]
def _parameters_write_tx(no_delay: int, speed: int) -> str:
"""The 0x0070 write with max gate 10, min gate 0 and both frequencies 80."""
values = [(0x05, 10), (0x0A, 0), (0x06, no_delay), (0x02, 80), (0x0C, 80)]
body = "".join(f"{key:02X}:00:{value:02X}:00:00:00:" for key, value in values)
return f"FD:FC:FB:FA:26:00:70:00:{body}0B:00:{speed:02X}:00:00:00:04:03:02:01"
WRITE_TX = [CONFIG_START_TX, _parameters_write_tx(10, 5), CONFIG_END_TX]
# The same values with response speed 10 (Fast)
SPEED_WRITE_TX = [CONFIG_START_TX, _parameters_write_tx(10, 10), CONFIG_END_TX]
# No delay 20 s; the fixture rejects it
REJECTED_WRITE_TX = [CONFIG_START_TX, _parameters_write_tx(20, 10), CONFIG_END_TX]
# Logged once the version and parameter answers are parsed
PARSED_ANSWERS = [
"Firmware 1.2.3",
@@ -117,13 +143,20 @@ def _tx_frame(line: str) -> str | None:
return ANSI_ESCAPE.sub("", line.split("bytes: ", 1)[1]).strip()
async def _start_scenario(client: APIClient, collector: SensorStateCollector) -> None:
"""Subscribe the collector past the initial states, then start the mock scenario."""
async def _start_scenario(
client: APIClient,
collector: SensorStateCollector,
on_state: Callable[[EntityState], None] | None = None,
) -> list[EntityInfo]:
"""Subscribe past the initial states, then start the mock scenario; returns the entities."""
entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities)
await subscribe_after_initial_states(client, entities, on_state=collector.on_state)
await subscribe_after_initial_states(
client, entities, on_state=on_state or collector.on_state
)
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
return entities
async def _wait_for_presence(
@@ -156,12 +189,15 @@ async def test_uart_mock_ld2410s(
collector = SensorStateCollector(
sensor_names=["distance"], binary_sensor_names=["has_target"]
)
number_states: list[float] = []
number_states: dict[int, list[float]] = {}
select_states: list[str] = []
def on_state(state: EntityState) -> None:
collector.on_state(state)
if isinstance(state, NumberState) and state.key == max_distance_key:
number_states.append(state.state)
if isinstance(state, NumberState):
number_states.setdefault(state.key, []).append(state.state)
if isinstance(state, SelectState):
select_states.append(state.state)
all_presence = collector.add_waiter(
lambda: len(collector.binary_states["has_target"]) >= len(EXPECTED_PRESENCE)
@@ -191,16 +227,28 @@ async def test_uart_mock_ld2410s(
collector.build_key_mapping(entities)
max_distance_key = require_entity(entities, "max_distance", NumberInfo).key
sub = await subscribe_after_initial_states(client, entities, on_state=on_state)
# The values read from the module: gate 16 and 10 s
# The values read from the module: gate 16, 10 s, 80 tenths of a hertz, speed 5
no_delay_key = require_entity(entities, "no_delay", NumberInfo).key
frequency_key = require_entity(
entities, "status_reporting_frequency", NumberInfo
).key
speed_key = require_entity(entities, "response_speed", SelectInfo).key
initial = sub.initial_states
assert initial[max_distance_key].state == pytest.approx(11.2)
assert initial[no_delay_key].state == pytest.approx(10)
assert initial[frequency_key].state == pytest.approx(8.0)
assert initial[speed_key].state == "Normal"
client.number_command(max_distance_key, 7.0)
# 7.3 m is between gates 10 and 11: gate 10 is written and 7 m reported
client.number_command(max_distance_key, 7.3)
await waiter.wait_count(TX_LINE, 9, 5.0, "Parameter write not sent")
assert tx_frames()[6:9] == WRITE_TX, tx_frames()
assert number_states == [pytest.approx(7.0)]
assert number_states[max_distance_key] == [pytest.approx(7.0)]
client.select_command(speed_key, "Fast")
await waiter.wait_count(TX_LINE, 12, 5.0, "Response speed write not sent")
assert tx_frames()[9:12] == SPEED_WRITE_TX, tx_frames()
assert select_states == ["Fast"]
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
@@ -218,7 +266,29 @@ async def test_uart_mock_ld2410s(
# A target 300 cm away, then none: the distance follows the presence
assert collector.sensor_states["distance"][:2] == [300, 0]
# Data frames must not restart the init sequence
assert len(tx_frames()) == 9, tx_frames()
assert len(tx_frames()) == 12, tx_frames()
# A rejected write leaves configuration mode and reads the module's values back
read_back = waiter.expect("Parameters: gates 0 to 16, no delay 10 s")
client.number_command(no_delay_key, 20)
await wait_or_fail(read_back, 5.0, "Values not read back", tx_frames=tx_frames)
async def republished() -> None:
while (
len(number_states.get(no_delay_key, ())) < 2
or len(number_states.get(max_distance_key, ())) < 2
or len(select_states) < 2
):
await asyncio.sleep(0.01)
await wait_or_fail(republished(), 5.0, "Read-back values not published")
assert tx_frames()[12:15] == REJECTED_WRITE_TX, tx_frames()
assert number_states[no_delay_key] == [pytest.approx(20), pytest.approx(10)]
assert number_states[max_distance_key] == [
pytest.approx(7.0),
pytest.approx(11.2),
]
assert select_states == ["Fast", "Normal"]
@pytest.mark.asyncio
@@ -234,10 +304,14 @@ async def test_uart_mock_ld2410s_recovery(
gave_up = loop.create_future()
oversized_dropped = loop.create_future()
footer_mismatches: list[str] = []
ignored_changes = 0
def line_callback(line: str) -> None:
nonlocal ignored_changes
if (frame := _tx_frame(line)) is not None:
tx_frames.append((loop.time(), frame))
if "Parameters not read yet, change ignored" in line:
ignored_changes += 1
if "No acknowledgement for command 00FF, resending" in line and not (
resent.done()
):
@@ -267,12 +341,25 @@ async def test_uart_mock_ld2410s_recovery(
>= len(EXPECTED_RECOVERY_PRESENCE)
)
)
setting_states: list[EntityState] = []
def on_state(state: EntityState) -> None:
collector.on_state(state)
if isinstance(state, (NumberState, SelectState)):
setting_states.append(state)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
await _start_scenario(client, collector)
entities = await _start_scenario(client, collector, on_state)
# The module never answered, so both changes are refused and nothing is published
client.number_command(
require_entity(entities, "max_distance", NumberInfo).key, 7.0
)
client.select_command(
require_entity(entities, "response_speed", SelectInfo).key, "Fast"
)
await _wait_for_presence(collector, all_presence)
await wait_or_fail(
asyncio.gather(resent, gave_up),
@@ -286,6 +373,8 @@ async def test_uart_mock_ld2410s_recovery(
# Recovery shows in the published states alone; the footer log below is
# only a cross-check that needs the fixture's VERBOSE logger level
assert collector.binary_states["has_target"] == EXPECTED_RECOVERY_PRESENCE
assert ignored_changes == 2
assert setting_states == []
assert oversized_dropped.done(), "Oversized length field was not dropped"
assert footer_mismatches == [TRUNCATED_FRAME_HEX, BAD_COMMAND_FRAME_HEX]