Compare commits

...
Author SHA1 Message Date
J. Nick Koston 52f7665633 Check that a periodic frame holds every value it is read for 2026-10-21 21:25:50 -10:00
J. Nick Koston fd30920c91 Check the length of the distance resolution and light control answers before reading them 2026-10-21 21:09:37 -10:00
J. Nick Koston c130580a88 Trim the comments 2026-10-21 20:54:24 -10:00
J. Nick Koston d3f774e781 Drop to the next header with one call per frame 2026-10-21 20:52:09 -10:00
J. Nick Koston 56b763f8ef Say that the frame handler may overwrite the buffer position 2026-10-21 20:48:41 -10:00
J. Nick Koston 3623b65534 Give the test fixture's protected methods trailing underscores for clang-tidy 2026-10-21 20:44:53 -10:00
J. Nick Koston ac6c654713 Share the frame splitter through ld24xx 2026-10-21 20:39:49 -10:00
J. Nick Koston fb7e762e68 Count the footer when checking that a version answer is long enough 2026-10-21 20:23:40 -10:00
J. Nick Koston c36da87dd7 Drop a doubled empty comment line from the mock fixture 2026-10-21 20:13:20 -10:00
J. Nick Koston c29d13457b Pick the header only once a byte is buffered, cover impossible counts and complete the waiter 2026-10-21 20:07:11 -10:00
J. Nick Koston 07f34705bc Drop the fixture notes about a signed byte helper the parser no longer has 2026-10-21 20:00:19 -10:00
J. Nick Koston d175ab3215 Name the drop helper's parameters so clang-tidy sees no swapped argument 2026-10-21 19:19:18 -10:00
J. Nick Koston 6c123adb82 Drain the buffer in a loop and keep only bytes that begin a header after each frame 2026-10-21 19:13:28 -10:00
J. Nick Koston 060192355e Keep the largest declared frame length from wrapping around to zero 2026-10-21 18:55:42 -10:00
J. Nick Koston 5f06efa88b Space the mock phases past the sensor throttle and cover the replay after a dropped frame 2026-10-21 18:44:29 -10:00
J. Nick Koston 3527f6572c Fix the byte map and cover footer bytes inside a periodic frame 2026-10-21 18:38:46 -10:00
J. Nick Koston 45fb35d3a5 [ld2410] Split frames by their length instead of scanning for footer bytes 2026-10-21 18:34:14 -10:00
9 changed files with 435 additions and 96 deletions
+45 -48
View File
@@ -163,21 +163,19 @@ static constexpr uint8_t CMD_DURATION_VALUE = 0x02;
// Bitmasks for target states
static constexpr uint8_t MOVE_BITMASK = 0x01;
static constexpr uint8_t STILL_BITMASK = 0x02;
// Header & Footer size
static constexpr uint8_t HEADER_FOOTER_SIZE = 4;
// Command Header & Footer
static constexpr uint8_t CMD_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xFD, 0xFC, 0xFB, 0xFA};
static constexpr uint8_t CMD_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0x04, 0x03, 0x02, 0x01};
using ld24xx::CMD_FRAME_FOOTER;
using ld24xx::CMD_FRAME_HEADER;
using ld24xx::HEADER_FOOTER_SIZE;
using ld24xx::validate_header_footer;
// Smallest frames whose values can all be read: the last value, data footer and check, then the frame footer
static constexpr uint8_t PERIODIC_FRAME_MIN = DETECT_DISTANCE_HIGH + 3 + HEADER_FOOTER_SIZE;
static constexpr uint8_t ENGINEERING_FRAME_MIN = OUT_PIN_SENSOR + 3 + HEADER_FOOTER_SIZE;
// 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 inline bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) {
return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0;
}
void LD2410Component::dump_config() {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char version_s[20];
@@ -328,6 +326,9 @@ void LD2410Component::handle_periodic_data_() {
0x02: Normal mode
*/
bool engineering_mode = this->buffer_data_[DATA_TYPES] == 0x01;
if (this->buffer_pos_ < (engineering_mode ? ENGINEERING_FRAME_MIN : PERIODIC_FRAME_MIN)) {
return; // too short to hold the values
}
#ifdef USE_SWITCH
if (this->engineering_mode_switch_ != nullptr) {
this->engineering_mode_switch_->publish_state(engineering_mode);
@@ -416,24 +417,15 @@ std::function<void()> set_number_value(number::Number *n, float value) {
}
#endif
bool LD2410Component::handle_ack_data_() {
void LD2410Component::handle_ack_data_() {
ESP_LOGV(TAG, "Handling ACK DATA for COMMAND %02X", this->buffer_data_[COMMAND]);
if (this->buffer_pos_ < 10) {
ESP_LOGE(TAG, "Invalid length");
return true;
}
if (!ld2410::validate_header_footer(CMD_FRAME_HEADER, this->buffer_data_)) {
char hex_buf[format_hex_pretty_size(HEADER_FOOTER_SIZE)];
ESP_LOGW(TAG, "Invalid header: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, HEADER_FOOTER_SIZE));
return true;
}
if (this->buffer_data_[COMMAND_STATUS] != 0x01) {
ESP_LOGE(TAG, "Invalid status");
return true;
return;
}
if (this->buffer_data_[8] || this->buffer_data_[9]) {
ESP_LOGW(TAG, "Invalid command: %02X, %02X", this->buffer_data_[8], this->buffer_data_[9]);
return true;
return;
}
switch (this->buffer_data_[COMMAND]) {
@@ -456,6 +448,9 @@ bool LD2410Component::handle_ack_data_() {
break;
case CMD_QUERY_VERSION: {
if (this->buffer_pos_ < 12 + sizeof(this->version_) + HEADER_FOOTER_SIZE) {
break; // too short to hold a version
}
std::memcpy(this->version_, &this->buffer_data_[12], sizeof(this->version_));
char version_s[20];
ld24xx::format_version_str(this->version_, version_s);
@@ -469,6 +464,9 @@ bool LD2410Component::handle_ack_data_() {
}
case CMD_QUERY_DISTANCE_RESOLUTION: {
if (this->buffer_pos_ < 11 + HEADER_FOOTER_SIZE) {
break; // too short to hold the resolution
}
const auto *distance_resolution = find_str(DISTANCE_RESOLUTIONS_BY_UINT, this->buffer_data_[10]);
ESP_LOGV(TAG, "Distance resolution: %s", distance_resolution);
#ifdef USE_SELECT
@@ -480,6 +478,9 @@ bool LD2410Component::handle_ack_data_() {
}
case CMD_QUERY_LIGHT_CONTROL: {
if (this->buffer_pos_ < 13 + HEADER_FOOTER_SIZE) {
break; // too short to hold the light settings
}
this->light_function_ = this->buffer_data_[10];
this->light_threshold_ = this->buffer_data_[11];
this->out_pin_level_ = this->buffer_data_[12];
@@ -504,7 +505,7 @@ bool LD2410Component::handle_ack_data_() {
}
case CMD_QUERY_MAC_ADDRESS: {
if (this->buffer_pos_ < 20) {
return false;
break; // too short to hold a MAC address
}
this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0;
@@ -549,12 +550,11 @@ bool LD2410Component::handle_ack_data_() {
break;
case CMD_QUERY: { // Query parameters response
// Config values can contain the footer bytes, so wait for the whole 38 byte frame
if (this->buffer_pos_ < 38) {
return false;
break; // too short to hold the parameters
}
if (this->buffer_data_[10] != HEADER)
return true; // value head=0xAA
return; // value head=0xAA
#ifdef USE_NUMBER
/*
Moving distance range: 13th byte
@@ -589,39 +589,36 @@ bool LD2410Component::handle_ack_data_() {
default:
break;
}
return true;
}
void LD2410Component::readline_(int readch) {
if (readch < 0) {
return; // No data available
}
// Periodic and command frames both carry their count after the header
ld24xx::feed_frame_byte(
this->buffer_data_, this->buffer_pos_, readch, DATA_FRAME_HEADER, ld24xx::cmd_frame_length,
[this](uint8_t frame_len) { return this->handle_frame_(frame_len); }, []() { ESP_LOGW(TAG, "Invalid length"); });
}
if (this->buffer_pos_ < MAX_LINE_LENGTH - 1) {
this->buffer_data_[this->buffer_pos_++] = readch;
this->buffer_data_[this->buffer_pos_] = 0;
} else {
// We should never get here, but just in case...
ESP_LOGW(TAG, "Max command length exceeded; ignoring");
this->buffer_pos_ = 0;
return;
}
if (this->buffer_pos_ < HEADER_FOOTER_SIZE) {
return; // Not enough data to process yet
}
if (ld2410::validate_header_footer(DATA_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) {
// Dispatches the frame of frame_len bytes at the start of the buffer when its footer is in place
bool LD2410Component::handle_frame_(uint8_t frame_len) {
this->buffer_pos_ = frame_len; // the handlers take the frame length from here
const uint8_t *tail = &this->buffer_data_[frame_len - HEADER_FOOTER_SIZE];
if (this->buffer_data_[0] == DATA_FRAME_HEADER[0]) {
if (!ld2410::validate_header_footer(DATA_FRAME_FOOTER, tail)) {
return false;
}
ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s");
this->handle_periodic_data_();
this->buffer_pos_ = 0; // Reset position index for next message
} else if (ld2410::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) {
ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s");
if (this->handle_ack_data_()) {
this->buffer_pos_ = 0; // Reset position index for next message
} else {
ESP_LOGV(TAG, "Ack Data incomplete");
}
return true;
}
if (!ld2410::validate_header_footer(CMD_FRAME_FOOTER, tail)) {
return false;
}
ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s");
this->handle_ack_data_();
return true;
}
void LD2410Component::set_config_mode_(bool enable) {
+2 -1
View File
@@ -106,7 +106,8 @@ class LD2410Component final : public Component, public uart::UARTDevice {
void send_command_(uint8_t command_str, const uint8_t *command_value, uint8_t command_value_len);
void set_config_mode_(bool enable);
void handle_periodic_data_();
bool handle_ack_data_();
void handle_ack_data_();
bool handle_frame_(uint8_t frame_len);
void readline_(int readch);
void query_parameters_();
void get_version_();
+70
View File
@@ -3,6 +3,8 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include <algorithm>
#include <cstring>
#include <memory>
#include <span>
@@ -53,6 +55,74 @@ inline void format_version_str(const uint8_t *version, std::span<char, 20> buffe
version[2]);
}
// Commands and their answers share this header, a little-endian byte count and this footer
static constexpr uint8_t HEADER_FOOTER_SIZE = 4;
static constexpr uint8_t CMD_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xFD, 0xFC, 0xFB, 0xFA};
static constexpr uint8_t CMD_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0x04, 0x03, 0x02, 0x01};
inline bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) {
return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0;
}
// Whole command frame length, or 0 until the count arrives; 32 bit so 0xFFFF cannot wrap to 0
inline uint32_t cmd_frame_length(const uint8_t *buffer, uint8_t count) {
if (count < HEADER_FOOTER_SIZE + 2) {
return 0;
}
return HEADER_FOOTER_SIZE + 2 + encode_value_le<uint16_t>(&buffer[HEADER_FOOTER_SIZE]) + HEADER_FOOTER_SIZE;
}
// Drops the first `from` bytes and anything after them that does not begin a header; returns the bytes kept
inline uint8_t drop_to_next_header(uint8_t *buffer, uint8_t count, uint8_t from, const uint8_t *data_header) {
for (; from < count; from++) {
const size_t n = std::min<size_t>(HEADER_FOOTER_SIZE, count - from);
if (std::memcmp(&buffer[from], CMD_FRAME_HEADER, n) == 0 || std::memcmp(&buffer[from], data_header, n) == 0) {
std::memmove(buffer, &buffer[from], count - from);
return count - from;
}
}
return 0;
}
// Feeds one byte into a frame buffer. A frame opens with the command header or `data_header` and ends at
// `frame_length(buffer, count)` bytes (0 while unknown); `handle_frame(len)` returns false for a footer out
// of place. A dropped or oversized frame calls `on_drop()`. The handler may overwrite `pos`; the splitter
// works from the count it saved.
template<size_t N, typename LengthFn, typename HandleFn, typename DropFn>
void feed_frame_byte(uint8_t (&buffer)[N], uint8_t &pos, uint8_t byte, const uint8_t *data_header,
LengthFn frame_length, HandleFn handle_frame, DropFn on_drop) {
// The first byte picks the header; bytes that do not continue it are dropped
if (pos > 0 && pos < HEADER_FOOTER_SIZE) {
const uint8_t *header = buffer[0] == data_header[0] ? data_header : CMD_FRAME_HEADER;
if (byte != header[pos]) {
pos = 0;
}
}
if (pos == 0 && byte != data_header[0] && byte != CMD_FRAME_HEADER[0]) {
return;
}
buffer[pos++] = byte;
// Drain every whole frame left in the buffer
while (true) {
const uint32_t frame_len = frame_length(buffer, pos);
if (frame_len == 0 || (frame_len <= N && pos < frame_len)) {
return;
}
const uint8_t total = pos;
// Keep only what begins a header: after the frame, or after its first byte when it is dropped
uint8_t from = 1;
if (frame_len <= N && handle_frame(static_cast<uint8_t>(frame_len))) {
from = frame_len;
} else {
on_drop();
}
pos = drop_to_next_header(buffer, total, from, data_header);
if (pos == 0) {
return;
}
}
}
#ifdef USE_SENSOR
template<typename T> using SensorWithDedup = sensor::SensorWithDedup<T>;
#endif
@@ -0,0 +1,152 @@
#include <cstdint>
#include <cstring>
#include <vector>
#include <gtest/gtest.h>
#include "esphome/components/ld24xx/ld24xx.h"
namespace esphome::ld24xx::testing {
namespace {
constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1};
constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5};
constexpr uint8_t DATA_FRAME_SIZE = 12; // header, four payload bytes, footer
constexpr size_t BUFFER_SIZE = 40;
// Command frame around the payload
std::vector<uint8_t> make_cmd_frame(const std::vector<uint8_t> &payload) {
std::vector<uint8_t> frame(std::begin(CMD_FRAME_HEADER), std::end(CMD_FRAME_HEADER));
frame.push_back(payload.size());
frame.push_back(0x00);
frame.insert(frame.end(), payload.begin(), payload.end());
frame.insert(frame.end(), std::begin(CMD_FRAME_FOOTER), std::end(CMD_FRAME_FOOTER));
return frame;
}
std::vector<uint8_t> make_data_frame(uint8_t fill) {
std::vector<uint8_t> frame(std::begin(DATA_FRAME_HEADER), std::end(DATA_FRAME_HEADER));
frame.insert(frame.end(), DATA_FRAME_SIZE - 2 * HEADER_FOOTER_SIZE, fill);
frame.insert(frame.end(), std::begin(DATA_FRAME_FOOTER), std::end(DATA_FRAME_FOOTER));
return frame;
}
} // namespace
// Data frames are fixed size, command frames carry their count, as in the radar components
class LD24xxFrameSplitterTest : public ::testing::Test {
protected:
void feed_(const std::vector<uint8_t> &bytes) {
for (uint8_t byte : bytes) {
feed_frame_byte(
this->buffer_, this->pos_, byte, DATA_FRAME_HEADER,
[](const uint8_t *buffer, uint8_t count) -> uint32_t {
if (count < HEADER_FOOTER_SIZE) {
return 0;
}
return buffer[0] == DATA_FRAME_HEADER[0] ? DATA_FRAME_SIZE : cmd_frame_length(buffer, count);
},
[this](uint8_t frame_len) { return this->handle_frame_(frame_len); }, [this]() { this->drops_++; });
}
}
bool handle_frame_(uint8_t frame_len) {
const uint8_t *footer = this->buffer_[0] == DATA_FRAME_HEADER[0] ? DATA_FRAME_FOOTER : CMD_FRAME_FOOTER;
if (!validate_header_footer(footer, &this->buffer_[frame_len - HEADER_FOOTER_SIZE])) {
return false;
}
this->frames_.emplace_back(this->buffer_, this->buffer_ + frame_len);
return true;
}
uint8_t buffer_[BUFFER_SIZE];
uint8_t pos_{0};
std::vector<std::vector<uint8_t>> frames_;
int drops_{0};
};
TEST_F(LD24xxFrameSplitterTest, FooterBytesInThePayloadDoNotEndTheFrame) {
auto frame = make_cmd_frame({0x61, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01, 0x55});
this->feed_(frame);
ASSERT_EQ(this->frames_.size(), 1u);
EXPECT_EQ(this->frames_[0], frame);
EXPECT_EQ(this->pos_, 0);
EXPECT_EQ(this->drops_, 0);
}
TEST_F(LD24xxFrameSplitterTest, BytesBeforeAHeaderAreNeverBuffered) {
this->feed_({0x00, 0x11, 0xFC, 0xFB, 0xFA, 0x04});
EXPECT_EQ(this->pos_, 0);
this->feed_(make_data_frame(0x22));
ASSERT_EQ(this->frames_.size(), 1u);
EXPECT_EQ(this->frames_[0], make_data_frame(0x22));
}
TEST_F(LD24xxFrameSplitterTest, AHeaderRestartsWhenItsBytesStopMatching) {
// F3 cannot continue the command header, and FD after FC starts it over
this->feed_({0xFD, 0xF3, 0xFD, 0xFC, 0xFD, 0xFC, 0xFB, 0xFA, 0x00, 0x00});
EXPECT_EQ(this->pos_, 6) << "only the header that matched through and its count are buffered";
this->feed_({0x04, 0x03, 0x02, 0x01});
ASSERT_EQ(this->frames_.size(), 1u);
EXPECT_EQ(this->frames_[0], make_cmd_frame({}));
EXPECT_EQ(this->drops_, 0);
}
TEST_F(LD24xxFrameSplitterTest, ImpossibleCountsAreDroppedAndTheNextFrameParsed) {
// 0xFFF6 totals 65536, which must not wrap to 0
this->feed_({0xFD, 0xFC, 0xFB, 0xFA, 0xF6, 0xFF});
this->feed_({0xFD, 0xFC, 0xFB, 0xFA, 0xFF, 0xFF});
EXPECT_EQ(this->drops_, 2);
EXPECT_EQ(this->pos_, 0);
this->feed_(make_data_frame(0x33));
ASSERT_EQ(this->frames_.size(), 1u);
EXPECT_EQ(this->frames_[0], make_data_frame(0x33));
}
TEST_F(LD24xxFrameSplitterTest, AFrameCaughtInsideADroppedFrameIsReplayed) {
// The count reaches 20 bytes in, past the whole frame that follows
std::vector<uint8_t> bytes = {0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x00, 0x61, 0x01};
auto inner = make_data_frame(0x44);
bytes.insert(bytes.end(), inner.begin(), inner.end());
this->feed_(bytes);
EXPECT_EQ(this->drops_, 1);
ASSERT_EQ(this->frames_.size(), 1u);
EXPECT_EQ(this->frames_[0], inner);
EXPECT_EQ(this->pos_, 0);
}
TEST_F(LD24xxFrameSplitterTest, EveryWholeFrameLeftByADropIsDrained) {
// The window holds two whole data frames and the start of a third
std::vector<uint8_t> bytes = {0xFD, 0xFC, 0xFB, 0xFA, 0x18, 0x00, 0x61, 0x01};
for (uint8_t fill : {0x55, 0x66}) {
auto inner = make_data_frame(fill);
bytes.insert(bytes.end(), inner.begin(), inner.end());
}
bytes.insert(bytes.end(), {0xF4, 0xF3});
this->feed_(bytes);
EXPECT_EQ(this->drops_, 1);
ASSERT_EQ(this->frames_.size(), 2u);
EXPECT_EQ(this->frames_[0], make_data_frame(0x55));
EXPECT_EQ(this->frames_[1], make_data_frame(0x66));
EXPECT_EQ(this->pos_, 2) << "the partial header at the end stays buffered";
}
TEST_F(LD24xxFrameSplitterTest, AWrongFooterDropsOnlyThatFrame) {
auto bad = make_cmd_frame({0x01, 0x02});
bad[bad.size() - 1] = 0xEE;
this->feed_(bad);
EXPECT_EQ(this->drops_, 1);
EXPECT_TRUE(this->frames_.empty());
this->feed_(make_cmd_frame({0x03}));
ASSERT_EQ(this->frames_.size(), 1u);
EXPECT_EQ(this->frames_[0], make_cmd_frame({0x03}));
}
TEST_F(LD24xxFrameSplitterTest, BackToBackFramesOfBothKinds) {
this->feed_(make_data_frame(0x01));
this->feed_(make_cmd_frame({0xA0, 0x01, 0x00, 0x00}));
this->feed_(make_data_frame(0x02));
ASSERT_EQ(this->frames_.size(), 3u);
EXPECT_EQ(this->frames_[1], make_cmd_frame({0xA0, 0x01, 0x00, 0x00}));
EXPECT_EQ(this->frames_[2], make_data_frame(0x02));
EXPECT_EQ(this->drops_, 0);
}
} // namespace esphome::ld24xx::testing
@@ -30,10 +30,7 @@ uart_mock:
# Detection distance: 300cm
# Target state: 0x03 (moving + still)
#
# Note: LD2410's two_byte_to_int() uses signed char, so low bytes must be
# <=127 to produce correct values. Values >127 wrap negative.
#
# Frame layout (24 bytes):
# Frame layout (23 bytes):
# [0-3] F4 F3 F2 F1 = data frame header
# [4-5] 0D 00 = length 13
# [6] 02 = data type (normal)
@@ -44,10 +41,9 @@ uart_mock:
# [12-13] 78 00 = still distance 120 (0x0078)
# [14] 19 = still energy 25
# [15-16] 2C 01 = detection distance 300 (0x012C)
# [17] 00 = padding
# [18] 55 = data footer marker
# [19] 00 = CRC/check
# [20-23] F8 F7 F6 F5 = data frame footer
# [17] 55 = data footer marker
# [18] 00 = CRC/check
# [19-22] F8 F7 F6 F5 = data frame footer
- delay: 100ms
inject_rx:
[
@@ -60,28 +56,23 @@ uart_mock:
0x78, 0x00,
0x19,
0x2C, 0x01,
0x00,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 2 (t=200ms): Garbage bytes - corrupt the buffer
# These random bytes will accumulate in the LD2410's internal buffer.
# No footer will be found, so the buffer just grows.
# Phase 2 (t=200ms): Garbage bytes
# None of them can start a frame header, so the parser drops them all.
- delay: 100ms
inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22]
# Phase 3 (t=300ms): Truncated frame (header + partial data, no footer)
# More bytes accumulating in the buffer without a footer match.
# The header opens the buffer; the parser waits for the 23 bytes its length announces.
- delay: 100ms
inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x0D, 0x00, 0x02, 0xAA]
# Phase 4 (t=500ms): Overflow - inject 85 bytes of 0xFF (MAX_LINE_LENGTH=50)
# Buffer has 15 bytes from phases 2+3.
# Overflow math: need 35 bytes to trigger first overflow (pos 15->49),
# then 50 more to trigger second overflow (pos 0->49). Total = 85 bytes.
# After two overflows, buffer_pos_ = 0 with a completely clean buffer.
# The LD2410 logs "Max command length exceeded" at each overflow.
# Phase 4 (t=500ms): 85 bytes of 0xFF
# The first 15 complete the truncated frame's 23 bytes without its footer, so it is dropped
# with an 'Invalid length' warning; the rest cannot start a header and are dropped too.
- delay: 200ms
inject_rx:
[
@@ -96,8 +87,8 @@ uart_mock:
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
]
# Phase 5 (t=600ms): Valid frame after overflow - recovery test
# Buffer was reset by overflow. This valid frame should parse correctly.
# Phase 5 (t=600ms): Valid frame after the garbage - recovery test
# The buffer is empty again, so this valid frame should parse correctly.
# Moving target: 50cm, energy 100
# Still target: 75cm, energy 80
# Detection distance: 127cm
@@ -113,7 +104,71 @@ uart_mock:
0x4B, 0x00,
0x50,
0x7F, 0x00,
0x00,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 6 (t=3100ms): Values that spell the command footer 04 03 02 01
# Moving distance 772 (0x0304), moving energy 2, still distance 1: bytes 9 to 12 are the
# command frame footer, which must not end the frame early.
# Still energy 5, detection distance 772. The sensors hold and throttle values for a second,
# so the gap lets the phase 5 states reach the test before these replace them.
- delay: 2500ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x0D, 0x00,
0x02, 0xAA,
0x03,
0x04, 0x03,
0x02,
0x01, 0x00,
0x05,
0x04, 0x03,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 7 (t=5600ms): Truncated frame merged with a complete one
# The eight header bytes announce a 23 byte frame, so its window takes in the first 15 bytes of
# the valid frame that follows. The merged frame has no footer where the count says, so it is
# dropped and the bytes after its header are replayed, which recovers the valid frame.
# Moving distance 400, moving energy 7, still distance 200, still energy 9, detection distance 400
- delay: 2500ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1, 0x0D, 0x00, 0x02, 0xAA,
0xF4, 0xF3, 0xF2, 0xF1,
0x0D, 0x00,
0x02, 0xAA,
0x03,
0x90, 0x01,
0x07,
0xC8, 0x00,
0x09,
0x90, 0x01,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 8 (t=8100ms): Impossible counts, then a complete frame
# A command header counting 0xFFF6 bytes (65536 in total, which must not wrap to 0) and one counting
# 0xFFFF are each dropped as soon as the count arrives, and the valid frame after them is parsed.
# Moving distance 600, moving energy 11, still distance 300, still energy 13, detection distance 600
- delay: 2500ms
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0xF6, 0xFF,
0xFD, 0xFC, 0xFB, 0xFA, 0xFF, 0xFF,
0xF4, 0xF3, 0xF2, 0xF1,
0x0D, 0x00,
0x02, 0xAA,
0x03,
0x58, 0x02,
0x0B,
0x2C, 0x01,
0x0D,
0x58, 0x02,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
@@ -89,7 +89,6 @@ uart_mock:
# Phase 3 (t=300ms): Frame with still target at 291cm (multi-byte distance)
# This tests the two_byte_to_int function with high byte > 0
# Note: low byte 0x2F < 0x80 so it avoids the signed char bug
- delay: 100ms
inject_rx:
[
@@ -42,7 +42,6 @@ uart_mock:
0x78, 0x00,
0x19,
0x2C, 0x01,
0x00,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
@@ -60,6 +59,24 @@ uart_mock:
0x04, 0x03, 0x02, 0x01,
]
# Version answer cut after two bytes, so the footer sits where the version would be
- delay: 100ms
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xA0, 0x01, 0x00, 0x00,
0x00, 0x01, 0x07, 0x02,
0x04, 0x03, 0x02, 0x01,
]
# A whole version answer: firmware type 1, version 2.07.22062416
- delay: 100ms
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x0C, 0x00, 0xA0, 0x01, 0x00, 0x00,
0x00, 0x01, 0x07, 0x02, 0x16, 0x24, 0x06, 0x22,
0x04, 0x03, 0x02, 0x01,
]
# Move thresholds of gates 0 to 3 are the footer bytes 04 03 02 01
# Max gates 6/5, move 4 3 2 1 60 45 46 47 48, still 20 to 28, timeout 300
- delay: 100ms
@@ -116,6 +133,12 @@ number:
still_threshold:
name: "G8 Still Threshold"
text_sensor:
- platform: ld2410
ld2410_id: ld2410_dev
version:
name: "Version"
button:
- platform: template
name: "Start Scenario"
+51 -13
View File
@@ -5,9 +5,12 @@ test_uart_mock_ld2410 (normal mode):
1. Happy path - valid data frame publishes correct sensor values
2. Garbage resilience - random bytes don't crash the component
3. Truncated frame handling - partial frame doesn't corrupt state
4. Buffer overflow recovery - overflow resets the parser
5. Post-overflow parsing - next valid frame after overflow is parsed correctly
6. TX logging - verifies LD2410 sends expected setup commands
4. Truncated frame recovery - the frame is dropped once its length is reached
5. Post-garbage parsing - the next valid frame is parsed correctly
6. Footer bytes in the values - a frame whose values spell the command footer is parsed whole
7. Replay after a drop - a valid frame caught inside a dropped frame's window is still parsed
8. Impossible counts - a length field of 0xFFF6 or 0xFFFF is dropped and the next frame is parsed
9. TX logging - verifies LD2410 sends expected setup commands
test_uart_mock_ld2410_engineering (engineering mode):
1. Engineering mode frames with per-gate energy data and light sensor
@@ -34,18 +37,18 @@ async def test_uart_mock_ld2410(
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test LD2410 data parsing with happy path, garbage, overflow, and recovery."""
"""Test LD2410 data parsing with happy path, garbage, a truncated frame, and recovery."""
loop = asyncio.get_running_loop()
# Track overflow warning in logs
overflow_seen = loop.create_future()
# Track the warning for the dropped truncated frame in logs
resync_seen = loop.create_future()
# Track TX data logged by the mock for assertions
tx_log_lines: list[str] = []
def line_callback(line: str) -> None:
if "Max command length exceeded" in line and not overflow_seen.done():
overflow_seen.set_result(True)
if "Invalid length" in line and not resync_seen.done():
resync_seen.set_result(True)
# Capture all TX log lines from uart_mock
if "uart_mock" in line and "TX " in line:
tx_log_lines.append(line)
@@ -74,6 +77,22 @@ async def test_uart_mock_ld2410(
and pytest.approx(100.0) in collector.sensor_states["moving_energy"]
and pytest.approx(80.0) in collector.sensor_states["still_energy"]
and pytest.approx(127.0) in collector.sensor_states["detection_distance"]
# Every value asserted below, so no state can still be in flight when the waiter fires
and pytest.approx(772.0) in collector.sensor_states["moving_distance"]
and pytest.approx(2.0) in collector.sensor_states["moving_energy"]
and pytest.approx(1.0) in collector.sensor_states["still_distance"]
and pytest.approx(5.0) in collector.sensor_states["still_energy"]
and pytest.approx(772.0) in collector.sensor_states["detection_distance"]
and pytest.approx(400.0) in collector.sensor_states["moving_distance"]
and pytest.approx(7.0) in collector.sensor_states["moving_energy"]
and pytest.approx(200.0) in collector.sensor_states["still_distance"]
and pytest.approx(9.0) in collector.sensor_states["still_energy"]
and pytest.approx(400.0) in collector.sensor_states["detection_distance"]
and pytest.approx(600.0) in collector.sensor_states["moving_distance"]
and pytest.approx(11.0) in collector.sensor_states["moving_energy"]
and pytest.approx(300.0) in collector.sensor_states["still_distance"]
and pytest.approx(13.0) in collector.sensor_states["still_energy"]
and pytest.approx(600.0) in collector.sensor_states["detection_distance"]
)
)
@@ -107,7 +126,7 @@ async def test_uart_mock_ld2410(
assert collector.sensor_states["detection_distance"][0] == pytest.approx(300.0)
# Wait for the recovery frame (Phase 5) to be parsed
# This proves the component survived garbage + truncated + overflow
# This proves the component survived garbage + truncated frame + filler bytes
await wait_or_fail(
recovery_received,
15.0,
@@ -115,10 +134,8 @@ async def test_uart_mock_ld2410(
sensor_states=collector.sensor_states,
)
# Verify overflow warning was logged
assert overflow_seen.done(), (
"Expected 'Max command length exceeded' warning in logs"
)
# Verify the truncated frame was dropped with a warning
assert resync_seen.done(), "Expected 'Invalid length' warning in logs"
# Verify LD2410 sent setup commands (TX logging)
# LD2410 sends 7 commands during setup: FF (config on), A0 (version),
@@ -151,6 +168,27 @@ async def test_uart_mock_ld2410(
recovery_idx
] == pytest.approx(127.0)
# Phase 6: the values 04 03 02 01 did not end the frame early
assert pytest.approx(772.0) in collector.sensor_states["moving_distance"]
assert pytest.approx(2.0) in collector.sensor_states["moving_energy"]
assert pytest.approx(1.0) in collector.sensor_states["still_distance"]
assert pytest.approx(5.0) in collector.sensor_states["still_energy"]
assert pytest.approx(772.0) in collector.sensor_states["detection_distance"]
# Phase 7: the valid frame inside the dropped frame's window was replayed and parsed
assert pytest.approx(400.0) in collector.sensor_states["moving_distance"]
assert pytest.approx(7.0) in collector.sensor_states["moving_energy"]
assert pytest.approx(200.0) in collector.sensor_states["still_distance"]
assert pytest.approx(9.0) in collector.sensor_states["still_energy"]
assert pytest.approx(400.0) in collector.sensor_states["detection_distance"]
# Phase 8: both impossible counts were dropped and the frame after them was parsed
assert pytest.approx(600.0) in collector.sensor_states["moving_distance"]
assert pytest.approx(11.0) in collector.sensor_states["moving_energy"]
assert pytest.approx(300.0) in collector.sensor_states["still_distance"]
assert pytest.approx(13.0) in collector.sensor_states["still_energy"]
assert pytest.approx(600.0) in collector.sensor_states["detection_distance"]
# Verify binary sensors detected targets (from Phase 1 frame)
assert collector.binary_states["has_target"][0] is True
assert collector.binary_states["has_moving_target"][0] is True
@@ -2,7 +2,8 @@
An ordinary data frame comes first, then two query ACKs. Move thresholds 4, 3, 2, 1
put the command footer inside the ACK values. Every value must come from the whole
38 byte frame, not from bytes left by the previous frame.
38 byte frame, not from bytes left by the previous frame. A version answer cut short between
the ACKs publishes nothing; the whole one after it does.
The ACKs are synthetic, built from the layout the component parses, not device captures.
"""
@@ -17,6 +18,7 @@ from aioesphomeapi import (
NumberState,
SensorInfo,
SensorState,
TextSensorState,
)
import pytest
@@ -37,6 +39,9 @@ EXPECTED: dict[str, tuple[float, float]] = {
"g8_still_threshold": (15.0, 28.0),
}
# The whole version answer formats as this; the short one must publish nothing
VERSION_EXPECTED = "2.07.22062416"
# Sensor object_id: value from the data frame
SENSOR_EXPECTED: dict[str, float] = {
"moving_distance": 100.0,
@@ -56,15 +61,12 @@ async def test_uart_mock_ld2410_query_ack(
"""Test a data frame, then a query ACK with the footer bytes in its values."""
loop = asyncio.get_running_loop()
ack_errors: list[str] = []
waited_for_frame_end = loop.create_future()
def line_callback(line: str) -> None:
if "[ld2410" not in line:
return
if "Invalid " in line:
ack_errors.append(line)
if "Ack Data incomplete" in line and not waited_for_frame_end.done():
waited_for_frame_end.set_result(True)
number_states: dict[str, list[float]] = {name: [] for name in EXPECTED}
sensor_states: dict[str, list[float]] = {name: [] for name in SENSOR_EXPECTED}
@@ -73,7 +75,12 @@ async def test_uart_mock_ld2410_query_ack(
data_received = loop.create_future()
final_received = loop.create_future()
version_states: list[str] = []
def on_state(state: EntityState) -> None:
if isinstance(state, TextSensorState) and not state.missing_state:
version_states.append(state.state)
return
if isinstance(state, SensorState) and not state.missing_state:
if (name := sensor_key_to_name.get(state.key)) is None:
return
@@ -145,10 +152,7 @@ async def test_uart_mock_ld2410_query_ack(
# Each number is published once per ACK: the seed value, then the value under test
for name, (seed, value) in EXPECTED.items():
assert number_states[name] == pytest.approx([seed, value]), name
# The log line comes over stdout, which can trail the API states
await wait_or_fail(
waited_for_frame_end,
2.0,
"Expected the parser to wait past the footer bytes in the values",
)
# Only the whole version answer reached the text sensor
assert version_states == [VERSION_EXPECTED]
# The footer bytes inside the values never ended the frame early, so nothing was rejected
assert not ack_errors, f"Unexpected ACK errors: {ack_errors}"