mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 13:56:10 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52f7665633 | ||
|
|
fd30920c91 | ||
|
|
c130580a88 | ||
|
|
d3f774e781 | ||
|
|
56b763f8ef | ||
|
|
3623b65534 | ||
|
|
ac6c654713 | ||
|
|
fb7e762e68 | ||
|
|
c36da87dd7 | ||
|
|
c29d13457b | ||
|
|
07f34705bc | ||
|
|
d175ab3215 | ||
|
|
6c123adb82 | ||
|
|
060192355e | ||
|
|
5f06efa88b | ||
|
|
3527f6572c | ||
|
|
45fb35d3a5 |
@@ -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) {
|
||||
|
||||
@@ -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_();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user