mirror of
https://github.com/esphome/esphome.git
synced 2026-10-23 14:38:52 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a41ffca2d1 | ||
|
|
e434592e0b | ||
|
|
e681450c44 | ||
|
|
3c7c4d1812 | ||
|
|
d581d24048 | ||
|
|
f250f7bbe0 | ||
|
|
8266508086 | ||
|
|
73c3d55a99 | ||
|
|
f8480af41b | ||
|
|
10679cd697 | ||
|
|
d2411bda0a | ||
|
|
5c1c619dc2 | ||
|
|
5cf2a9953c | ||
|
|
76f1aaf4f4 | ||
|
|
bcdea5c52e | ||
|
|
0684576b18 |
No files matched your search
@@ -50,6 +50,7 @@ enum PeriodicDataValue : uint8_t {
|
||||
};
|
||||
|
||||
enum AckData : uint8_t {
|
||||
FRAME_LENGTH = 4,
|
||||
COMMAND = 6,
|
||||
COMMAND_STATUS = 7,
|
||||
};
|
||||
@@ -124,17 +125,17 @@ static constexpr uint8_t CMD_QUERY_TARGET_MODE = 0x91;
|
||||
static constexpr uint8_t CMD_SET_BAUD_RATE = 0xA1;
|
||||
static constexpr uint8_t CMD_QUERY_ZONE = 0xC1;
|
||||
static constexpr uint8_t CMD_SET_ZONE = 0xC2;
|
||||
// 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;
|
||||
// Data Header & Footer
|
||||
static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xAA, 0xFF, 0x03, 0x00};
|
||||
static constexpr uint8_t DATA_FRAME_FOOTER[2] = {0x55, 0xCC};
|
||||
// Successful zone query ACK: header (4) + length (2) + command/status (2) + result (2) + zone type (2) +
|
||||
// 3 zones * 8 + footer (4). Zone coordinates may contain the command footer bytes.
|
||||
// 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};
|
||||
|
||||
@@ -164,22 +165,17 @@ static inline int16_t decode_speed(uint8_t low_byte, uint8_t high_byte) {
|
||||
return speed * 10; // mm/s
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Drop the frame at the start of the buffer, keeping bytes from the next frame header (or a partial
|
||||
// header at the end). Returns the new buffer length.
|
||||
static inline uint8_t drop_to_next_header(uint8_t *buffer, uint8_t len) {
|
||||
for (uint8_t start = 1; start < len; start++) {
|
||||
const size_t n = std::min<size_t>(HEADER_FOOTER_SIZE, len - start);
|
||||
if (std::memcmp(&buffer[start], CMD_FRAME_HEADER, n) == 0 ||
|
||||
std::memcmp(&buffer[start], DATA_FRAME_HEADER, n) == 0) {
|
||||
std::memmove(buffer, &buffer[start], len - start);
|
||||
return len - start;
|
||||
}
|
||||
// Length of the frame that opens the buffer: periodic frames are fixed size, command frames carry their
|
||||
// payload length after the header. readline_ only buffers a valid header, so its first byte names it.
|
||||
// Returns 0 while the length field has not arrived yet.
|
||||
static inline uint32_t frame_length(const uint8_t *buffer, uint8_t len) {
|
||||
if (len < HEADER_FOOTER_SIZE) {
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
if (buffer[0] == DATA_FRAME_HEADER[0]) {
|
||||
return PERIODIC_FRAME_SIZE;
|
||||
}
|
||||
return ld24xx::cmd_frame_length(buffer, len);
|
||||
}
|
||||
|
||||
void LD2450Component::setup() {
|
||||
@@ -457,17 +453,6 @@ void LD2450Component::send_command_(uint8_t command, const uint8_t *command_valu
|
||||
// [AA FF 03 00] [0E 03 B1 86 10 00 40 01] [00 00 00 00 00 00 00 00] [00 00 00 00 00 00 00 00] [55 CC]
|
||||
// Header Target 1 Target 2 Target 3 End
|
||||
void LD2450Component::handle_periodic_data_() {
|
||||
if (this->buffer_pos_ < 29) { // header (4 bytes) + 8 x 3 target data + footer (2 bytes)
|
||||
ESP_LOGE(TAG, "Invalid length");
|
||||
return;
|
||||
}
|
||||
if (!ld2450::validate_header_footer(DATA_FRAME_HEADER, this->buffer_data_) ||
|
||||
this->buffer_data_[this->buffer_pos_ - 2] != DATA_FRAME_FOOTER[0] ||
|
||||
this->buffer_data_[this->buffer_pos_ - 1] != DATA_FRAME_FOOTER[1]) {
|
||||
ESP_LOGE(TAG, "Invalid header/footer");
|
||||
return;
|
||||
}
|
||||
|
||||
int16_t target_count = 0;
|
||||
int16_t still_target_count = 0;
|
||||
int16_t moving_target_count = 0;
|
||||
@@ -655,24 +640,15 @@ void LD2450Component::handle_periodic_data_() {
|
||||
this->data_callback_.call();
|
||||
}
|
||||
|
||||
bool LD2450Component::handle_ack_data_() {
|
||||
void LD2450Component::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 (!ld2450::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]) {
|
||||
@@ -695,6 +671,9 @@ bool LD2450Component::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);
|
||||
@@ -709,7 +688,7 @@ bool LD2450Component::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;
|
||||
@@ -756,6 +735,9 @@ bool LD2450Component::handle_ack_data_() {
|
||||
break;
|
||||
|
||||
case CMD_QUERY_TARGET_MODE:
|
||||
if (this->buffer_pos_ < 11 + HEADER_FOOTER_SIZE) {
|
||||
break; // too short to hold the mode
|
||||
}
|
||||
ESP_LOGV(TAG, "Query target tracking mode");
|
||||
#ifdef USE_SWITCH
|
||||
if (this->multi_target_switch_ != nullptr) {
|
||||
@@ -766,7 +748,7 @@ bool LD2450Component::handle_ack_data_() {
|
||||
|
||||
case CMD_QUERY_ZONE:
|
||||
if (this->buffer_pos_ < ZONE_ACK_FRAME_SIZE) {
|
||||
return false; // command footer bytes inside the coordinates; keep reading
|
||||
break; // too short to hold the zones
|
||||
}
|
||||
ESP_LOGV(TAG, "Query zone conf");
|
||||
this->zone_type_ = this->buffer_data_[10];
|
||||
@@ -797,7 +779,6 @@ bool LD2450Component::handle_ack_data_() {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read LD2450 buffer data
|
||||
@@ -805,46 +786,30 @@ void LD2450Component::readline_(int readch) {
|
||||
if (readch < 0) {
|
||||
return; // No data available
|
||||
}
|
||||
ld24xx::feed_frame_byte(
|
||||
this->buffer_data_, this->buffer_pos_, readch, DATA_FRAME_HEADER, ld2450::frame_length,
|
||||
[this](uint8_t frame_len) { return this->handle_frame_(frame_len); },
|
||||
[]() { ESP_LOGW(TAG, "Invalid header/footer"); });
|
||||
}
|
||||
|
||||
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 (this->buffer_data_[this->buffer_pos_ - 2] == DATA_FRAME_FOOTER[0] &&
|
||||
this->buffer_data_[this->buffer_pos_ - 1] == DATA_FRAME_FOOTER[1]) {
|
||||
// Dispatches the frame of frame_len bytes at the start of the buffer when its footer is in place
|
||||
bool LD2450Component::handle_frame_(uint8_t frame_len) {
|
||||
this->buffer_pos_ = frame_len; // the handlers take the frame length from here
|
||||
const uint8_t *end = &this->buffer_data_[frame_len];
|
||||
if (this->buffer_data_[0] == DATA_FRAME_HEADER[0]) {
|
||||
if (end[-2] != DATA_FRAME_FOOTER[0] || end[-1] != DATA_FRAME_FOOTER[1]) {
|
||||
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 frame
|
||||
} else if (ld2450::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");
|
||||
}
|
||||
} else if (this->buffer_pos_ == ZONE_ACK_FRAME_SIZE && this->buffer_data_[COMMAND] == CMD_QUERY_ZONE &&
|
||||
ld2450::validate_header_footer(CMD_FRAME_HEADER, this->buffer_data_)) {
|
||||
ESP_LOGW(TAG, "Truncated zone ACK");
|
||||
// Replay the retained bytes so frames already complete inside them are dispatched.
|
||||
// At most ZONE_ACK_FRAME_SIZE - 1 bytes are replayed from position 0, so neither this
|
||||
// branch nor the overflow path can be re-entered during the replay.
|
||||
uint8_t pending[ZONE_ACK_FRAME_SIZE];
|
||||
const uint8_t pending_len = ld2450::drop_to_next_header(this->buffer_data_, this->buffer_pos_);
|
||||
std::memcpy(pending, this->buffer_data_, pending_len);
|
||||
this->buffer_pos_ = 0;
|
||||
this->buffer_data_[0] = 0;
|
||||
for (uint8_t i = 0; i < pending_len; i++) {
|
||||
this->readline_(pending[i]); // NOLINT(misc-no-recursion)
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!ld2450::validate_header_footer(CMD_FRAME_FOOTER, end - HEADER_FOOTER_SIZE)) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Set Config Mode - Pre-requisite sending commands
|
||||
|
||||
@@ -157,7 +157,8 @@ class LD2450Component : 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 process_zone_();
|
||||
void readline_(int readch);
|
||||
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
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include "esphome/components/ld2450/ld2450.h"
|
||||
#include "esphome/components/uart/uart_component.h"
|
||||
#ifdef USE_SWITCH
|
||||
#include "esphome/components/switch/switch.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::ld2450::testing {
|
||||
|
||||
@@ -29,6 +32,7 @@ class TestableLD2450 : public LD2450Component {
|
||||
using LD2450Component::buffer_data_;
|
||||
using LD2450Component::buffer_pos_;
|
||||
using LD2450Component::readline_;
|
||||
using LD2450Component::version_;
|
||||
using LD2450Component::zone_config_;
|
||||
|
||||
void feed(const std::vector<uint8_t> &data) {
|
||||
@@ -62,4 +66,11 @@ inline std::vector<uint8_t> make_ack_frame() {
|
||||
};
|
||||
}
|
||||
|
||||
#ifdef USE_SWITCH
|
||||
class TestSwitch : public switch_::Switch {
|
||||
protected:
|
||||
void write_state(bool state) override {}
|
||||
};
|
||||
#endif
|
||||
|
||||
} // namespace esphome::ld2450::testing
|
||||
@@ -51,11 +51,10 @@ TEST_F(LD2450ReadlineTest, BackToBackMixedFrames) {
|
||||
// --- Garbage then valid frame tests ---
|
||||
|
||||
TEST_F(LD2450ReadlineTest, GarbageThenValidFrame) {
|
||||
// Garbage bytes accumulate in the buffer but don't match any footer.
|
||||
// A valid frame follows; its footer resets the buffer and resyncs.
|
||||
// Garbage bytes never open the buffer; a valid frame that follows is parsed as usual.
|
||||
std::vector<uint8_t> garbage = {0x01, 0x02, 0x03, 0x42, 0x99};
|
||||
this->ld2450_.feed(garbage);
|
||||
EXPECT_GT(this->ld2450_.buffer_pos_, 0); // Garbage accumulated
|
||||
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
|
||||
|
||||
auto frame = make_periodic_frame();
|
||||
this->ld2450_.feed(frame);
|
||||
@@ -142,4 +141,70 @@ TEST_F(LD2450ReadlineTest, SimulatedRestartThenFrames) {
|
||||
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
|
||||
}
|
||||
|
||||
// --- Frame payloads that look like footers ---
|
||||
|
||||
TEST_F(LD2450ReadlineTest, CmdFooterBytesInPeriodicPayloadDoNotEndTheFrame) {
|
||||
int frames = 0;
|
||||
this->ld2450_.add_on_data_callback([&frames]() { frames++; });
|
||||
// Target 1 at x 772 and y 258: its first four bytes are the command footer 04 03 02 01
|
||||
auto frame = make_periodic_frame();
|
||||
frame[4] = 0x04;
|
||||
frame[5] = 0x03;
|
||||
frame[6] = 0x02;
|
||||
frame[7] = 0x01;
|
||||
this->ld2450_.feed(frame);
|
||||
EXPECT_EQ(frames, 1);
|
||||
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
|
||||
|
||||
// The parser is still in step: the next frames are handled as usual
|
||||
this->ld2450_.feed(make_ack_frame());
|
||||
this->ld2450_.feed(make_periodic_frame());
|
||||
EXPECT_EQ(frames, 2);
|
||||
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
|
||||
}
|
||||
|
||||
TEST_F(LD2450ReadlineTest, BogusLengthFieldResyncsOnTheNextHeader) {
|
||||
int frames = 0;
|
||||
this->ld2450_.add_on_data_callback([&frames]() { frames++; });
|
||||
// A command header claiming a 255 byte payload, then a complete periodic frame
|
||||
std::vector<uint8_t> stream = {0xFD, 0xFC, 0xFB, 0xFA, 0xFF, 0x00};
|
||||
auto frame = make_periodic_frame();
|
||||
stream.insert(stream.end(), frame.begin(), frame.end());
|
||||
this->ld2450_.feed(stream);
|
||||
EXPECT_EQ(frames, 1);
|
||||
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
|
||||
}
|
||||
|
||||
TEST_F(LD2450ReadlineTest, LargestLengthFieldDoesNotWrapToIncomplete) {
|
||||
int frames = 0;
|
||||
this->ld2450_.add_on_data_callback([&frames]() { frames++; });
|
||||
// A 0xFFF6 byte payload makes the frame 65536 bytes long, which must not wrap around to 0
|
||||
std::vector<uint8_t> stream = {0xFD, 0xFC, 0xFB, 0xFA, 0xF6, 0xFF};
|
||||
stream.insert(stream.end(), MAX_LINE_LENGTH, 0x11);
|
||||
auto frame = make_periodic_frame();
|
||||
stream.insert(stream.end(), frame.begin(), frame.end());
|
||||
this->ld2450_.feed(stream);
|
||||
EXPECT_EQ(frames, 1);
|
||||
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
|
||||
}
|
||||
|
||||
TEST_F(LD2450ReadlineTest, MixedHeaderPrefixNeverOpensAFrame) {
|
||||
int frames = 0;
|
||||
this->ld2450_.add_on_data_callback([&frames]() { frames++; });
|
||||
// A stray command header byte followed by a periodic header that lost its first byte: the stray byte
|
||||
// picks the command header, so the periodic bytes do not continue it and the buffer restarts on AA
|
||||
std::vector<uint8_t> stream = {0xFD, 0xFF, 0x03, 0x00};
|
||||
this->ld2450_.feed(stream);
|
||||
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
|
||||
|
||||
// The reverse mix is rejected the same way
|
||||
this->ld2450_.feed({0xAA, 0xFC, 0xFB, 0xFA});
|
||||
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
|
||||
|
||||
// A real frame right after is still parsed
|
||||
this->ld2450_.feed(make_periodic_frame());
|
||||
EXPECT_EQ(frames, 1);
|
||||
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
|
||||
}
|
||||
|
||||
} // namespace esphome::ld2450::testing
|
||||
@@ -0,0 +1,69 @@
|
||||
#include "common.h"
|
||||
|
||||
namespace esphome::ld2450::testing {
|
||||
|
||||
namespace {
|
||||
// Version answer: command A0 01, status, firmware type, then the six version bytes.
|
||||
constexpr uint8_t VERSION_BYTES[] = {0x07, 0x02, 0x16, 0x24, 0x06, 0x22};
|
||||
|
||||
std::vector<uint8_t> make_version_ack() {
|
||||
std::vector<uint8_t> frame = {0xFD, 0xFC, 0xFB, 0xFA, 0x0C, 0x00, 0xA0, 0x01, 0x00, 0x00, 0x00, 0x01};
|
||||
frame.insert(frame.end(), std::begin(VERSION_BYTES), std::end(VERSION_BYTES));
|
||||
frame.insert(frame.end(), {0x04, 0x03, 0x02, 0x01});
|
||||
return frame;
|
||||
}
|
||||
|
||||
// A version answer cut after two version bytes: the footer sits where the version would be.
|
||||
std::vector<uint8_t> make_short_version_ack() {
|
||||
return {0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xA0, 0x01, 0x00, 0x00, 0x00, 0x01, 0x07, 0x02, 0x04, 0x03, 0x02, 0x01};
|
||||
}
|
||||
|
||||
// Target mode answer: command 91 01, status, then the two mode bytes.
|
||||
std::vector<uint8_t> make_target_mode_ack(uint8_t mode) {
|
||||
return {0xFD, 0xFC, 0xFB, 0xFA, 0x06, 0x00, 0x91, 0x01, 0x00, 0x00, mode, 0x00, 0x04, 0x03, 0x02, 0x01};
|
||||
}
|
||||
|
||||
// A target mode answer with no mode bytes: the footer sits where the mode would be.
|
||||
std::vector<uint8_t> make_short_target_mode_ack() {
|
||||
return {0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x91, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
class LD2450ShortAckTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override { this->ld2450_.set_uart_parent(&this->mock_uart_); }
|
||||
|
||||
MockUARTComponent mock_uart_;
|
||||
TestableLD2450 ld2450_;
|
||||
};
|
||||
|
||||
TEST_F(LD2450ShortAckTest, ShortVersionAckDoesNotChangeTheVersion) {
|
||||
this->ld2450_.feed(make_short_version_ack());
|
||||
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
|
||||
for (uint8_t byte : this->ld2450_.version_) {
|
||||
EXPECT_EQ(byte, 0);
|
||||
}
|
||||
|
||||
this->ld2450_.feed(make_version_ack());
|
||||
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
|
||||
EXPECT_EQ(std::memcmp(this->ld2450_.version_, VERSION_BYTES, sizeof(VERSION_BYTES)), 0);
|
||||
}
|
||||
|
||||
#ifdef USE_SWITCH
|
||||
TEST_F(LD2450ShortAckTest, ShortTargetModeAckDoesNotChangeTheSwitch) {
|
||||
TestSwitch multi_target;
|
||||
this->ld2450_.set_multi_target_switch(&multi_target);
|
||||
multi_target.publish_state(true);
|
||||
|
||||
this->ld2450_.feed(make_short_target_mode_ack());
|
||||
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
|
||||
EXPECT_TRUE(multi_target.state);
|
||||
|
||||
this->ld2450_.feed(make_target_mode_ack(0x01));
|
||||
EXPECT_FALSE(multi_target.state);
|
||||
this->ld2450_.feed(make_target_mode_ack(0x02));
|
||||
EXPECT_TRUE(multi_target.state);
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::ld2450::testing
|
||||
@@ -177,4 +177,11 @@ TEST_F(LD2450ZoneAckTest, MultipleCompleteFramesInRetainedSuffixAreDrained) {
|
||||
expect_zone(this->ld2450_, 0, 100, 200, 1500, 3000);
|
||||
}
|
||||
|
||||
TEST_F(LD2450ZoneAckTest, PeriodicFooterBytesInZonePayloadDoNotEndTheAck) {
|
||||
// Zone 0 x1 is -13227 (0xCC55 little endian): its bytes 55 CC are the periodic frame footer
|
||||
this->ld2450_.feed(make_zone_ack({0x55, 0xCC, 0xC8, 0x00, 0xDC, 0x05, 0xB8, 0x0B}));
|
||||
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
|
||||
expect_zone(this->ld2450_, 0, -13227, 200, 1500, 3000);
|
||||
}
|
||||
|
||||
} // namespace esphome::ld2450::testing
|
||||
@@ -0,0 +1,12 @@
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# Turns on USE_SWITCH so the hub's switch publishing code is in the host test build
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
CORE.testing_ensure_platform_registered("switch")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
manifest.dependencies = manifest.dependencies + ["switch"]
|
||||
@@ -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
|
||||
@@ -83,28 +83,19 @@ uart_mock:
|
||||
]
|
||||
|
||||
# Phase 2 (t=300ms): Garbage bytes
|
||||
# LD2450's readline_ does NOT reject bytes at position 0 (unlike LD2412),
|
||||
# so these bytes accumulate in the buffer. buffer_pos_ goes from 0 to 7.
|
||||
# None of them can start a frame header, so the parser drops them all.
|
||||
- delay: 200ms
|
||||
inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22]
|
||||
|
||||
# Phase 3 (t=400ms): Truncated frame (header + partial data, no footer)
|
||||
# More bytes accumulating in the buffer without a footer match.
|
||||
# After this, buffer_pos_ = 7 + 8 = 15.
|
||||
# The header opens the buffer; the parser now waits for the 30 bytes of a periodic frame.
|
||||
- delay: 100ms
|
||||
inject_rx: [0xAA, 0xFF, 0x03, 0x00, 0x01, 0x02, 0x03, 0x04]
|
||||
|
||||
# Phase 4 (t=600ms): Overflow - inject 75 bytes of 0xFF (MAX_LINE_LENGTH=45)
|
||||
# Buffer has 15 bytes from phases 2+3.
|
||||
# readline_() stores bytes while buffer_pos_ < 44. When buffer_pos_ == 44,
|
||||
# the next byte triggers overflow: logs warning, resets buffer_pos_ to 0,
|
||||
# and discards that byte.
|
||||
#
|
||||
# First overflow: 29 bytes fill positions 15-43 (buffer_pos_=44), byte 30
|
||||
# triggers overflow (discarded). Total consumed: 30 bytes.
|
||||
# Second overflow: 44 bytes fill positions 0-43 (buffer_pos_=44), byte 45
|
||||
# triggers overflow (discarded). Total consumed: 30+45 = 75 bytes.
|
||||
# After both overflows, buffer_pos_ = 0 (clean state for recovery frame).
|
||||
# Phase 4 (t=600ms): 75 bytes of 0xFF
|
||||
# The first 22 complete the truncated frame's 30 bytes without its footer, so it is
|
||||
# dropped with an 'Invalid header/footer' warning; the rest cannot start a header and
|
||||
# are dropped too, leaving the buffer empty for the recovery frame.
|
||||
- delay: 200ms
|
||||
inject_rx:
|
||||
[
|
||||
@@ -118,8 +109,8 @@ uart_mock:
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
]
|
||||
|
||||
# Phase 5 (t=700ms): Valid frame after overflow - recovery test
|
||||
# Buffer was reset by overflow. This valid frame should parse correctly.
|
||||
# Phase 5 (t=700ms): Valid frame after the garbage - recovery test
|
||||
# The buffer is empty again, so this valid frame should parse correctly.
|
||||
#
|
||||
# Target 1: X=300mm, Y=400mm, Speed=30mm/s (moving away), Res=100mm
|
||||
# X: magnitude=300 (0x012C), positive → high=0x81, low=0x2C
|
||||
|
||||
@@ -10,8 +10,8 @@ test_uart_mock_ld2450:
|
||||
6. Direction text sensor - "Approaching" for negative speed target
|
||||
7. Garbage resilience - random bytes don't crash the component
|
||||
8. Truncated frame handling - partial frame doesn't corrupt state
|
||||
9. Buffer overflow recovery - overflow resets the parser
|
||||
10. Post-overflow parsing - next valid frame after overflow is parsed correctly
|
||||
9. Truncated frame recovery - the frame is dropped once its length is reached
|
||||
10. Post-garbage parsing - the next valid frame is parsed correctly
|
||||
11. TX logging - verifies LD2450 sends expected setup commands
|
||||
"""
|
||||
|
||||
@@ -32,7 +32,7 @@ async def test_uart_mock_ld2450(
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test LD2450 data parsing with happy path, garbage, overflow, and recovery."""
|
||||
"""Test LD2450 data parsing with happy path, garbage, a truncated frame, and recovery."""
|
||||
waiter = LineWaiter()
|
||||
|
||||
collector = SensorStateCollector(
|
||||
@@ -133,7 +133,7 @@ async def test_uart_mock_ld2450(
|
||||
assert collector.text_sensor_states["target_1_direction"][0] == "Approaching"
|
||||
|
||||
# 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,
|
||||
5.0,
|
||||
@@ -141,9 +141,9 @@ async def test_uart_mock_ld2450(
|
||||
sensor_states=collector.sensor_states,
|
||||
)
|
||||
|
||||
# Verify overflow warning was logged
|
||||
assert any("Max command length exceeded" in line for line in waiter.lines), (
|
||||
"Expected 'Max command length exceeded' warning in logs"
|
||||
# Verify the truncated frame was dropped with a warning
|
||||
assert any("Invalid header/footer" in line for line in waiter.lines), (
|
||||
"Expected 'Invalid header/footer' warning in logs"
|
||||
)
|
||||
|
||||
# Verify LD2450 sent setup commands (TX logging)
|
||||
@@ -161,7 +161,7 @@ async def test_uart_mock_ld2450(
|
||||
"Expected LD2450 command frame footer 04:03:02:01 in TX log"
|
||||
)
|
||||
|
||||
# Recovery frame values (Phase 5, after overflow):
|
||||
# Recovery frame values (Phase 5, after the garbage):
|
||||
# Target 1: X=300, Y=400, Distance=500, Speed=30 (moving away)
|
||||
# target_count=1, moving=1, still=0
|
||||
#
|
||||
|
||||
Reference in new issue
Block a user