diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index d2a0e04d122..cdf542ee8b8 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -10,6 +10,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include #include #include #include @@ -131,6 +132,9 @@ static constexpr uint8_t CMD_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0x04, 0x03, 0x0 // 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. +static constexpr uint8_t ZONE_ACK_FRAME_SIZE = 40; // MAC address the module uses when Bluetooth is disabled static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01}; @@ -173,6 +177,20 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui 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(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; + } + } + return 0; +} + void LD2450Component::setup() { #ifdef USE_NUMBER if (this->presence_timeout_number_ != nullptr) { @@ -756,6 +774,9 @@ bool LD2450Component::handle_ack_data_() { break; case CMD_QUERY_ZONE: + if (this->buffer_pos_ < ZONE_ACK_FRAME_SIZE) { + return false; // command footer bytes inside the coordinates; keep reading + } ESP_LOGV(TAG, "Query zone conf"); this->zone_type_ = this->buffer_data_[10]; this->publish_zone_type(); @@ -824,6 +845,20 @@ void LD2450Component::readline_(int readch) { } 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) + } } } diff --git a/tests/components/ld2450/common.h b/tests/components/ld2450/common.h index de912ddcbca..95b0a65de8e 100644 --- a/tests/components/ld2450/common.h +++ b/tests/components/ld2450/common.h @@ -29,6 +29,7 @@ class TestableLD2450 : public LD2450Component { using LD2450Component::buffer_data_; using LD2450Component::buffer_pos_; using LD2450Component::readline_; + using LD2450Component::zone_config_; void feed(const std::vector &data) { for (uint8_t byte : data) { diff --git a/tests/components/ld2450/ld2450_zone_ack.cpp b/tests/components/ld2450/ld2450_zone_ack.cpp new file mode 100644 index 00000000000..5f6674709d9 --- /dev/null +++ b/tests/components/ld2450/ld2450_zone_ack.cpp @@ -0,0 +1,180 @@ +#include +#include "common.h" + +namespace esphome::ld2450::testing { + +namespace { +// Successful zone query ACK (40 bytes), zone type 0x01, with the given coordinate bytes; missing ones are zero. +std::vector make_zone_ack(std::vector coords) { + coords.resize(24, 0); + std::vector frame = {0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x01, 0x00}; + frame.insert(frame.end(), coords.begin(), coords.end()); + frame.insert(frame.end(), {0x04, 0x03, 0x02, 0x01}); + return frame; +} + +// Zone ACK with zone 0 at (100, 200) to (1500, 3000) and the other zones cleared. +std::vector make_complete_zone_ack() { + return make_zone_ack({0x64, 0x00, 0xC8, 0x00, 0xDC, 0x05, 0xB8, 0x0B}); +} + +// The first 16 bytes of a zone ACK whose zone 0 starts at (772, 258): its last four bytes look like a footer. +std::vector make_truncated_zone_ack() { + return {0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01}; +} + +// Successful disable-configuration ACK: a four-byte body. +std::vector make_disable_conf_ack() { + auto frame = make_ack_frame(); + frame[6] = 0xFE; + return frame; +} + +void expect_zone(const TestableLD2450 &ld2450, size_t index, int x1, int y1, int x2, int y2) { + EXPECT_EQ(ld2450.zone_config_[index].x1, x1) << "zone " << index; + EXPECT_EQ(ld2450.zone_config_[index].y1, y1) << "zone " << index; + EXPECT_EQ(ld2450.zone_config_[index].x2, x2) << "zone " << index; + EXPECT_EQ(ld2450.zone_config_[index].y2, y2) << "zone " << index; +} +} // namespace + +class LD2450ZoneAckTest : public ::testing::Test { + protected: + void SetUp() override { this->ld2450_.set_uart_parent(&this->mock_uart_); } + void set_all_zones_(int16_t x1, int16_t y1, int16_t x2, int16_t y2) { + for (auto &zone : this->ld2450_.zone_config_) { + zone = {x1, y1, x2, y2}; + } + } + void expect_all_zones_(int x1, int y1, int x2, int y2) { + for (size_t i = 0; i < MAX_ZONES; i++) { + expect_zone(this->ld2450_, i, x1, y1, x2, y2); + } + } + + MockUARTComponent mock_uart_; + TestableLD2450 ld2450_; +}; + +TEST_F(LD2450ZoneAckTest, CmdFooterBytesInZonePayloadDoNotTruncateAck) { + // x1=772 and y1=258 are the command footer bytes, followed by x2=1500 and y2=3000. + static constexpr uint8_t COORDINATES[] = {0x04, 0x03, 0x02, 0x01, 0xDC, 0x05, 0xB8, 0x0B}; + for (uint8_t zone = 0; zone < MAX_ZONES; zone++) { + std::vector coords(24, 0); + std::memcpy(coords.data() + zone * 8, COORDINATES, sizeof(COORDINATES)); + const auto frame = make_zone_ack(coords); + std::memset(this->ld2450_.buffer_data_, 0xEE, MAX_LINE_LENGTH); + + const size_t split = 12 + zone * 8 + 4; + this->ld2450_.feed(std::vector(frame.begin(), frame.begin() + split)); + EXPECT_EQ(this->ld2450_.buffer_pos_, split) << "zone " << static_cast(zone); + this->ld2450_.feed(std::vector(frame.begin() + split, frame.end())); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + for (uint8_t i = 0; i < MAX_ZONES; i++) { + if (i == zone) { + expect_zone(this->ld2450_, i, 772, 258, 1500, 3000); + } else { + expect_zone(this->ld2450_, i, 0, 0, 0, 0); + } + } + } +} + +TEST_F(LD2450ZoneAckTest, CompleteZoneAckWithoutEmbeddedFooter) { + this->set_all_zones_(100, 200, 300, 400); + this->ld2450_.feed(make_zone_ack({})); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + this->expect_all_zones_(0, 0, 0, 0); +} + +TEST_F(LD2450ZoneAckTest, FailedZoneQueryAckDoesNotWaitForCoordinates) { + this->set_all_zones_(100, 200, 300, 400); + auto frame = make_ack_frame(); + frame[6] = 0xC1; + frame[8] = 0x01; + this->ld2450_.feed(frame); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + this->expect_all_zones_(100, 200, 300, 400); +} + +TEST_F(LD2450ZoneAckTest, ValidZoneAckWithEmbeddedFooterAndHeaderIsNotResynced) { + const auto frame = make_zone_ack({0x04, 0x03, 0x02, 0x01, 0xFD, 0xFC, 0xFB, 0xFA, // 772, 258, -771, -1285 + 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x01, 0x00, // 30, 449, 0, 1 + 0x64, 0x00, 0xC8, 0x00, 0xDC, 0x05, 0xB8, 0x0B}); // 100, 200, 1500, 3000 + // The first 36 bytes equal a truncated ACK followed by a complete one; only the footer at offset 36 differs. + auto stream = make_truncated_zone_ack(); + const auto complete = make_complete_zone_ack(); + stream.insert(stream.end(), complete.begin(), complete.end()); + ASSERT_TRUE(std::equal(frame.begin(), frame.begin() + 36, stream.begin())); + + this->ld2450_.feed(frame); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 772, 258, -771, -1285); + expect_zone(this->ld2450_, 1, 30, 449, 0, 1); + expect_zone(this->ld2450_, 2, 100, 200, 1500, 3000); +} + +TEST_F(LD2450ZoneAckTest, TruncatedZoneAckThenCompleteZoneAckRecovers) { + this->ld2450_.feed(make_truncated_zone_ack()); + this->ld2450_.feed(make_complete_zone_ack()); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 1500, 3000); +} + +TEST_F(LD2450ZoneAckTest, TruncatedZoneAckThenPeriodicFrameIsParsed) { + this->ld2450_.zone_config_[0] = {100, 200, 300, 400}; + int frames = 0; + this->ld2450_.add_on_data_callback([&frames]() { frames++; }); + + this->ld2450_.feed(make_truncated_zone_ack()); + this->ld2450_.feed(make_periodic_frame()); + + // The truncated ACK is not decoded; the periodic frame is recovered through the data header. + EXPECT_EQ(frames, 1); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 300, 400); +} + +TEST_F(LD2450ZoneAckTest, TruncatedZoneAckThenShortAckThenCompleteZoneAckRecovers) { + this->ld2450_.feed(make_truncated_zone_ack()); + this->ld2450_.feed(make_disable_conf_ack()); + this->ld2450_.feed(make_complete_zone_ack()); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 1500, 3000); +} + +TEST_F(LD2450ZoneAckTest, ShortAckInRetainedSuffixIsDispatchedOnResync) { + const auto complete = make_complete_zone_ack(); + this->ld2450_.feed(make_truncated_zone_ack()); + this->ld2450_.feed(make_disable_conf_ack()); + // Ten bytes of the next zone ACK bring the buffer to 40 bytes without a footer. + this->ld2450_.feed(std::vector(complete.begin(), complete.begin() + 10)); + // The short ACK was replayed and consumed; only the new zone ACK prefix remains. + EXPECT_EQ(this->ld2450_.buffer_pos_, 10); + EXPECT_EQ(this->ld2450_.buffer_data_[6], 0xC1); + this->ld2450_.feed(std::vector(complete.begin() + 10, complete.end())); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 1500, 3000); +} + +TEST_F(LD2450ZoneAckTest, MultipleCompleteFramesInRetainedSuffixAreDrained) { + // Truncated zone ACK without any footer, followed by two complete short ACKs (11 + 14 + 14 = 39). + const auto truncated = make_truncated_zone_ack(); + std::vector stream(truncated.begin(), truncated.begin() + 11); + const auto disable_ack = make_disable_conf_ack(); + stream.insert(stream.end(), disable_ack.begin(), disable_ack.end()); + const auto enable_ack = make_ack_frame(); + stream.insert(stream.end(), enable_ack.begin(), enable_ack.end()); + this->ld2450_.feed(stream); + EXPECT_EQ(this->ld2450_.buffer_pos_, 39); // the C1 header still claims the buffer + // The 40th byte triggers the resync; both buffered ACKs are drained, leaving only this byte. + const auto complete = make_complete_zone_ack(); + this->ld2450_.feed({complete[0]}); + EXPECT_EQ(this->ld2450_.buffer_pos_, 1); + EXPECT_EQ(this->ld2450_.buffer_data_[0], 0xFD); + this->ld2450_.feed(std::vector(complete.begin() + 1, complete.end())); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 1500, 3000); +} + +} // namespace esphome::ld2450::testing