Compare commits

..
30 changed files with 916 additions and 107 deletions

No files matched your search

+11 -18
View File
@@ -4,12 +4,11 @@ The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack
bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
on this component and contain no SDK calls of their own.
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7252N/BK7253 (BLE 5.2),
and any future BLE-5.x SoC. BK7238 (BLE 5.2) is blocked for now: with BLE
compiled in, the Beken SDK erases the bootloader flash sector at boot because
LibreTiny's partition table has no BLE bonding entry (esphome#18646,
libretiny-eu/libretiny#408). Known non-5.x families and BK7238 are rejected in
to_code. Unknown families are capability-checked at compile time via
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 (BLE 5.2),
and any future BLE-5.x SoC. LibreTiny 1.14 or newer is required: earlier releases
have no BLE bonding partition, so the Beken SDK erases the bootloader flash sector
at boot on BK7238 (esphome#18646, libretiny-eu/libretiny#408). Known non-5.x
families are rejected in to_code. Unknown families are capability-checked at compile time via
`__has_include("app_ble.h")`, a header only on the BLE 5.x include path
(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build
fails with a clear #error.
@@ -29,7 +28,6 @@ from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231Q,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_BK7251,
)
import esphome.config_validation as cv
@@ -54,7 +52,9 @@ BEKEN_BDK_VERSION_OPTION = "custom_versions.beken-bdk"
# bootloader with BLE compiled in; the libretiny platform builds with this one
MIN_BDK_VERSION = (3, 0, 78)
# Older LibreTiny releases arm the Beken MCU sleep with wifi power save, which halts
# the device once the station stops while BLE runs (libretiny-eu/libretiny#414)
# the device once the station stops while BLE runs (libretiny-eu/libretiny#414), and
# have no BLE bond partition, so the BK7238 bond store erases flash sector 0 and
# bricks the chip (libretiny-eu/libretiny#408)
MIN_LIBRETINY_VERSION = (1, 14, 0)
_LOGGER = logging.getLogger(__name__)
@@ -86,14 +86,6 @@ def _unsupported_family_message(family: str) -> str | None:
)
if family == FAMILY_BK7231Q:
return "bk72xx_ble does not support BK7231Q: this SoC has no BLE"
if family == FAMILY_BK7238:
return (
"bk72xx_ble is disabled on BK7238: with BLE compiled in, the Beken SDK "
"erases the bootloader flash sector at boot and the device can no longer "
"start (see https://github.com/esphome/esphome/issues/18646); support "
"returns once the LibreTiny partition table fix "
"(libretiny-eu/libretiny#408) is released"
)
return None
@@ -117,14 +109,15 @@ def check_bdk_version(value: str | None) -> None:
def check_libretiny_version(version: cv.Version) -> None:
"""Reject a LibreTiny release older than the one whose WiFi sleep is safe with BLE."""
"""Reject a LibreTiny release older than the one whose WiFi sleep and bond storage are safe with BLE."""
if (version.major, version.minor, version.patch) >= MIN_LIBRETINY_VERSION:
return
minimum = ".".join(str(part) for part in MIN_LIBRETINY_VERSION)
raise cv.Invalid(
f"bk72xx_ble needs LibreTiny {minimum} or newer, not {version}: older releases "
"arm the Beken MCU sleep with wifi power save and the device halts once the "
"station stops while BLE runs"
"station stops while BLE runs, and on BK7238 the bond store erases flash "
"sector 0 and bricks the chip"
)
+1 -6
View File
@@ -102,8 +102,6 @@ static constexpr uint8_t ENGINEERING_FRAME_MIN = OUT_PIN_SENSOR + 3 + HEADER_FOO
// Data Header & Footer
static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1};
static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5};
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
void LD2410Component::dump_config() {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
@@ -425,10 +423,7 @@ void LD2410Component::handle_ack_data_() {
break; // too short to hold a MAC address
}
this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0;
if (this->bluetooth_on_) {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
+1 -6
View File
@@ -120,8 +120,6 @@ static constexpr uint8_t ENGINEERING_FRAME_MIN = LIGHT_SENSOR + 3 + HEADER_FOOTE
// Data Header & Footer
static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1};
static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5};
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
static constexpr uint8_t GATE_SENS_ACK_SIZE = ACK_PAYLOAD + TOTAL_GATES + HEADER_FOOTER_SIZE;
void LD2412Component::dump_config() {
@@ -477,10 +475,7 @@ void LD2412Component::handle_ack_data_() {
break; // too short to hold a MAC address
}
this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0;
if (this->bluetooth_on_) {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
+42 -20
View File
@@ -79,8 +79,6 @@ static constexpr uint8_t DATA_FRAME_FOOTER[2] = {0x55, 0xCC};
// 3 zones * 8 + footer (4).
static constexpr uint8_t ZONE_ACK_FRAME_SIZE = 40;
static constexpr uint8_t PERIODIC_FRAME_SIZE = 30; // header, three targets of 8 bytes, footer
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
static inline uint32_t convert_seconds_to_ms(uint16_t value) { return (uint32_t) value * 1000; };
@@ -192,6 +190,11 @@ void LD2450Component::dump_config() {
for (text_sensor::TextSensor *s : this->direction_text_sensors_) {
LOG_TEXT_SENSOR(" ", "Direction", s);
}
#ifdef USE_LD2450_TARGET_XY
for (text_sensor::TextSensor *s : this->target_xy_text_sensors_) {
LOG_TEXT_SENSOR(" ", "TargetXY", s);
}
#endif
#endif
#ifdef USE_NUMBER
ESP_LOGCONFIG(TAG, "Numbers:");
@@ -295,9 +298,6 @@ bool LD2450Component::get_timeout_status_(uint32_t check_millis) {
if (check_millis == 0) {
return true;
}
if (this->timeout_ == 0) {
this->timeout_ = ld2450::convert_seconds_to_ms(DEFAULT_PRESENCE_TIMEOUT);
}
return App.get_loop_component_start_time() - check_millis >= this->timeout_;
}
@@ -468,9 +468,16 @@ void LD2450Component::handle_periodic_data_() {
// Store target info for zone target count. Zero out untracked targets (td==0)
// so stale coordinates don't produce ghost counts in count_targets_in_zone_().
#ifdef USE_LD2450_TARGET_XY
const Target previous = this->target_info_[index];
#endif
this->target_info_[index].x = (td > 0) ? tx : 0;
this->target_info_[index].y = (td > 0) ? ty : 0;
this->target_info_[index].is_moving = (td > 0) && is_moving;
#ifdef USE_LD2450_TARGET_XY
this->update_target_xy_(index,
previous.x != this->target_info_[index].x || previous.y != this->target_info_[index].y);
#endif
} // End loop thru targets
@@ -621,10 +628,7 @@ void LD2450Component::handle_ack_data_() {
break; // too short to hold a MAC address
}
this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0;
if (this->bluetooth_on_) {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
@@ -862,6 +866,27 @@ void LD2450Component::set_direction_text_sensor(uint8_t target, text_sensor::Tex
this->direction_text_sensors_[target] = s;
}
#endif
#ifdef USE_LD2450_TARGET_XY
void LD2450Component::set_target_xy_text_sensor(uint8_t target, text_sensor::TextSensor *s) {
this->target_xy_text_sensors_[target] = s;
}
// Publish the position of a target in one radar frame; the sensor's filters throttle it
void LD2450Component::update_target_xy_(uint8_t target, bool changed) {
text_sensor::TextSensor *s = this->target_xy_text_sensors_[target];
if (s == nullptr || (!changed && s->has_state()))
return;
const Target &pos = this->target_info_[target];
if (pos.x == 0 && pos.y == 0) {
s->publish_state(DirectionStrings::get_progmem_str(DIRECTION_NA, DirectionStrings::LAST_INDEX));
return;
}
// Longest is "-32767,-32767"
char text[16];
const size_t len = buf_append_printf(text, sizeof(text), 0, "%d,%d", pos.x, pos.y);
s->publish_state(text, len);
}
#endif
// Send Zone coordinates data to LD2450
#ifdef USE_NUMBER
@@ -900,17 +925,14 @@ void LD2450Component::set_zone_numbers(uint8_t zone, number::Number *x1, number:
// Set Presence Timeout load and save from flash
#ifdef USE_NUMBER
void LD2450Component::set_presence_timeout() {
if (this->presence_timeout_number_ != nullptr) {
if (this->presence_timeout_number_->state == 0) {
float timeout = this->restore_from_flash_();
this->presence_timeout_number_->publish_state(timeout);
this->timeout_ = ld2450::convert_seconds_to_ms(timeout);
}
if (this->presence_timeout_number_->has_state()) {
this->save_to_flash_(this->presence_timeout_number_->state);
this->timeout_ = ld2450::convert_seconds_to_ms(this->presence_timeout_number_->state);
}
}
if (this->presence_timeout_number_ == nullptr)
return;
// Restore only while the number has no state yet: 0 is a valid presence_timeout (no hold)
if (!this->presence_timeout_number_->has_state())
this->presence_timeout_number_->publish_state(this->restore_from_flash_());
const float timeout = this->presence_timeout_number_->state;
this->save_to_flash_(timeout);
this->timeout_ = ld2450::convert_seconds_to_ms(timeout);
}
// Save Presence Timeout to flash
+10
View File
@@ -146,6 +146,9 @@ class LD2450Component : public Component, public uart::UARTDevice {
#ifdef USE_TEXT_SENSOR
void set_direction_text_sensor(uint8_t target, text_sensor::TextSensor *s);
#endif
#ifdef USE_LD2450_TARGET_XY
void set_target_xy_text_sensor(uint8_t target, text_sensor::TextSensor *s);
#endif
#ifdef USE_NUMBER
void set_zone_coordinate(number::Number *n);
void set_zone_numbers(uint8_t zone, number::Number *x1, number::Number *y1, number::Number *x2, number::Number *y2);
@@ -189,6 +192,9 @@ class LD2450Component : public Component, public uart::UARTDevice {
void save_to_flash_(float value);
float restore_from_flash_();
bool get_timeout_status_(uint32_t check_millis);
#ifdef USE_LD2450_TARGET_XY
void update_target_xy_(uint8_t target, bool changed);
#endif
void count_targets_in_zone_(const Zone &zone, uint8_t &still, uint8_t &moving);
uint32_t presence_millis_ = 0;
@@ -226,6 +232,10 @@ class LD2450Component : public Component, public uart::UARTDevice {
#ifdef USE_TEXT_SENSOR
std::array<text_sensor::TextSensor *, MAX_TARGETS> direction_text_sensors_{};
#endif
#ifdef USE_LD2450_TARGET_XY
// Publishes a target's position as "x,y" text, so both values always come from the same radar frame
std::array<text_sensor::TextSensor *, MAX_TARGETS> target_xy_text_sensors_{};
#endif
#ifdef LD2450_POLYGON_ZONE_COUNT
StaticVector<PolygonZone *, LD2450_POLYGON_ZONE_COUNT> polygon_zones_;
@@ -40,6 +40,8 @@ void PolygonZone::control(const std::string &value) {
ESP_LOGW(TAG, "'%s': could not save the polygon; keeping the previous one", LOG_STR_ARG(this->get_log_name()));
} else {
this->polygon_ = polygon;
// A user-made edit is written to flash now, as wall-powered sensors lose power without warning
global_preferences->sync();
// A target seen inside the previous polygon must not keep the new one on
this->last_inside_ms_ = 0;
if (this->polygon_.empty())
+16 -3
View File
@@ -18,6 +18,10 @@ from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401
DEPENDENCIES = ["ld2450"]
CONF_XY = "xy"
ICON_CROSSHAIRS_GPS = "mdi:crosshairs-gps"
MAX_TARGETS = 3
CONFIG_SCHEMA = cv.Schema(
@@ -43,6 +47,11 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend(
entity_category=ENTITY_CATEGORY_NONE,
icon=ICON_SIGN_DIRECTION,
),
cv.Optional(CONF_XY): text_sensor.text_sensor_schema(
entity_category=ENTITY_CATEGORY_NONE,
icon=ICON_CROSSHAIRS_GPS,
filters=text_sensor.timeout_throttle_filters("NA"),
),
}
)
for n in range(MAX_TARGETS)
@@ -56,8 +65,12 @@ async def to_code(config: ConfigType) -> None:
await text_sensors(CONF_VERSION, hub.set_version_text_sensor)
await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor)
for n in range(MAX_TARGETS):
if (direction_conf := config.get(f"target_{n + 1}")) and (
direction_config := direction_conf.get(CONF_DIRECTION)
):
if not (target_conf := config.get(f"target_{n + 1}")):
continue
if direction_config := target_conf.get(CONF_DIRECTION):
sens = await text_sensor.new_text_sensor(direction_config)
cg.add(hub.set_direction_text_sensor(n, sens))
if xy_config := target_conf.get(CONF_XY):
cg.add_define("USE_LD2450_TARGET_XY")
sens = await text_sensor.new_text_sensor(xy_config)
cg.add(hub.set_target_xy_text_sensor(n, sens))
+24 -1
View File
@@ -41,7 +41,15 @@ template<typename T, size_t N> optional<size_t> find_index(const T (&arr)[N], T
// Formats the MAC address into the buffer, or "unknown" when it is not valid
inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buffer) {
if (mac_address_is_valid(mac_address)) {
// mac_address_is_valid() rejects the multicast bit, which radar Bluetooth MACs may have set; only all-00 and all-FF
// mean no address
uint8_t any_set = 0x00;
uint8_t all_set = 0xFF;
for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) {
any_set |= mac_address[i];
all_set &= mac_address[i];
}
if (any_set != 0x00 && all_set != 0xFF) {
format_mac_addr_upper(mac_address, buffer.data());
} else {
ESPHOME_strncpy_P(buffer.data(), ESPHOME_PSTR("unknown"), buffer.size());
@@ -49,6 +57,21 @@ inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, MA
return buffer.data();
}
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[MAC_ADDRESS_SIZE] PROGMEM = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
// Stores the radar's reported MAC, or clears it when the radar reports Bluetooth off; returns whether Bluetooth is on
inline bool store_mac(const uint8_t *reported, uint8_t *mac_address) {
uint8_t no_mac[MAC_ADDRESS_SIZE];
progmem_memcpy(no_mac, NO_MAC, MAC_ADDRESS_SIZE);
if (std::memcmp(reported, no_mac, MAC_ADDRESS_SIZE) == 0) {
std::memset(mac_address, 0, MAC_ADDRESS_SIZE);
return false;
}
std::memcpy(mac_address, reported, MAC_ADDRESS_SIZE);
return true;
}
// Helper function to format firmware version with stack allocation
// Buffer must be exactly 20 bytes (format: "x.xxXXXXXX" fits in 11 + null terminator, 20 for safety)
inline void format_version_str(const uint8_t *version, std::span<char, 20> buffer) {
@@ -1,4 +1,5 @@
#include "prometheus_handler.h"
#include <cinttypes>
#if defined(USE_NETWORK) && !defined(USE_HOST)
#include "esphome/core/application.h"
@@ -100,6 +101,24 @@ void PrometheusHandler::handleRequest(AsyncWebServerRequest *req) {
this->valve_row_(stream, obj, area, node, friendly_name);
#endif
#ifdef USE_DATETIME_DATE
this->date_type_(stream);
for (auto *obj : App.get_dates())
this->date_row_(stream, obj, area, node, friendly_name);
#endif
#ifdef USE_DATETIME_TIME
this->time_type_(stream);
for (auto *obj : App.get_times())
this->time_row_(stream, obj, area, node, friendly_name);
#endif
#ifdef USE_DATETIME_DATETIME
this->datetime_type_(stream);
for (auto *obj : App.get_datetimes())
this->datetime_row_(stream, obj, area, node, friendly_name);
#endif
#ifdef USE_CLIMATE
this->climate_type_(stream);
for (auto *obj : App.get_climates())
@@ -1096,6 +1115,78 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima
}
#endif
#if defined(USE_DATETIME_DATE) || defined(USE_DATETIME_DATETIME) || defined(USE_DATETIME_TIME)
void PrometheusHandler::date_time_rows_(AsyncResponseStream *stream, ProgmemStr failed_metric, ProgmemStr value_metric,
EntityBase *obj, int64_t value_ms, std::string &area, std::string &node,
std::string &friendly_name) {
if (obj->is_internal() && !this->include_internal_)
return;
this->print_metric_labels_(stream, failed_metric, obj, area, node, friendly_name);
if (!obj->has_state()) {
stream->print(ESPHOME_F("\"} 1\n"));
return;
}
stream->print(ESPHOME_F("\"} 0\n"));
this->print_metric_labels_(stream, value_metric, obj, area, node, friendly_name);
// Date and datetime are ms since the epoch (what Grafana expects), time is ms since midnight
char buf[32];
snprintf(buf, sizeof(buf), "\"} %" PRId64 "\n", value_ms);
stream->print(buf);
}
#endif
#ifdef USE_DATETIME_DATE
void PrometheusHandler::date_type_(AsyncResponseStream *stream) {
stream->print(ESPHOME_F("#TYPE esphome_date_value gauge\n"));
stream->print(ESPHOME_F("#TYPE esphome_date_failed gauge\n"));
}
void PrometheusHandler::date_row_(AsyncResponseStream *stream, datetime::DateEntity *obj, std::string &area,
std::string &node, std::string &friendly_name) {
int64_t value_ms = 0;
if (obj->has_state()) {
// DateEntity::state_as_esptime() leaves the time fields unset, so build midnight here
ESPTime t{};
t.year = obj->year;
t.month = obj->month;
t.day_of_month = obj->day;
t.recalc_timestamp_local();
value_ms = static_cast<int64_t>(t.timestamp) * 1000;
}
this->date_time_rows_(stream, ESPHOME_F("esphome_date_failed"), ESPHOME_F("esphome_date_value"), obj, value_ms, area,
node, friendly_name);
}
#endif
#ifdef USE_DATETIME_TIME
void PrometheusHandler::time_type_(AsyncResponseStream *stream) {
stream->print(ESPHOME_F("#TYPE esphome_time_value gauge\n"));
stream->print(ESPHOME_F("#TYPE esphome_time_failed gauge\n"));
}
void PrometheusHandler::time_row_(AsyncResponseStream *stream, datetime::TimeEntity *obj, std::string &area,
std::string &node, std::string &friendly_name) {
int64_t value_ms = 0;
if (obj->has_state())
value_ms = (static_cast<int64_t>(obj->hour) * 3600 + obj->minute * 60 + obj->second) * 1000;
this->date_time_rows_(stream, ESPHOME_F("esphome_time_failed"), ESPHOME_F("esphome_time_value"), obj, value_ms, area,
node, friendly_name);
}
#endif
#ifdef USE_DATETIME_DATETIME
void PrometheusHandler::datetime_type_(AsyncResponseStream *stream) {
stream->print(ESPHOME_F("#TYPE esphome_datetime_value gauge\n"));
stream->print(ESPHOME_F("#TYPE esphome_datetime_failed gauge\n"));
}
void PrometheusHandler::datetime_row_(AsyncResponseStream *stream, datetime::DateTimeEntity *obj, std::string &area,
std::string &node, std::string &friendly_name) {
int64_t value_ms = 0;
if (obj->has_state())
value_ms = static_cast<int64_t>(obj->state_as_esptime().timestamp) * 1000;
this->date_time_rows_(stream, ESPHOME_F("esphome_datetime_failed"), ESPHOME_F("esphome_datetime_value"), obj,
value_ms, area, node, friendly_name);
}
#endif
} // namespace esphome::prometheus
#endif
@@ -193,6 +193,36 @@ class PrometheusHandler final : public AsyncWebHandler, public Component {
std::string &friendly_name);
#endif
#if defined(USE_DATETIME_DATE) || defined(USE_DATETIME_DATETIME) || defined(USE_DATETIME_TIME)
/// Print the failed row and, when the entity has a state, the value row in milliseconds
void date_time_rows_(AsyncResponseStream *stream, ProgmemStr failed_metric, ProgmemStr value_metric, EntityBase *obj,
int64_t value_ms, std::string &area, std::string &node, std::string &friendly_name);
#endif
#ifdef USE_DATETIME_DATE
/// Return the type for prometheus
void date_type_(AsyncResponseStream *stream);
/// Return the date state as prometheus data point
void date_row_(AsyncResponseStream *stream, datetime::DateEntity *obj, std::string &area, std::string &node,
std::string &friendly_name);
#endif
#ifdef USE_DATETIME_TIME
/// Return the type for prometheus
void time_type_(AsyncResponseStream *stream);
/// Return the time state as prometheus data point
void time_row_(AsyncResponseStream *stream, datetime::TimeEntity *obj, std::string &area, std::string &node,
std::string &friendly_name);
#endif
#ifdef USE_DATETIME_DATETIME
/// Return the type for prometheus
void datetime_type_(AsyncResponseStream *stream);
/// Return the datetime state as prometheus data point
void datetime_row_(AsyncResponseStream *stream, datetime::DateTimeEntity *obj, std::string &area, std::string &node,
std::string &friendly_name);
#endif
#ifdef USE_CLIMATE
/// Return the type for prometheus
void climate_type_(AsyncResponseStream *stream);
@@ -8,8 +8,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <new>
#ifdef USE_ESP_IDF
#include "esp_task_wdt.h"
#endif
@@ -96,21 +94,10 @@ static int draw_callback(JPEGDRAW *jpeg) {
return 1;
}
JpegDecoder::JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {
RAMAllocator<JPEGDEC> allocator;
JPEGDEC *jpeg = allocator.allocate(1);
if (jpeg == nullptr) {
JpegDecoder::JpegDecoder(RuntimeImage *image)
: ImageDecoder(image, JPEG), jpeg_(RAMAllocator<JPEGDEC>().make_unique()) {
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "Failed to allocate memory for the JPEG decoder");
return;
}
this->jpeg_ = new (jpeg) JPEGDEC();
}
JpegDecoder::~JpegDecoder() {
if (this->jpeg_ != nullptr) {
this->jpeg_->~JPEGDEC();
RAMAllocator<JPEGDEC> allocator;
allocator.deallocate(this->jpeg_, 1);
}
}
@@ -19,7 +19,6 @@ class JpegDecoder : public ImageDecoder {
* @param image The RuntimeImage to decode the stream into.
*/
JpegDecoder(RuntimeImage *image);
~JpegDecoder() override;
int prepare(size_t expected_size) override;
int HOT decode(uint8_t *buffer, size_t size) override;
@@ -47,8 +46,8 @@ class JpegDecoder : public ImageDecoder {
static int32_t source_seek(JPEGFILE *file, int32_t position);
static void source_close(void *handle) {}
/// About 18 KB of decoder state, allocated like pngle in PngDecoder: PSRAM first, nullptr if out of memory
JPEGDEC *jpeg_{nullptr};
/// About 18 KB of decoder state: PSRAM first, nullptr if out of memory
RAMUniquePtr<JPEGDEC> jpeg_;
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
bool source_failed_{false};
@@ -74,9 +74,16 @@ class TinyUSB final : public Component {
.bLength = sizeof(tusb_desc_device_t),
.bDescriptorType = TUSB_DESC_DEVICE,
.bcdUSB = 0x0200,
#if CFG_TUD_CDC || CFG_TUD_NCM || CFG_TUD_ECM_RNDIS
// Interface association (IAD) only for classes that group interfaces, as esp_tinyusb's default descriptor does
.bDeviceClass = TUSB_CLASS_MISC,
.bDeviceSubClass = MISC_SUBCLASS_COMMON,
.bDeviceProtocol = MISC_PROTOCOL_IAD,
#else
.bDeviceClass = TUSB_CLASS_UNSPECIFIED,
.bDeviceSubClass = 0,
.bDeviceProtocol = 0,
#endif
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = 0x303A,
.idProduct = 0x4001,
+1
View File
@@ -185,6 +185,7 @@
#define MDNS_DYNAMIC_TXT_COUNT 2
#define LD2420_LISTENER_COUNT 1
#define LD2450_POLYGON_ZONE_COUNT 2
#define USE_LD2450_TARGET_XY
#define SEN6X_STATUS_SENSOR_COUNT 1
#define UART_BINARY_SENSOR_COUNT 2
#define MICRONOVA_LISTENER_COUNT 1
+10 -6
View File
@@ -137,6 +137,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
delay = 1;
}
uint32_t offset = 0;
// Take lock early to protect scheduler_item_pool_head_ access
{
LockGuard guard{this->lock_};
@@ -181,13 +182,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
if (type == SchedulerItem::INTERVAL) {
item->interval = delay;
// first execution happens immediately after a random smallish offset
uint32_t offset = this->calculate_interval_offset_(delay);
offset = this->calculate_interval_offset_(delay);
item->set_next_execution(now_64 + offset);
#ifdef ESPHOME_LOG_HAS_VERBOSE
SchedulerNameLog name_log;
ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms",
name_log.format(name_type, static_name, hash_or_id), delay, offset);
#endif
} else {
item->interval = 0;
item->set_next_execution(now_64 + delay);
@@ -213,6 +209,14 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
}
#endif
}
#ifdef ESPHOME_LOG_HAS_VERBOSE
// Logged after releasing the lock: a logger on_message automation can call back into the scheduler.
if (type == SchedulerItem::INTERVAL) {
SchedulerNameLog name_log;
ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms",
name_log.format(name_type, static_name, hash_or_id), delay, offset);
}
#endif
// A background insertion may shorten a sleep whose deadline was already computed.
if (!is_main_loop_thread()) [[unlikely]]
wake_scheduler_threadsafe();
+4 -3
View File
@@ -7,6 +7,7 @@ import ipaddress
import logging
import os
from pathlib import Path
import platform
import re
import stat
import sys
@@ -40,9 +41,9 @@ FALSY_BOOL_STRINGS = frozenset({"false", "no", "off", "disable"})
TRUTHY_ENV_STRINGS = TRUTHY_BOOL_STRINGS | {"1"}
FALSY_ENV_STRINGS = FALSY_BOOL_STRINGS | {"0"}
IS_MACOS = sys.platform == "darwin"
IS_WINDOWS = sys.platform == "win32"
IS_LINUX = sys.platform.startswith("linux")
IS_MACOS = platform.system() == "Darwin"
IS_WINDOWS = platform.system() == "Windows"
IS_LINUX = platform.system() == "Linux"
# FNV-1 hash constants (must match C++ in esphome/core/helpers.h)
FNV1_OFFSET_BASIS = 2166136261
@@ -16,7 +16,6 @@ from esphome.core import EsphomeError
("test_bk7231t.yaml", "BK7231T.*BLE 4.2"),
("test_bk7252.yaml", "BK7251.*BLE 4.2"),
("test_bk7231q.yaml", "BK7231Q.*no BLE"),
("test_bk7238.yaml", "BK7238.*bootloader"),
],
)
def test_unsupported_family_rejected(
@@ -33,9 +32,11 @@ def test_unsupported_family_rejected(
assert "cannot compile" in caplog.text
@pytest.mark.parametrize("config_file", ["test_bk7231n.yaml", "test_bk7238.yaml"])
def test_ble5_family_generates(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
main_cpp = generate_main(component_config_path("test_bk7231n.yaml"))
main_cpp = generate_main(component_config_path(config_file))
assert "bk72xx_ble::BK72xxBLE" in main_cpp
@@ -0,0 +1,2 @@
packages:
bk72xx_ble: !include common.yaml
+7
View File
@@ -30,11 +30,18 @@ class MockUARTComponent : public uart::UARTComponent {
class TestableLD2450 : public LD2450Component {
public:
using LD2450Component::buffer_data_;
using LD2450Component::bluetooth_on_;
using LD2450Component::buffer_pos_;
using LD2450Component::get_timeout_status_;
using LD2450Component::mac_address_;
using LD2450Component::readline_;
using LD2450Component::timeout_;
using LD2450Component::version_;
using LD2450Component::zone_config_;
using LD2450Component::zone_type_;
#ifdef USE_NUMBER
using LD2450Component::pref_;
#endif
void feed(const std::vector<uint8_t> &data) {
for (uint8_t byte : data) {
+11
View File
@@ -131,12 +131,23 @@ text_sensor:
target_1:
direction:
name: Target-1 Direction
xy:
name: Target-1 XY
target_2:
direction:
name: Target-2 Direction
xy:
name: Target-2 XY
filters:
- throttle_with_priority:
timeout: 200ms
value: NA
target_3:
direction:
name: Target-3 Direction
xy:
name: Target-3 XY
filters: []
number:
- platform: ld2450
+43
View File
@@ -0,0 +1,43 @@
#include "common.h"
#include "esphome/components/ld24xx/ld24xx.h"
namespace esphome::ld2450::testing {
namespace {
// MAC query ACK (command 0xA5): header, length 10, command, result ok, 6 MAC bytes, footer.
std::vector<uint8_t> make_mac_ack(const uint8_t (&mac)[MAC_ADDRESS_SIZE]) {
std::vector<uint8_t> frame(std::begin(ld24xx::CMD_FRAME_HEADER), std::end(ld24xx::CMD_FRAME_HEADER));
frame.insert(frame.end(), {0x0A, 0x00, 0xA5, 0x01, 0x00, 0x00});
frame.insert(frame.end(), mac, mac + MAC_ADDRESS_SIZE);
frame.insert(frame.end(), std::begin(ld24xx::CMD_FRAME_FOOTER), std::end(ld24xx::CMD_FRAME_FOOTER));
return frame;
}
} // namespace
class LD2450MacAckTest : public ::testing::Test {
protected:
void SetUp() override { this->ld2450_.set_uart_parent(&this->mock_uart_); }
std::string mac_str_() const {
char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
return ld24xx::format_mac_str(this->ld2450_.mac_address_, buffer);
}
MockUARTComponent mock_uart_;
TestableLD2450 ld2450_;
};
TEST_F(LD2450MacAckTest, BluetoothOffAckClearsStoredMac) {
// Captured from radar firmware 2.04.23101915; the first byte has the multicast bit set
static constexpr uint8_t BEDROOM_MAC[MAC_ADDRESS_SIZE] = {0x81, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
this->ld2450_.feed(make_mac_ack(BEDROOM_MAC));
EXPECT_TRUE(this->ld2450_.bluetooth_on_);
EXPECT_THAT(this->ld2450_.mac_address_, ::testing::ElementsAreArray(BEDROOM_MAC));
ASSERT_EQ(this->mac_str_(), "81:2C:62:BE:C9:96");
this->ld2450_.feed(make_mac_ack(ld24xx::NO_MAC));
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
EXPECT_FALSE(this->ld2450_.bluetooth_on_);
EXPECT_THAT(this->ld2450_.mac_address_, ::testing::Each(0));
}
} // namespace esphome::ld2450::testing
@@ -0,0 +1,157 @@
#ifdef USE_HOST
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include "common.h"
#include "esphome/components/host/preferences.h"
#include "esphome/components/number/number.h"
#include "esphome/core/application.h"
namespace esphome::ld2450::testing {
namespace fs = std::filesystem;
namespace {
TestableLD2450 *hub = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// What the generated apply="set_presence_timeout()", publish="before" number does
void apply_presence_timeout(number::Number *entity, float value) {
entity->publish_state(value);
hub->set_presence_timeout();
}
using PresenceTimeoutNumber = number::ApplyNumber<apply_presence_timeout>;
// The presence_timeout part of LD2450Component::setup(), without its radar restart and scheduled info read
void boot(TestableLD2450 &radar, number::Number *entity) {
radar.set_presence_timeout_number(entity);
if (entity != nullptr) {
radar.pref_ = entity->make_entity_preference<float>();
radar.set_presence_timeout();
}
}
} // namespace
class LD2450PresenceTimeoutTest : public ::testing::Test {
protected:
void SetUp() override {
this->temp_dir_ = fs::temp_directory_path() / "esphome_ld2450_presence_timeout_test";
fs::remove_all(this->temp_dir_);
fs::create_directories(this->temp_dir_);
setenv("ESPHOME_PREFDIR", this->temp_dir_.c_str(), 1);
App.pre_setup("test_ld2450_timeout", strlen("test_ld2450_timeout"), "", 0);
// A fresh store per test: the shared one keeps the file path of whichever test set it up first, whose directory
// may already be gone, and its in-memory values
host::host_preferences = &this->prefs_;
global_preferences = &this->prefs_;
this->ld2450_.set_uart_parent(&this->uart_);
hub = &this->ld2450_;
}
void TearDown() override {
hub = nullptr;
host::setup_preferences();
unsetenv("ESPHOME_PREFDIR");
fs::remove_all(this->temp_dir_);
}
/// Save a presence_timeout the way a previous boot did, and flush it
void save_timeout(float seconds) {
PresenceTimeoutNumber saved;
ESPPreferenceObject pref = saved.make_entity_preference<float>();
ASSERT_TRUE(pref.save(&seconds));
ASSERT_TRUE(global_preferences->sync());
}
/// Whether a frame without a target, now ms after the last one, clears presence
bool cleared_after(uint32_t last_target_ms, uint32_t now_ms) {
LoopBlockingGuard clock{nullptr, nullptr, now_ms};
return this->ld2450_.get_timeout_status_(last_target_ms);
}
host::HostPreferences prefs_;
MockUARTComponent uart_;
TestableLD2450 ld2450_;
PresenceTimeoutNumber timeout_number_;
fs::path temp_dir_;
};
TEST_F(LD2450PresenceTimeoutTest, NoNumberKeepsDefaultHold) {
boot(this->ld2450_, nullptr);
EXPECT_EQ(this->ld2450_.timeout_, 5000u);
EXPECT_FALSE(this->cleared_after(1000, 5999));
EXPECT_TRUE(this->cleared_after(1000, 6000));
}
TEST_F(LD2450PresenceTimeoutTest, SetupWithNothingSavedPublishesDefault) {
boot(this->ld2450_, &this->timeout_number_);
ASSERT_TRUE(this->timeout_number_.has_state());
EXPECT_EQ(this->timeout_number_.state, 5.0f);
EXPECT_EQ(this->ld2450_.timeout_, 5000u);
}
TEST_F(LD2450PresenceTimeoutTest, SetupWithSavedZeroPublishesZero) {
this->save_timeout(0);
boot(this->ld2450_, &this->timeout_number_);
ASSERT_TRUE(this->timeout_number_.has_state());
EXPECT_EQ(this->timeout_number_.state, 0.0f);
EXPECT_EQ(this->ld2450_.timeout_, 0u);
}
TEST_F(LD2450PresenceTimeoutTest, SetupWithSavedValuePublishesIt) {
this->save_timeout(12);
boot(this->ld2450_, &this->timeout_number_);
EXPECT_EQ(this->timeout_number_.state, 12.0f);
EXPECT_EQ(this->ld2450_.timeout_, 12000u);
}
TEST_F(LD2450PresenceTimeoutTest, ControlZeroKeepsZeroAndHoldsNothing) {
boot(this->ld2450_, &this->timeout_number_);
ASSERT_EQ(this->timeout_number_.state, 5.0f);
auto call = this->timeout_number_.make_call();
call.set_value(0);
call.perform();
// The old code restored the saved 5 s as soon as the state read 0
EXPECT_EQ(this->timeout_number_.state, 0.0f);
EXPECT_EQ(this->ld2450_.timeout_, 0u);
EXPECT_TRUE(this->cleared_after(1000, 1000));
EXPECT_TRUE(this->cleared_after(1000, 1001));
}
TEST_F(LD2450PresenceTimeoutTest, ControlZeroIsSavedForTheNextBoot) {
boot(this->ld2450_, &this->timeout_number_);
auto call = this->timeout_number_.make_call();
call.set_value(0);
call.perform();
ASSERT_TRUE(global_preferences->sync());
TestableLD2450 rebooted;
PresenceTimeoutNumber rebooted_number;
rebooted.set_uart_parent(&this->uart_);
hub = &rebooted;
boot(rebooted, &rebooted_number);
ASSERT_TRUE(rebooted_number.has_state());
EXPECT_EQ(rebooted_number.state, 0.0f);
EXPECT_EQ(rebooted.timeout_, 0u);
}
TEST_F(LD2450PresenceTimeoutTest, ControlNonZeroSetsHold) {
boot(this->ld2450_, &this->timeout_number_);
auto call = this->timeout_number_.make_call();
call.set_value(3);
call.perform();
EXPECT_EQ(this->ld2450_.timeout_, 3000u);
EXPECT_FALSE(this->cleared_after(1000, 3999));
EXPECT_TRUE(this->cleared_after(1000, 4000));
}
} // namespace esphome::ld2450::testing
#endif // USE_HOST
+55
View File
@@ -0,0 +1,55 @@
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include "esphome/components/ld24xx/ld24xx.h"
namespace esphome::ld24xx::testing {
static std::string format_mac(const uint8_t (&mac)[MAC_ADDRESS_SIZE]) {
char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
return format_mac_str(mac, buffer);
}
TEST(LD24xxFormatMac, MulticastBitMacIsShownAsIs) {
// First byte 0x81 has the multicast bit set, and the radar reports it as its Bluetooth MAC.
const uint8_t mac[MAC_ADDRESS_SIZE] = {0x81, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
EXPECT_EQ(format_mac(mac), "81:2C:62:BE:C9:96");
}
TEST(LD24xxFormatMac, UnicastMacIsShownAsIs) {
const uint8_t mac[MAC_ADDRESS_SIZE] = {0x80, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
EXPECT_EQ(format_mac(mac), "80:2C:62:BE:C9:96");
}
TEST(LD24xxFormatMac, AllZeroMacIsUnknown) {
const uint8_t mac[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0};
EXPECT_EQ(format_mac(mac), "unknown");
}
TEST(LD24xxFormatMac, AllFfMacIsUnknown) {
const uint8_t mac[MAC_ADDRESS_SIZE] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
EXPECT_EQ(format_mac(mac), "unknown");
}
TEST(LD24xxFormatMac, MacWithSomeZeroOrFfBytesIsShown) {
const uint8_t mac[MAC_ADDRESS_SIZE] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x01};
EXPECT_EQ(format_mac(mac), "00:00:00:00:00:01");
const uint8_t ff_mac[MAC_ADDRESS_SIZE] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE};
EXPECT_EQ(format_mac(ff_mac), "FF:FF:FF:FF:FF:FE");
}
TEST(LD24xxStoreMac, ReportedMacIsStored) {
const uint8_t reported[MAC_ADDRESS_SIZE] = {0x81, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
uint8_t mac[MAC_ADDRESS_SIZE] = {};
EXPECT_TRUE(store_mac(reported, mac));
EXPECT_THAT(mac, ::testing::ElementsAreArray(reported));
}
TEST(LD24xxStoreMac, NoMacClearsStoredMac) {
uint8_t mac[MAC_ADDRESS_SIZE] = {0x81, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
EXPECT_FALSE(store_mac(NO_MAC, mac));
EXPECT_THAT(mac, ::testing::Each(0));
}
} // namespace esphome::ld24xx::testing
+32
View File
@@ -182,6 +182,29 @@ climate:
name: LG Climate
transmitter_id: xmitr
datetime:
- platform: template
name: Date
id: test_date
type: date
initial_value: "2000-1-2"
set_action:
- logger.log: "set_value"
- platform: template
name: Time
id: test_time
type: time
initial_value: "12:34:56am"
set_action:
- logger.log: "set_value"
- platform: template
name: DateTime
id: test_datetime
type: datetime
initial_value: "2000-1-2 12:34:56"
set_action:
- logger.log: "set_value"
prometheus:
include_internal: true
relabel:
@@ -191,3 +214,12 @@ prometheus:
template_text_sensor1:
id: hello_text
name: Text Substitution
test_date:
id: prometheus_date
name: Prometheus Date
test_time:
id: prometheus_time
name: Prometheus Time
test_datetime:
id: prometheus_datetime
name: Prometheus DateTime
@@ -3,21 +3,19 @@
#include "esphome/components/runtime_image/jpeg_decoder.h"
#include "esphome/components/runtime_image/runtime_image.h"
#include <utility>
#ifdef USE_RUNTIME_IMAGE_JPEG
namespace esphome::runtime_image::testing {
// A decoder whose JPEGDEC state is missing, as when its allocation fails. The state is handed back before the base
// destructor runs, so the teardown stays with JpegDecoder.
// A decoder whose JPEGDEC state is missing, as when its allocation fails. The state moves to state_, which frees it.
class StatelessJpegDecoder : public JpegDecoder {
public:
explicit StatelessJpegDecoder(RuntimeImage *image) : JpegDecoder(image), state_(this->jpeg_) {
this->jpeg_ = nullptr;
}
~StatelessJpegDecoder() override { this->jpeg_ = this->state_; }
explicit StatelessJpegDecoder(RuntimeImage *image) : JpegDecoder(image), state_(std::move(this->jpeg_)) {}
protected:
JPEGDEC *state_;
RAMUniquePtr<JPEGDEC> state_;
};
// The ~18 KB of JPEGDEC state lives in its own allocation, so it can come from PSRAM
@@ -0,0 +1,27 @@
esphome:
name: sched-verbose-log
host:
api:
logger:
level: VERBOSE
on_message:
# The scheduler logs each new interval at VERBOSE. This automation then
# calls back into the scheduler (the delay is a scheduler timeout).
level: VERBOSE
then:
- delay: 1ms
globals:
- id: fire_count
type: int
initial_value: "0"
interval:
- interval: 50ms
then:
- lambda: |-
id(fire_count) += 1;
if (id(fire_count) == 5) {
ESP_LOGI("test", "INTERVAL_5_FIRES_REACHED");
}
@@ -0,0 +1,198 @@
esphome:
name: uart-mock-ld2450-target-xy
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 256000
auto_start: false
responses:
# Generic ACK for any command, to unblock setup commands
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# One frame every 100 ms, as the radar sends them. Coordinates are sign-magnitude, with the high bit set for
# positive values.
injections:
# Targets 1 and 2 move, one step per frame. Target 3 moves for 3 frames, then leaves while its
# later positions are still held back by the throttle
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x58, 0x02, 0xE8, 0x83, 0x00, 0x00, 0x64, 0x00,
0xC8, 0x80, 0xD0, 0x87, 0x00, 0x00, 0x64, 0x00,
0x2C, 0x01, 0xB8, 0x8B, 0x00, 0x00, 0x64, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xF4, 0x01, 0x1A, 0x84, 0x00, 0x00, 0x64, 0x00,
0x18, 0x81, 0xEE, 0x87, 0x00, 0x00, 0x64, 0x00,
0xFA, 0x00, 0xEA, 0x8B, 0x00, 0x00, 0x64, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x90, 0x01, 0x4C, 0x84, 0x00, 0x00, 0x64, 0x00,
0x68, 0x81, 0x0C, 0x88, 0x00, 0x00, 0x64, 0x00,
0xC8, 0x00, 0x1C, 0x8C, 0x00, 0x00, 0x64, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x01, 0x7E, 0x84, 0x00, 0x00, 0x64, 0x00,
0xB8, 0x81, 0x2A, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xC8, 0x00, 0xB0, 0x84, 0x00, 0x00, 0x64, 0x00,
0x08, 0x82, 0x48, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x64, 0x00, 0xE2, 0x84, 0x00, 0x00, 0x64, 0x00,
0x58, 0x82, 0x66, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x14, 0x85, 0x00, 0x00, 0x64, 0x00,
0xA8, 0x82, 0x84, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x64, 0x80, 0x46, 0x85, 0x00, 0x00, 0x64, 0x00,
0xF8, 0x82, 0xA2, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xC8, 0x80, 0x78, 0x85, 0x00, 0x00, 0x64, 0x00,
0x48, 0x83, 0xC0, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0xAA, 0x85, 0x00, 0x00, 0x64, 0x00,
0x98, 0x83, 0xDE, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x90, 0x81, 0xDC, 0x85, 0x00, 0x00, 0x64, 0x00,
0xE8, 0x83, 0xFC, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xF4, 0x81, 0x0E, 0x86, 0x00, 0x00, 0x64, 0x00,
0x38, 0x84, 0x1A, 0x89, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Targets 1 and 2 stand still for 1.2 s (repeated frames are ignored), then leave
- delay: 1300ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
ld2450:
id: ld2450_dev
uart_id: mock_uart
text_sensor:
- platform: ld2450
ld2450_id: ld2450_dev
target_1:
xy:
name: "Target 1 XY"
filters:
- timeout:
timeout: 500ms
value: last
- throttle_with_priority:
timeout: 500ms
value: NA
target_2:
xy:
name: "Target 2 XY"
filters: []
target_3:
xy:
name: "Target 3 XY"
filters:
- timeout:
timeout: 500ms
value: last
- throttle_with_priority:
timeout: 500ms
value: NA
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(mock_uart).start_scenario();'
@@ -0,0 +1,34 @@
"""Test that a logger on_message automation can use the scheduler from a scheduler log.
Regression test for a self-deadlock: Scheduler::set_timer_common_ logged a new
interval at VERBOSE while holding the scheduler lock. The logger calls on_message
listeners on the same thread, and an automation with a delay adds a scheduler
timeout, which took the same non-recursive lock again. The first set_interval()
in setup() hung the main loop.
"""
from __future__ import annotations
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_scheduler_verbose_log_on_message(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""The main loop keeps running when on_message reacts to a scheduler log."""
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "sched-verbose-log")
await waiter.wait_for("Scheduler interval for", timeout=5.0)
await waiter.wait_for("INTERVAL_5_FIRES_REACHED", timeout=5.0)
@@ -0,0 +1,87 @@
"""Integration test for the LD2450 target xy text sensor, with and without a throttle."""
from __future__ import annotations
import asyncio
from itertools import groupby
from aioesphomeapi import ButtonInfo, EntityState, TextSensorInfo, TextSensorState
import pytest
from .state_utils import require_entity, subscribe_after_initial_states
from .types import APIClientConnectedFactory, RunCompiledFunction
# The positions of each target in the fixture, one per radar frame
TARGET_1 = [f"{-600 + 100 * i},{1000 + 50 * i}" for i in range(12)]
TARGET_2 = [f"{200 + 80 * i},{2000 + 30 * i}" for i in range(12)]
# Target 3 moves for 3 frames (300 ms) and leaves, with a 500 ms throttle
TARGET_3 = [f"{-300 + 50 * i},{3000 + 50 * i}" for i in range(3)]
# Frames are 100 ms apart: "NA" must come well before the 500 ms throttle ends
TARGET_3_LEAVES_WITHIN_S = 0.45
def _distinct(states: list[str]) -> list[str]:
"""Drop the repeat that the timeout filter sends after a pause."""
return [state for state, _ in groupby(states)]
@pytest.mark.asyncio
async def test_uart_mock_ld2450_target_xy(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test the target xy text sensor with and without a throttle."""
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
target_1 = require_entity(entities, "target_1_xy", TextSensorInfo)
target_2 = require_entity(entities, "target_2_xy", TextSensorInfo)
target_3 = require_entity(entities, "target_3_xy", TextSensorInfo)
history: dict[int, list[str]] = {
target.key: [] for target in (target_1, target_2, target_3)
}
target_3_times: list[float] = []
loop = asyncio.get_running_loop()
both_left = loop.create_future()
def on_state(state: EntityState) -> None:
if not isinstance(state, TextSensorState) or state.key not in history:
return
history[state.key].append(state.state)
if state.key == target_3.key:
target_3_times.append(loop.time())
# Target 3 left long before, so only targets 1 and 2 end the scenario
left = all(
history[key] and history[key][-1] == "NA"
for key in (target_1.key, target_2.key)
)
if left and not both_left.done():
both_left.set_result(None)
await subscribe_after_initial_states(client, entities, on_state=on_state)
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
await asyncio.wait_for(both_left, timeout=10.0)
# Throttled: the first position at once, then a few, in order, and the final one before the target leaves
throttled = _distinct(history[target_1.key])
assert throttled[-1] == "NA"
positions = throttled[:-1]
assert positions[0] == TARGET_1[0]
assert positions[-1] == TARGET_1[-1], (
"the final position was held back for good"
)
indexes = [TARGET_1.index(position) for position in positions]
assert indexes == sorted(set(indexes)), f"out of order or repeated: {positions}"
assert 3 <= len(positions) <= 6, f"expected a few updates, got {positions}"
# Not throttled: every change is published
assert history[target_2.key] == [*TARGET_2, "NA"]
# "NA" bypasses the throttle and drops the held position: nothing follows it
assert _distinct(history[target_3.key]) == [TARGET_3[0], "NA"]
assert target_3_times[1] - target_3_times[0] < TARGET_3_LEAVES_WITHIN_S
-16
View File
@@ -94,22 +94,6 @@ def _leaked_heavy_modules(module: str, extra: tuple[str, ...] = ()) -> str:
return result.stdout.strip()
def test_main_module_does_not_query_the_os_version() -> None:
"""``import esphome.__main__`` must not call ``platform.uname()`` (or ``system()``, which uses it).
On Windows they query WMI and fall back to running ``ver`` (importing
subprocess) when that fails, so the stdlib leak check below went flaky.
"""
check = (
"import platform\n"
"def fail(*args, **kwargs):\n"
" raise AssertionError('platform queried at import time')\n"
"platform.uname = fail\n"
"import esphome.__main__"
)
subprocess.run([sys.executable, "-c", check], check=True)
def test_main_module_does_not_import_heavy_modules() -> None:
"""A bare ``import esphome.__main__`` must not drag in validation/codegen.