Compare commits

..
Author SHA1 Message Date
J. Nick Koston 3340fced89 [web_server] Make web OTA's request handler part of its component
WebServerOTAComponent is now the AsyncWebHandler itself instead of creating a separate handler with new in setup(), so the handler is static storage that memory reports count, with no allocation code and no back pointer.
2026-10-26 18:17:39 -10:00
27 changed files with 272 additions and 602 deletions

No files matched your search

-76
View File
@@ -1,76 +0,0 @@
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
+29 -18
View File
@@ -73,17 +73,16 @@ 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, kept in the apt-packages action.
# In ci-status needs so a broken seed fails dev.
# 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.
if: github.event_name == 'push'
timeout-minutes: 10
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: .github
- name: Install apt packages (cached)
uses: ./.github/actions/apt-packages
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
determine-jobs:
name: Determine which jobs to run
@@ -413,10 +412,14 @@ 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. libsdl2-dev is needed by the headless display tests, which
# capture screenshots.
# path. Packages and version must match seed-apt-cache exactly.
# libsdl2-dev is needed by the headless display tests, which capture
# screenshots.
timeout-minutes: 10
uses: ./.github/actions/apt-packages
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
- name: Set up Python 3.13
id: python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
@@ -715,9 +718,13 @@ 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.
# by dsmr and dlms_meter. Packages and version must match seed-apt-cache
# exactly.
timeout-minutes: 10
uses: ./.github/actions/apt-packages
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
- name: Restore Python
id: restore-python
@@ -1104,16 +1111,20 @@ 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. The action has no --no-install-recommends; same
# package set this job used before #17463.
# 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.
timeout-minutes: 10
uses: ./.github/actions/apt-packages
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
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -1,7 +1,6 @@
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,
@@ -13,7 +12,10 @@ 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 DOMAIN as ZEPHYR_BLE_SERVER_DOMAIN
from esphome.components.zephyr_ble_server import (
CONF_ON_NUMERIC_COMPARISON_REQUEST,
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
+2 -1
View File
@@ -5,7 +5,6 @@ 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 (
@@ -102,8 +101,10 @@ 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,7 +8,6 @@ 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"
@@ -59,7 +58,6 @@ 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,86 +25,16 @@ 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
@@ -293,9 +223,6 @@ 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
+2 -27
View File
@@ -1,4 +1,5 @@
#include "ld2410s.h"
#include "ld2410s_number.h"
#include <cstring>
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
@@ -39,11 +40,6 @@ 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;
@@ -140,27 +136,6 @@ 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) {
@@ -527,7 +502,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_state(this->parameters_[i] * PARAMETER_SCALES[i]);
this->numbers_[i]->publish_raw(this->parameters_[i]);
}
}
#endif
+6 -10
View File
@@ -14,9 +14,6 @@
#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
@@ -42,6 +39,10 @@ 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)
@@ -66,14 +67,9 @@ 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, number::Number *number) {
void set_number(Parameter parameter, LD2410SNumber *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
@@ -113,7 +109,7 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
#endif
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
number::Number *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number
LD2410SNumber *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number
#endif
const uint16_t *sequence_{nullptr}; // PROGMEM; null while nothing is being sent
@@ -0,0 +1,32 @@
#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
@@ -0,0 +1,21 @@
#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
+15 -9
View File
@@ -16,7 +16,7 @@ from esphome.const import (
)
from esphome.types import ConfigType
from . import CONF_LD2410S_ID, LD2410SComponent, Parameter
from . import CONF_LD2410S_ID, LD2410SComponent, Parameter, ld2410s_ns
DEPENDENCIES = ["ld2410s"]
@@ -26,8 +26,9 @@ CONF_MIN_DISTANCE = "min_distance"
CONF_NO_DELAY = "no_delay"
CONF_STATUS_REPORTING_FREQUENCY = "status_reporting_frequency"
# 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
LD2410SNumber = ld2410s_ns.class_("LD2410SNumber", number.Number)
# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz
GATE_WIDTH_M = 0.7
FREQUENCY_SCALE = 0.1
_DISTANCE_RANGE = {"min_value": 0, "max_value": 16 * GATE_WIDTH_M, "step": GATE_WIDTH_M}
@@ -36,7 +37,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(
number.ApplyNumber,
LD2410SNumber,
device_class=device_class,
entity_category=ENTITY_CATEGORY_CONFIG,
unit_of_measurement=unit,
@@ -71,19 +72,22 @@ async def to_code(config: ConfigType) -> None:
await numbers(
CONF_MAX_DISTANCE,
partial(hub.set_number, Parameter.PARAMETER_MAX_DISTANCE),
apply="control_number(entity, value)",
Parameter.PARAMETER_MAX_DISTANCE,
GATE_WIDTH_M,
**_DISTANCE_RANGE,
)
await numbers(
CONF_MIN_DISTANCE,
partial(hub.set_number, Parameter.PARAMETER_MIN_DISTANCE),
apply="control_number(entity, value)",
Parameter.PARAMETER_MIN_DISTANCE,
GATE_WIDTH_M,
**_DISTANCE_RANGE,
)
await numbers(
CONF_NO_DELAY,
partial(hub.set_number, Parameter.PARAMETER_NO_DELAY),
apply="control_number(entity, value)",
Parameter.PARAMETER_NO_DELAY,
1,
min_value=1,
max_value=120,
step=1,
@@ -91,12 +95,14 @@ async def to_code(config: ConfigType) -> None:
await numbers(
CONF_STATUS_REPORTING_FREQUENCY,
partial(hub.set_number, Parameter.PARAMETER_STATUS_FREQUENCY),
apply="control_number(entity, value)",
Parameter.PARAMETER_STATUS_FREQUENCY,
FREQUENCY_SCALE,
**_FREQUENCY_RANGE,
)
await numbers(
CONF_DISTANCE_REPORTING_FREQUENCY,
partial(hub.set_number, Parameter.PARAMETER_DISTANCE_FREQUENCY),
apply="control_number(entity, value)",
Parameter.PARAMETER_DISTANCE_FREQUENCY,
FREQUENCY_SCALE,
**_FREQUENCY_RANGE,
)
+5 -6
View File
@@ -4,16 +4,18 @@ import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG
from esphome.types import ConfigType
from . import CONF_LD2410S_ID, LD2410SComponent
from . import CONF_LD2410S_ID, LD2410SComponent, ld2410s_ns
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(
select.ApplySelect,
LD2410SSelect,
entity_category=ENTITY_CATEGORY_CONFIG,
icon="mdi:speedometer",
),
@@ -25,8 +27,5 @@ 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"],
apply="control_response_speed(entity, index)",
CONF_RESPONSE_SPEED, hub.set_response_speed_select, options=["Normal", "Fast"]
)
@@ -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(tmp);
this->parent_->queue_command(std::string(tmp));
} else {
ESP_LOGD(TAG, "Will not write: %s as it is not in list of allowed values", tmp);
}
+3 -9
View File
@@ -224,23 +224,17 @@ bool Pipsolar::send_next_poll_() {
return false;
}
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) {
void Pipsolar::queue_command(const std::string &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, testposition);
ESP_LOGD(TAG, "Command queued successfully: %s at position %d", command.c_str(), testposition);
return;
}
}
ESP_LOGD(TAG, "Command queue full dropping command: %s", command);
ESP_LOGD(TAG, "Command queue full dropping command: %s", command.c_str());
}
void Pipsolar::handle_poll_response_(ENUMPollingCommand polling_command, const char *message) {
+1 -4
View File
@@ -194,10 +194,7 @@ 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 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 queue_command(const std::string &command);
void setup() override;
void loop() override;
void dump_config() override;
+24 -58
View File
@@ -1,14 +1,10 @@
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
from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns
DEPENDENCIES = ["uart"]
@@ -20,55 +16,21 @@ 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: _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",
),
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"),
}
PipsolarSwitch = pipsolar_ns.class_("PipsolarSwitch", switch.Switch, cg.Component)
PIPSWITCH_SCHEMA = switch.switch_schema(
switch.ApplySwitch, icon=ICON_POWER, block_inverted=True
)
PipsolarSwitch, icon=ICON_POWER, block_inverted=True
).extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend(
{cv.Optional(type): PIPSWITCH_SCHEMA for type in TYPES}
@@ -76,11 +38,15 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend(
async def to_code(config: ConfigType) -> None:
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})'
)
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))
@@ -0,0 +1,17 @@
#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
@@ -0,0 +1,26 @@
#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
@@ -1,7 +1,6 @@
#include "ota_web_server.h"
#ifdef USE_WEBSERVER_OTA
#include "esphome/components/ota/ota_backend_factory.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
@@ -9,73 +8,37 @@
#include "esphome/components/captive_portal/captive_portal.h"
#endif
#if USE_ESP32
using PlatformString = std::string;
#elif USE_ARDUINO
using PlatformString = String;
#endif
namespace esphome::web_server {
ESPHOME_LOG_TAG(TAG, "web_server.ota");
class OTARequestHandler : public AsyncWebHandler {
public:
OTARequestHandler(WebServerOTAComponent *parent) : parent_(parent) {}
void handleRequest(AsyncWebServerRequest *request) override;
void handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index, uint8_t *data,
size_t len, bool final) override;
bool canHandle(AsyncWebServerRequest *request) const override {
if (request->method() != HTTP_POST)
return false;
// Check if this is an OTA update request
bool WebServerOTAComponent::canHandle(AsyncWebServerRequest *request) const {
if (request->method() != HTTP_POST)
return false;
// Check if this is an OTA update request
#ifdef USE_ESP32
char url_buf[AsyncWebServerRequest::URL_BUF_SIZE];
bool is_ota_request = request->url_to(url_buf) == "/update";
char url_buf[AsyncWebServerRequest::URL_BUF_SIZE];
bool is_ota_request = request->url_to(url_buf) == "/update";
#else
bool is_ota_request = request->url() == ESPHOME_F("/update");
bool is_ota_request = request->url() == ESPHOME_F("/update");
#endif
#if defined(USE_WEBSERVER_OTA_DISABLED) && defined(USE_CAPTIVE_PORTAL)
// IMPORTANT: USE_WEBSERVER_OTA_DISABLED only disables OTA for the web_server component
// Captive portal can still perform OTA updates - check if request is from active captive portal
// Note: global_captive_portal is the standard way components communicate in ESPHome
return is_ota_request && captive_portal::global_captive_portal != nullptr &&
captive_portal::global_captive_portal->is_active();
// IMPORTANT: USE_WEBSERVER_OTA_DISABLED only disables OTA for the web_server component
// Captive portal can still perform OTA updates - check if request is from active captive portal
// Note: global_captive_portal is the standard way components communicate in ESPHome
return is_ota_request && captive_portal::global_captive_portal != nullptr &&
captive_portal::global_captive_portal->is_active();
#elif defined(USE_WEBSERVER_OTA_DISABLED)
// OTA disabled for web_server and no captive portal compiled in
return false;
// OTA disabled for web_server and no captive portal compiled in
return false;
#else
// OTA enabled for web_server
return is_ota_request;
// OTA enabled for web_server
return is_ota_request;
#endif
}
}
// NOLINTNEXTLINE(readability-identifier-naming)
bool isRequestHandlerTrivial() const override { return false; }
protected:
void report_ota_progress_(AsyncWebServerRequest *request);
void schedule_ota_reboot_();
void ota_init_(const char *filename);
/// Abort and drop the backend, releasing the lock.
void abort_session_();
/// Reply "busy" without touching the running upload.
void refuse_(AsyncWebServerRequest *request);
uint32_t last_ota_progress_{0};
uint32_t ota_read_length_{0};
WebServerOTAComponent *parent_;
bool ota_success_{false};
private:
ota::OTABackendPtr ota_backend_{nullptr};
/// Request that owns ota_backend_.
const AsyncWebServerRequest *ota_request_{nullptr};
const AsyncWebServerRequest *refused_request_{nullptr};
};
void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) {
void WebServerOTAComponent::report_ota_progress_(AsyncWebServerRequest *request) {
const uint32_t now = millis();
if (now - this->last_ota_progress_ > 1000) {
float percentage = 0.0f;
@@ -91,28 +54,28 @@ void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) {
}
#ifdef USE_OTA_STATE_LISTENER
// Report progress - use notify_state_deferred_ since we're in web server task
this->parent_->notify_state_deferred_(ota::OTA_IN_PROGRESS, percentage, 0);
this->notify_state_deferred_(ota::OTA_IN_PROGRESS, percentage, 0);
#endif
this->last_ota_progress_ = now;
}
}
void OTARequestHandler::schedule_ota_reboot_() {
void WebServerOTAComponent::schedule_ota_reboot_() {
ESP_LOGI(TAG, "OTA update successful!");
this->parent_->set_timeout(100, []() {
this->set_timeout(100, []() {
ESP_LOGI(TAG, "Performing OTA reboot now");
App.safe_reboot();
});
}
void OTARequestHandler::ota_init_(const char *filename) {
void WebServerOTAComponent::ota_init_(const char *filename) {
ESP_LOGI(TAG, "OTA Update Start: %s", filename);
this->ota_read_length_ = 0;
this->ota_success_ = false;
}
void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index,
uint8_t *data, size_t len, bool final) {
void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index,
uint8_t *data, size_t len, bool final) {
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK;
// First byte of a new upload: index==0 with actual data. (web_server_idf
@@ -130,8 +93,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf
if (!this->ota_backend_) {
ESP_LOGE(TAG, "Failed to create OTA backend");
#ifdef USE_OTA_STATE_LISTENER
this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f,
static_cast<uint8_t>(ota::OTA_RESPONSE_ERROR_UNKNOWN));
this->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(ota::OTA_RESPONSE_ERROR_UNKNOWN));
#endif
return;
}
@@ -153,14 +115,14 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf
#ifdef USE_OTA_STATE_LISTENER
// Notify OTA started - use notify_state_deferred_ since we're in web server task
this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0);
this->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0);
#endif
if (error_code != ota::OTA_RESPONSE_OK) {
ESP_LOGE(TAG, "OTA begin failed: %d", error_code);
this->ota_backend_.reset();
#ifdef USE_OTA_STATE_LISTENER
this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
this->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
#endif
return;
}
@@ -188,7 +150,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf
this->ota_backend_.reset();
this->ota_request_ = nullptr;
#ifdef USE_OTA_STATE_LISTENER
this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
this->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
#endif
return;
}
@@ -209,7 +171,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf
this->ota_success_ = true;
#ifdef USE_OTA_STATE_LISTENER
// Report completion before reboot - use notify_state_deferred_ since we're in web server task
this->parent_->notify_state_deferred_(ota::OTA_COMPLETED, 100.0f, 0);
this->notify_state_deferred_(ota::OTA_COMPLETED, 100.0f, 0);
#endif
this->schedule_ota_reboot_();
} else {
@@ -217,7 +179,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf
// Reset the platform updater before the lock is released.
this->ota_backend_->abort();
#ifdef USE_OTA_STATE_LISTENER
this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
this->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
#endif
}
this->ota_backend_.reset();
@@ -225,16 +187,16 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf
}
}
void OTARequestHandler::abort_session_() {
void WebServerOTAComponent::abort_session_() {
this->ota_backend_->abort();
#ifdef USE_OTA_STATE_LISTENER
this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0);
this->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0);
#endif
this->ota_backend_.reset();
this->ota_request_ = nullptr;
}
void OTARequestHandler::refuse_(AsyncWebServerRequest *request) {
void WebServerOTAComponent::refuse_(AsyncWebServerRequest *request) {
this->refused_request_ = request;
request->onDisconnect([this, request]() {
if (this->refused_request_ == request)
@@ -242,7 +204,7 @@ void OTARequestHandler::refuse_(AsyncWebServerRequest *request) {
});
}
void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) {
void WebServerOTAComponent::handleRequest(AsyncWebServerRequest *request) {
AsyncWebServerResponse *response;
// Use the ota_success_ flag to determine the actual result
const bool refused = request == this->refused_request_;
@@ -277,8 +239,7 @@ void WebServerOTAComponent::setup() {
return;
}
// The handler lives for the life of the process; WebServerBase never destroys its server
base->add_handler(new OTARequestHandler(this)); // NOLINT
base->add_handler(this);
}
void WebServerOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, "Web Server OTA"); }
@@ -3,20 +3,44 @@
#include "esphome/core/defines.h"
#ifdef USE_WEBSERVER_OTA
#include "esphome/components/ota/ota_backend.h"
#include "esphome/components/ota/ota_backend_factory.h"
#include "esphome/components/web_server_base/web_server_base.h"
#include "esphome/core/component.h"
namespace esphome::web_server {
class WebServerOTAComponent final : public ota::OTAComponent {
class WebServerOTAComponent final : public AsyncWebHandler, public ota::OTAComponent {
public:
void setup() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
void handleRequest(AsyncWebServerRequest *request) override;
void handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index, uint8_t *data,
size_t len, bool final) override;
bool canHandle(AsyncWebServerRequest *request) const override;
// NOLINTNEXTLINE(readability-identifier-naming)
bool isRequestHandlerTrivial() const override { return false; }
protected:
friend class OTARequestHandler;
void report_ota_progress_(AsyncWebServerRequest *request);
void schedule_ota_reboot_();
void ota_init_(const char *filename);
/// Abort and drop the backend, releasing the lock.
void abort_session_();
/// Reply "busy" without touching the running upload.
void refuse_(AsyncWebServerRequest *request);
uint32_t last_ota_progress_{0};
uint32_t ota_read_length_{0};
bool ota_success_{false};
private:
ota::OTABackendPtr ota_backend_{nullptr};
/// Request that owns ota_backend_.
const AsyncWebServerRequest *ota_request_{nullptr};
const AsyncWebServerRequest *refused_request_{nullptr};
};
} // namespace esphome::web_server
@@ -2,7 +2,6 @@ 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,
@@ -55,6 +54,8 @@ 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(
@@ -1,18 +0,0 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
sendspin:
switch:
- platform: sendspin
type: enabled
id: sendspin_enabled
name: Sendspin Enabled
@@ -1,19 +0,0 @@
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
@@ -1,45 +0,0 @@
"""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,22 +74,6 @@ 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
@@ -153,14 +137,6 @@ 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,19 +92,6 @@ 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"
+20 -109
View File
@@ -5,15 +5,9 @@ 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
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
1b. Setting the max distance number to 7 m writes all six parameters (gate 10
plus the values read at init) inside a configuration-mode session, and the
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
number reports 7 m
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
@@ -22,8 +16,6 @@ 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
@@ -44,19 +36,9 @@ test_uart_mock_ld2410s_link_lost:
from __future__ import annotations
import asyncio
from collections.abc import Callable
import re
from aioesphomeapi import (
APIClient,
ButtonInfo,
EntityInfo,
EntityState,
NumberInfo,
NumberState,
SelectInfo,
SelectState,
)
from aioesphomeapi import APIClient, ButtonInfo, EntityState, NumberInfo, NumberState
import pytest
from .log_utils import LineWaiter
@@ -84,20 +66,12 @@ INIT_TX = [
OUTPUT_MODE_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]
# 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]
# Logged once the version and parameter answers are parsed
PARSED_ANSWERS = [
"Firmware 1.2.3",
@@ -143,20 +117,13 @@ 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,
on_state: Callable[[EntityState], None] | None = None,
) -> list[EntityInfo]:
"""Subscribe past the initial states, then start the mock scenario; returns the entities."""
async def _start_scenario(client: APIClient, collector: SensorStateCollector) -> None:
"""Subscribe the collector past the initial states, then start the mock scenario."""
entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities)
await subscribe_after_initial_states(
client, entities, on_state=on_state or collector.on_state
)
await subscribe_after_initial_states(client, entities, on_state=collector.on_state)
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
return entities
async def _wait_for_presence(
@@ -189,15 +156,12 @@ async def test_uart_mock_ld2410s(
collector = SensorStateCollector(
sensor_names=["distance"], binary_sensor_names=["has_target"]
)
number_states: dict[int, list[float]] = {}
select_states: list[str] = []
number_states: list[float] = []
def on_state(state: EntityState) -> None:
collector.on_state(state)
if isinstance(state, NumberState):
number_states.setdefault(state.key, []).append(state.state)
if isinstance(state, SelectState):
select_states.append(state.state)
if isinstance(state, NumberState) and state.key == max_distance_key:
number_states.append(state.state)
all_presence = collector.add_waiter(
lambda: len(collector.binary_states["has_target"]) >= len(EXPECTED_PRESENCE)
@@ -227,28 +191,16 @@ 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, 10 s, 80 tenths of a hertz, speed 5
# The values read from the module: gate 16 and 10 s
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"
# 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)
client.number_command(max_distance_key, 7.0)
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[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"]
assert number_states == [pytest.approx(7.0)]
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
@@ -266,29 +218,7 @@ 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()) == 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"]
assert len(tx_frames()) == 9, tx_frames()
@pytest.mark.asyncio
@@ -304,14 +234,10 @@ 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()
):
@@ -341,25 +267,12 @@ 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,
):
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 _start_scenario(client, collector)
await _wait_for_presence(collector, all_presence)
await wait_or_fail(
asyncio.gather(resent, gave_up),
@@ -373,8 +286,6 @@ 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]