From 753c10e5eace236ad573f0c1b668277a77ecae61 Mon Sep 17 00:00:00 2001 From: Ivan Novak <37712440+NovakIrs@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:06:45 +0200 Subject: [PATCH] [ld2410s] Add the distance sensor, firmware version and common parameters (Part 2) (#8486) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston --- esphome/components/ld2410s/__init__.py | 2 + esphome/components/ld2410s/ld2410s.cpp | 309 +++++++++++++++--- esphome/components/ld2410s/ld2410s.h | 84 ++++- esphome/components/ld2410s/ld2410s_number.h | 32 ++ esphome/components/ld2410s/ld2410s_select.h | 21 ++ esphome/components/ld2410s/number.py | 108 ++++++ esphome/components/ld2410s/select.py | 31 ++ esphome/components/ld2410s/sensor.py | 32 ++ esphome/components/ld2410s/text_sensor.py | 23 ++ esphome/core/defines.h | 2 + tests/components/ld2410s/common-bare.yaml | 28 ++ tests/components/ld2410s/common.yaml | 28 ++ .../ld2410s/test-bare.esp8266-ard.yaml | 3 + .../fixtures/uart_mock_ld2410s.yaml | 62 ++++ .../fixtures/uart_mock_ld2410s_link_lost.yaml | 19 ++ tests/integration/test_uart_mock_ld2410s.py | 81 ++++- 16 files changed, 808 insertions(+), 57 deletions(-) create mode 100644 esphome/components/ld2410s/ld2410s_number.h create mode 100644 esphome/components/ld2410s/ld2410s_select.h create mode 100644 esphome/components/ld2410s/number.py create mode 100644 esphome/components/ld2410s/select.py create mode 100644 esphome/components/ld2410s/sensor.py create mode 100644 esphome/components/ld2410s/text_sensor.py create mode 100644 tests/components/ld2410s/common-bare.yaml create mode 100644 tests/components/ld2410s/test-bare.esp8266-ard.yaml diff --git a/esphome/components/ld2410s/__init__.py b/esphome/components/ld2410s/__init__.py index 1c39c7eab3..ecc90455d1 100644 --- a/esphome/components/ld2410s/__init__.py +++ b/esphome/components/ld2410s/__init__.py @@ -4,6 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType +AUTO_LOAD = ["ld24xx"] CODEOWNERS = ["@NovakIrs"] DEPENDENCIES = ["uart"] DOMAIN = "ld2410s" @@ -13,6 +14,7 @@ CONF_LD2410S_ID = "ld2410s_id" ld2410s_ns = cg.esphome_ns.namespace(DOMAIN) LD2410SComponent = ld2410s_ns.class_("LD2410SComponent", cg.Component, uart.UARTDevice) +Parameter = ld2410s_ns.enum("Parameter", is_class=True) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/ld2410s/ld2410s.cpp b/esphome/components/ld2410s/ld2410s.cpp index 6462236ac9..3f71c77a62 100644 --- a/esphome/components/ld2410s/ld2410s.cpp +++ b/esphome/components/ld2410s/ld2410s.cpp @@ -1,4 +1,5 @@ #include "ld2410s.h" +#include "ld2410s_number.h" #include #include #include "esphome/core/application.h" @@ -25,18 +26,46 @@ static constexpr uint16_t CMD_CONFIRMATION = 0x0100; // set in the command word static constexpr uint16_t CONFIG_MODE_START_CMD = 0x00FF; static constexpr uint16_t CONFIG_MODE_START_VALUE = 0x0001; static constexpr uint16_t CONFIG_MODE_END_CMD = 0x00FE; +static constexpr uint16_t VERSION_READ_CMD = 0x0000; +static constexpr uint16_t PARAMETERS_WRITE_CMD = 0x0070; +static constexpr uint16_t PARAMETERS_READ_CMD = 0x0071; static constexpr uint16_t OUTPUT_MODE_SWITCH_CMD = 0x007A; // Minimal output mode (short frames carrying presence and distance only) is selected by six zero bytes static constexpr uint8_t OUTPUT_MODE_MINIMAL_SIZE = 6; -static constexpr uint8_t CMD_FRAME_MAX_SIZE = - LONG_PAYLOAD_POS + 2 + OUTPUT_MODE_MINIMAL_SIZE + sizeof(CMD_FRAME_FOOTER); +// The parameter write carries a 16-bit key and a 32-bit value per parameter, the read only the keys +static constexpr uint8_t PARAMETER_KEY_SIZE = 2; +static constexpr uint8_t PARAMETER_VALUE_SIZE = 4; +static constexpr uint8_t PARAMETERS_WRITE_SIZE = PARAMETER_COUNT * (PARAMETER_KEY_SIZE + PARAMETER_VALUE_SIZE); +static constexpr uint8_t CMD_FRAME_MAX_SIZE = LONG_PAYLOAD_POS + 2 + PARAMETERS_WRITE_SIZE + sizeof(CMD_FRAME_FOOTER); +// Keys of the parameters, in Parameter order +#ifdef USE_LD2410S_PARAMETERS +static constexpr uint16_t PARAMETER_KEYS[] PROGMEM = {0x0005, 0x000A, 0x0006, 0x0002, 0x000C, 0x000B}; +static_assert(sizeof(PARAMETER_KEYS) / sizeof(PARAMETER_KEYS[0]) == PARAMETER_COUNT, "one key per parameter"); +#endif +// The version answer: 4 bytes device type, then major, minor and patch as 16-bit words +static constexpr uint8_t VERSION_TYPE_SIZE = 4; +static constexpr uint8_t VERSION_SIZE = VERSION_TYPE_SIZE + 3 * 2; -// Everything the module needs at start: enter configuration mode, pick the output mode, leave it -static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, OUTPUT_MODE_SWITCH_CMD, - CONFIG_MODE_END_CMD}; +// Everything the module needs at start: configuration mode on, read the version and parameters when +// an entity shows them, pick the output mode, configuration mode off +static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = { + CONFIG_MODE_START_CMD, +#ifdef USE_LD2410S_VERSION + VERSION_READ_CMD, +#endif +#ifdef USE_LD2410S_PARAMETERS + PARAMETERS_READ_CMD, +#endif + OUTPUT_MODE_SWITCH_CMD, CONFIG_MODE_END_CMD, +}; +#ifdef USE_LD2410S_PARAMETERS +static constexpr uint16_t WRITE_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, PARAMETERS_WRITE_CMD, CONFIG_MODE_END_CMD}; +#endif static constexpr uint8_t INIT_SEQUENCE_LENGTH = sizeof(INIT_SEQUENCE) / sizeof(INIT_SEQUENCE[0]); -static uint16_t init_command(uint8_t step) { return progmem_read_uint16(&INIT_SEQUENCE[step]); } -static constexpr uint8_t INIT_MAX_TIMEOUTS = 2; +#ifdef USE_LD2410S_PARAMETERS +static constexpr uint8_t WRITE_SEQUENCE_LENGTH = sizeof(WRITE_SEQUENCE) / sizeof(WRITE_SEQUENCE[0]); +#endif +static constexpr uint8_t STEP_MAX_TIMEOUTS = 2; static constexpr uint32_t ACK_TIMEOUT_MS = 300; static constexpr uint32_t COMMAND_GAP_MS = 300; // pause after an acknowledgement before the next command static constexpr uint32_t REINIT_PAUSE_MS = 15000; // pause before starting over after the module stopped answering @@ -49,6 +78,20 @@ void LD2410SComponent::dump_config() { #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Has Target", this->target_binary_sensor_); #endif +#ifdef USE_SENSOR + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "Distance", this->distance_sensor_); +#endif +#ifdef USE_LD2410S_VERSION + LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_); +#endif +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) + for (auto *number : this->numbers_) { + LOG_NUMBER(" ", "Setting", number); + } +#endif +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) + LOG_SELECT(" ", "Response Speed", this->response_speed_select_); +#endif } void LD2410SComponent::loop() { @@ -67,35 +110,80 @@ void LD2410SComponent::loop() { avail -= len; } const uint32_t now = App.get_loop_component_start_time(); - this->run_init_sequence_(now); - if (this->init_step_ >= INIT_SEQUENCE_LENGTH && now - this->last_frame_at_ > LINK_TIMEOUT_MS) { + if (this->sequence_ == nullptr) { + if (!this->init_done_) { + this->start_sequence_(INIT_SEQUENCE, INIT_SEQUENCE_LENGTH, now); +#ifdef USE_LD2410S_PARAMETERS + } else if (this->parameters_dirty_) { + this->parameters_dirty_ = false; + this->start_sequence_(WRITE_SEQUENCE, WRITE_SEQUENCE_LENGTH, this->next_send_at_); +#endif + } + } + this->run_sequence_(now); + if (this->init_done_ && now - this->last_frame_at_ > LINK_TIMEOUT_MS) { // The count tells real silence apart from frames that never parse (wrong baud rate or firmware) ESP_LOGW(TAG, "No data for %" PRIu32 " s (%u frames dropped), starting over", LINK_TIMEOUT_MS / 1000, this->dropped_frames_); this->dropped_frames_ = 0; - this->status_set_warning(); - this->init_step_ = 0; - this->init_timeouts_ = 0; - this->awaiting_ack_ = false; - this->next_send_at_ = now; + this->restart_init_(now); } } -void LD2410SComponent::run_init_sequence_(uint32_t now) { - if (this->init_step_ >= INIT_SEQUENCE_LENGTH || static_cast(now - this->next_send_at_) < 0) { +#ifdef USE_LD2410S_PARAMETERS +bool LD2410SComponent::set_parameter(Parameter parameter, uint8_t value) { + // All six are written together, so the other five must be the module's own values + if (!this->init_done_) { + ESP_LOGW(TAG, "Parameters not read yet, change ignored"); + return false; + } + if (!this->parameters_known_) { + ESP_LOGW(TAG, "The module reported a parameter out of range, changes are disabled"); + return false; + } + this->parameters_[static_cast(parameter)] = value; + this->parameters_dirty_ = true; // written by loop() once no sequence is running + return true; +} +#endif + +void LD2410SComponent::restart_init_(uint32_t at) { + this->init_done_ = false; + this->status_set_warning(); // cleared again when the new init sequence is acknowledged +#ifdef USE_LD2410S_PARAMETERS + // The module's own values count again once it is set up anew + if (this->parameters_dirty_) { + ESP_LOGW(TAG, "Dropping the pending parameter change, the module's values are read back"); + } + this->parameters_dirty_ = false; + this->parameters_known_ = false; // a refused value may sit in parameters_ until the read replaces it +#endif + this->start_sequence_(INIT_SEQUENCE, INIT_SEQUENCE_LENGTH, at); +} + +void LD2410SComponent::start_sequence_(const uint16_t *sequence, uint8_t length, uint32_t now) { + this->sequence_ = sequence; + this->sequence_len_ = length; + this->step_ = 0; + this->step_timeouts_ = 0; + this->awaiting_ack_ = false; + this->next_send_at_ = now; +} + +uint16_t LD2410SComponent::current_command_() const { return progmem_read_uint16(&this->sequence_[this->step_]); } + +void LD2410SComponent::run_sequence_(uint32_t now) { + if (this->sequence_ == nullptr || static_cast(now - this->next_send_at_) < 0) { return; } - const uint16_t command = init_command(this->init_step_); + const uint16_t command = this->current_command_(); if (this->awaiting_ack_) { - if (++this->init_timeouts_ > INIT_MAX_TIMEOUTS) { + if (++this->step_timeouts_ > STEP_MAX_TIMEOUTS) { ESP_LOGW(TAG, "No acknowledgement for command %04X, starting over in %" PRIu32 " s", command, REINIT_PAUSE_MS / 1000); // Leave configuration mode whatever state the module is in, then begin again this->send_command_(CONFIG_MODE_END_CMD); - this->init_step_ = 0; - this->init_timeouts_ = 0; - this->awaiting_ack_ = false; - this->next_send_at_ = now + REINIT_PAUSE_MS; + this->restart_init_(now + REINIT_PAUSE_MS); return; } ESP_LOGD(TAG, "No acknowledgement for command %04X, resending", command); @@ -120,6 +208,22 @@ void LD2410SComponent::send_command_(uint16_t command) { frame[pos++] = CONFIG_MODE_START_VALUE & 0xFF; frame[pos++] = CONFIG_MODE_START_VALUE >> 8; break; +#ifdef USE_LD2410S_PARAMETERS + case PARAMETERS_READ_CMD: + case PARAMETERS_WRITE_CMD: + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + const uint16_t key = progmem_read_uint16(&PARAMETER_KEYS[i]); + frame[pos++] = key & 0xFF; + frame[pos++] = key >> 8; + if (command == PARAMETERS_WRITE_CMD) { + const uint32_t value = this->parameters_[i]; // a 32-bit field on the wire + for (uint8_t shift = 0; shift < 32; shift += 8) { + frame[pos++] = (value >> shift) & 0xFF; + } + } + } + break; +#endif default: break; } @@ -252,27 +356,27 @@ bool LD2410SComponent::handle_frame_() { } bool LD2410SComponent::handle_data_frame_(const uint8_t *payload, uint16_t len) { - uint8_t state; - if (this->frame_type_ == FrameType::SHORT_DATA) { - // [state][distance low][distance high] - state = payload[0]; - } else if (len >= 4 && payload[0] == DATA_TYPE_STANDARD) { - // [type][state][distance low][distance high]...; the module only sends these before the init - // sequence switches it to minimal output - state = payload[1]; - } else { - if (len >= 1) { - ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]); + // Short frames carry [state][distance low][distance high]; standard frames put a type byte first, and the + // module only sends them before the init sequence switches it to minimal output + const uint8_t *data = payload; + if (this->frame_type_ == FrameType::STD_DATA) { + if (len < 4 || payload[0] != DATA_TYPE_STANDARD) { + if (len >= 1) { + ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]); + } + return true; } - return true; + data = payload + 1; } + const uint8_t state = data[0]; + const uint16_t distance = encode_uint16(data[2], data[1]); // States 0 and 1 mean no target, 2 and 3 a target; anything else is line noise that happened to // carry the header and footer bytes if (state > 3) { ESP_LOGV(TAG, "Dropping frame with impossible state %u", state); return false; } - this->publish_target_(state > 1); + this->publish_target_(state > 1, distance); return true; } @@ -283,32 +387,147 @@ void LD2410SComponent::handle_command_ack_(const uint8_t *payload, uint16_t len) } const uint16_t command_word = encode_uint16(payload[1], payload[0]); const uint16_t ack = encode_uint16(payload[3], payload[2]); - if (!this->awaiting_ack_ || command_word != (init_command(this->init_step_) | CMD_CONFIRMATION)) { + if (!this->awaiting_ack_) { ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word); return; } - if (ack != 0) { - // Leave the command pending so the timeout resends it - ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", init_command(this->init_step_), ack); + const uint16_t command = this->current_command_(); + if (command_word != (command | CMD_CONFIRMATION)) { + ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word); return; } +#ifdef USE_LD2410S_PARAMETERS + if (ack != 0 && command == PARAMETERS_WRITE_CMD) { + // Resending the same values would be rejected again; leave configuration mode and read the + // module's own values back, which republishes them + ESP_LOGW(TAG, "Module rejected the parameters, ack %04X", ack); + this->send_command_(CONFIG_MODE_END_CMD); + this->restart_init_(App.get_loop_component_start_time() + COMMAND_GAP_MS); + return; + } +#endif + if (ack != 0) { + // Leave the command pending so the timeout resends it + ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", command, ack); + return; + } + // A short answer leaves the read pending the same way, so init cannot finish without the values +#ifdef USE_LD2410S_PARAMETERS + if (command == PARAMETERS_READ_CMD && !this->handle_parameters_(&payload[4], len - 4)) + return; +#endif +#ifdef USE_LD2410S_VERSION + if (command == VERSION_READ_CMD && !this->handle_version_(&payload[4], len - 4)) + return; +#endif this->awaiting_ack_ = false; - this->init_timeouts_ = 0; - this->init_step_++; - this->next_send_at_ = App.get_loop_component_start_time() + COMMAND_GAP_MS; - if (this->init_step_ >= INIT_SEQUENCE_LENGTH) { + this->step_timeouts_ = 0; + const uint32_t now = App.get_loop_component_start_time(); + this->next_send_at_ = now + COMMAND_GAP_MS; + if (++this->step_ < this->sequence_len_) { + return; + } + const bool was_init = this->sequence_ == INIT_SEQUENCE; + this->sequence_ = nullptr; + if (was_init) { ESP_LOGD(TAG, "Setup done"); - this->last_frame_at_ = App.get_loop_component_start_time(); + this->init_done_ = true; + this->last_frame_at_ = now; this->status_clear_warning(); } } -void LD2410SComponent::publish_target_(bool target) { +#ifdef USE_LD2410S_PARAMETERS +// The answer lists the parameter values in key order, 32 bits each; returns false when it is cut short +bool LD2410SComponent::handle_parameters_(const uint8_t *values, uint16_t len) { + if (len < PARAMETER_COUNT * PARAMETER_VALUE_SIZE) { + ESP_LOGW(TAG, "Parameter answer too short (%u bytes)", len); + return false; + } + // Every parameter must fit the low byte of its little-endian value; otherwise nothing is stored or + // published and writes stay refused, so a cut-down value is never shown or sent back + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + const uint8_t *v = &values[i * PARAMETER_VALUE_SIZE]; + if ((v[1] | v[2] | v[3]) != 0) { + ESP_LOGW(TAG, "Parameter %u out of range: %" PRIu32 ", changes stay disabled", i, + encode_uint32(v[3], v[2], v[1], v[0])); + this->parameters_known_ = false; +#ifdef USE_NUMBER + // A value a number showed before the module answered is not confirmed + for (auto *number : this->numbers_) { + if (number != nullptr) { + number->publish_state(NAN); + } + } +#endif + return true; + } + } + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + this->parameters_[i] = values[i * PARAMETER_VALUE_SIZE]; + } + this->parameters_known_ = true; + this->publish_parameters_(); + ESP_LOGD(TAG, "Parameters: gates %u to %u, no delay %u s, frequencies %u/%u dHz, response speed %u", + this->parameters_[1], this->parameters_[0], this->parameters_[2], this->parameters_[3], this->parameters_[4], + this->parameters_[5]); + return true; +} +#endif + +#ifdef USE_LD2410S_VERSION +// Returns false when the answer is cut short +bool LD2410SComponent::handle_version_(const uint8_t *values, uint16_t len) { + if (len < VERSION_SIZE) { + ESP_LOGW(TAG, "Version answer too short (%u bytes)", len); + return false; + } + const uint8_t *v = &values[VERSION_TYPE_SIZE]; + char version[18]; + snprintf(version, sizeof(version), "%u.%u.%u", encode_uint16(v[1], v[0]), encode_uint16(v[3], v[2]), + encode_uint16(v[5], v[4])); + ESP_LOGD(TAG, "Firmware %s", version); + if (this->version_text_sensor_ != nullptr) { + this->version_text_sensor_->publish_state(version); + } + return true; +} +#endif + +void LD2410SComponent::publish_target_(bool target, uint16_t distance) { #ifdef USE_BINARY_SENSOR if (this->target_binary_sensor_ != nullptr) { this->target_binary_sensor_->publish_state(target); } #endif +#ifdef USE_SENSOR + // The module keeps reporting the last distance after the target left + SAFE_PUBLISH_SENSOR(this->distance_sensor_, target ? distance : 0); +#endif } +#ifdef USE_LD2410S_PARAMETERS +void LD2410SComponent::publish_parameters_() { +#ifdef USE_NUMBER + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + if (this->numbers_[i] != nullptr) { + this->numbers_[i]->publish_raw(this->parameters_[i]); + } + } +#endif +#ifdef USE_SELECT + if (this->response_speed_select_ != nullptr) { + const uint8_t speed = this->parameters_[static_cast(Parameter::PARAMETER_RESPONSE_SPEED)]; + for (uint8_t i = 0; i < RESPONSE_SPEED_COUNT; i++) { + if (progmem_read_byte(&RESPONSE_SPEEDS[i]) == speed) { + this->response_speed_select_->publish_state(i); + return; + } + } + ESP_LOGV(TAG, "Unknown response speed %u", speed); + } +#endif +} +#endif + } // namespace esphome::ld2410s diff --git a/esphome/components/ld2410s/ld2410s.h b/esphome/components/ld2410s/ld2410s.h index 4af809836c..9a0a256974 100644 --- a/esphome/components/ld2410s/ld2410s.h +++ b/esphome/components/ld2410s/ld2410s.h @@ -2,10 +2,21 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/components/uart/uart.h" #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/ld24xx/ld24xx.h" +#endif +#ifdef USE_LD2410S_VERSION +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) +#include "esphome/components/select/select.h" +#endif namespace esphome::ld2410s { @@ -13,16 +24,55 @@ namespace esphome::ld2410s { static constexpr size_t RX_BUFFER_SIZE = 96; static_assert(RX_BUFFER_SIZE <= 255, "frame lengths are kept in a byte"); +/// The module's common parameters, in the order the parameter read answers them +enum class Parameter : uint8_t { + PARAMETER_MAX_DISTANCE, + PARAMETER_MIN_DISTANCE, + PARAMETER_NO_DELAY, + PARAMETER_STATUS_FREQUENCY, + PARAMETER_DISTANCE_FREQUENCY, + PARAMETER_RESPONSE_SPEED, + PARAMETER_COUNT, +}; +static constexpr uint8_t PARAMETER_COUNT = static_cast(Parameter::PARAMETER_COUNT); +// Response speed values, in the order of the select's options (Normal, Fast) +static constexpr uint8_t RESPONSE_SPEEDS[] PROGMEM = {5, 10}; +static constexpr uint8_t RESPONSE_SPEED_COUNT = sizeof(RESPONSE_SPEEDS); + +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) +class LD2410SNumber; +#endif + class LD2410SComponent final : public Component, public uart::UARTDevice { #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(target) #endif +#ifdef USE_SENSOR + SUB_SENSOR_WITH_DEDUP(distance, uint16_t) +#endif +#ifdef USE_LD2410S_VERSION + SUB_TEXT_SENSOR(version) +#endif +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) + SUB_SELECT(response_speed) +#endif public: void setup() override { this->status_set_warning(); } // cleared once the module acknowledged the init sequence void loop() override; void dump_config() override; + /// Store a parameter in the module's units; loop() then writes all of them to the module. + /// Returns false, and changes nothing, until the module's own values have been read. +#ifdef USE_LD2410S_PARAMETERS + bool set_parameter(Parameter parameter, uint8_t value); +#ifdef USE_NUMBER + void set_number(Parameter parameter, LD2410SNumber *number) { + this->numbers_[static_cast(parameter)] = number; + } +#endif +#endif + protected: enum class FrameType : uint8_t { NONE, SHORT_DATA, STD_DATA, COMMAND }; @@ -32,6 +82,12 @@ class LD2410SComponent final : public Component, public uart::UARTDevice { /// Returns false for a data frame whose state byte is impossible, so it counts as dropped. bool handle_data_frame_(const uint8_t *payload, uint16_t len); void handle_command_ack_(const uint8_t *payload, uint16_t len); +#ifdef USE_LD2410S_PARAMETERS + bool handle_parameters_(const uint8_t *values, uint16_t len); +#endif +#ifdef USE_LD2410S_VERSION + bool handle_version_(const uint8_t *values, uint16_t len); +#endif void count_dropped_frame_() { if (this->dropped_frames_ < UINT8_MAX) { this->dropped_frames_++; @@ -42,21 +98,41 @@ class LD2410SComponent final : public Component, public uart::UARTDevice { this->expected_len_ = 0; this->frame_type_ = FrameType::NONE; } - void run_init_sequence_(uint32_t now); + void start_sequence_(const uint16_t *sequence, uint8_t length, uint32_t now); + void restart_init_(uint32_t at); + void run_sequence_(uint32_t now); + uint16_t current_command_() const; void send_command_(uint16_t command); - void publish_target_(bool target); + void publish_target_(bool target, uint16_t distance); +#ifdef USE_LD2410S_PARAMETERS + void publish_parameters_(); +#endif +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) + LD2410SNumber *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number +#endif + + const uint16_t *sequence_{nullptr}; // PROGMEM; null while nothing is being sent uint32_t next_send_at_{0}; uint32_t last_frame_at_{0}; +#ifdef USE_LD2410S_PARAMETERS + uint8_t parameters_[PARAMETER_COUNT]{}; // all fit a byte: gates, seconds, tenths of a hertz, speed +#endif uint8_t rx_buffer_[RX_BUFFER_SIZE]; uint8_t rx_len_{0}; uint8_t expected_len_{0}; - uint8_t init_step_{0}; - uint8_t init_timeouts_{0}; + uint8_t sequence_len_{0}; + uint8_t step_{0}; + uint8_t step_timeouts_{0}; uint8_t dropped_frames_{0}; // since the last accepted data frame, saturates at 255 FrameType frame_type_{FrameType::NONE}; bool awaiting_ack_{false}; bool rescanning_{false}; + bool init_done_{false}; +#ifdef USE_LD2410S_PARAMETERS + bool parameters_known_{false}; // the module answered the parameter read at least once + bool parameters_dirty_{false}; +#endif }; } // namespace esphome::ld2410s diff --git a/esphome/components/ld2410s/ld2410s_number.h b/esphome/components/ld2410s/ld2410s_number.h new file mode 100644 index 0000000000..bc24e300ca --- /dev/null +++ b/esphome/components/ld2410s/ld2410s_number.h @@ -0,0 +1,32 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) + +#include "ld2410s.h" +#include "esphome/components/number/number.h" + +namespace esphome::ld2410s { + +/// Edits one common parameter; scale converts the module's units to the number's. +class LD2410SNumber final : public number::Number, public Parented { + public: + LD2410SNumber(Parameter parameter, float scale) : parameter_(parameter), scale_(scale) {} + + void publish_raw(uint8_t raw) { this->publish_state(raw * this->scale_); } + + protected: + void control(float value) override { + // Show the step the module got, not a value between two steps + const uint8_t raw = lroundf(value / this->scale_); // the schema keeps every parameter within a byte + if (this->parent_->set_parameter(this->parameter_, raw)) + this->publish_raw(raw); + } + + const Parameter parameter_; + const float scale_; +}; + +} // namespace esphome::ld2410s + +#endif // USE_NUMBER && USE_LD2410S_PARAMETERS diff --git a/esphome/components/ld2410s/ld2410s_select.h b/esphome/components/ld2410s/ld2410s_select.h new file mode 100644 index 0000000000..e2598b917a --- /dev/null +++ b/esphome/components/ld2410s/ld2410s_select.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) + +#include "ld2410s.h" +#include "esphome/components/select/select.h" + +namespace esphome::ld2410s { + +class LD2410SSelect final : public select::Select, public Parented { + protected: + void control(size_t index) override { + if (this->parent_->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index]))) + this->publish_state(index); + } +}; + +} // namespace esphome::ld2410s + +#endif // USE_SELECT && USE_LD2410S_PARAMETERS diff --git a/esphome/components/ld2410s/number.py b/esphome/components/ld2410s/number.py new file mode 100644 index 0000000000..912e139734 --- /dev/null +++ b/esphome/components/ld2410s/number.py @@ -0,0 +1,108 @@ +from functools import partial + +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_FREQUENCY, + ENTITY_CATEGORY_CONFIG, + ICON_PULSE, + ICON_TIMELAPSE, + UNIT_HERTZ, + UNIT_METER, + UNIT_SECOND, +) +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent, Parameter, ld2410s_ns + +DEPENDENCIES = ["ld2410s"] + +CONF_DISTANCE_REPORTING_FREQUENCY = "distance_reporting_frequency" +CONF_MAX_DISTANCE = "max_distance" +CONF_MIN_DISTANCE = "min_distance" +CONF_NO_DELAY = "no_delay" +CONF_STATUS_REPORTING_FREQUENCY = "status_reporting_frequency" + +LD2410SNumber = ld2410s_ns.class_("LD2410SNumber", number.Number) + +# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz +GATE_WIDTH_M = 0.7 +FREQUENCY_SCALE = 0.1 +_DISTANCE_RANGE = {"min_value": 0, "max_value": 16 * GATE_WIDTH_M, "step": GATE_WIDTH_M} +_FREQUENCY_RANGE = {"min_value": 0.5, "max_value": 8, "step": 0.5} + + +def _number_schema(device_class: str, unit: str, icon: str) -> cv.Schema: + return number.number_schema( + LD2410SNumber, + device_class=device_class, + entity_category=ENTITY_CATEGORY_CONFIG, + unit_of_measurement=unit, + icon=icon, + ) + + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Optional(CONF_MAX_DISTANCE): _number_schema( + DEVICE_CLASS_DISTANCE, UNIT_METER, "mdi:arrow-collapse-right" + ), + cv.Optional(CONF_MIN_DISTANCE): _number_schema( + DEVICE_CLASS_DISTANCE, UNIT_METER, "mdi:arrow-collapse-left" + ), + cv.Optional(CONF_NO_DELAY): _number_schema( + DEVICE_CLASS_DURATION, UNIT_SECOND, ICON_TIMELAPSE + ), + cv.Optional(CONF_STATUS_REPORTING_FREQUENCY): _number_schema( + DEVICE_CLASS_FREQUENCY, UNIT_HERTZ, ICON_PULSE + ), + cv.Optional(CONF_DISTANCE_REPORTING_FREQUENCY): _number_schema( + DEVICE_CLASS_FREQUENCY, UNIT_HERTZ, ICON_PULSE + ), +} + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LD2410S_PARAMETERS") + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_MAX_DISTANCE, + partial(hub.set_number, Parameter.PARAMETER_MAX_DISTANCE), + Parameter.PARAMETER_MAX_DISTANCE, + GATE_WIDTH_M, + **_DISTANCE_RANGE, + ) + await numbers( + CONF_MIN_DISTANCE, + partial(hub.set_number, Parameter.PARAMETER_MIN_DISTANCE), + Parameter.PARAMETER_MIN_DISTANCE, + GATE_WIDTH_M, + **_DISTANCE_RANGE, + ) + await numbers( + CONF_NO_DELAY, + partial(hub.set_number, Parameter.PARAMETER_NO_DELAY), + Parameter.PARAMETER_NO_DELAY, + 1, + min_value=1, + max_value=120, + step=1, + ) + await numbers( + CONF_STATUS_REPORTING_FREQUENCY, + partial(hub.set_number, Parameter.PARAMETER_STATUS_FREQUENCY), + Parameter.PARAMETER_STATUS_FREQUENCY, + FREQUENCY_SCALE, + **_FREQUENCY_RANGE, + ) + await numbers( + CONF_DISTANCE_REPORTING_FREQUENCY, + partial(hub.set_number, Parameter.PARAMETER_DISTANCE_FREQUENCY), + Parameter.PARAMETER_DISTANCE_FREQUENCY, + FREQUENCY_SCALE, + **_FREQUENCY_RANGE, + ) diff --git a/esphome/components/ld2410s/select.py b/esphome/components/ld2410s/select.py new file mode 100644 index 0000000000..4a1376b9bf --- /dev/null +++ b/esphome/components/ld2410s/select.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent, ld2410s_ns + +DEPENDENCIES = ["ld2410s"] + +CONF_RESPONSE_SPEED = "response_speed" + +LD2410SSelect = ld2410s_ns.class_("LD2410SSelect", select.Select) + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Required(CONF_RESPONSE_SPEED): select.select_schema( + LD2410SSelect, + entity_category=ENTITY_CATEGORY_CONFIG, + icon="mdi:speedometer", + ), +} + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LD2410S_PARAMETERS") + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_RESPONSE_SPEED, hub.set_response_speed_select, options=["Normal", "Fast"] + ) diff --git a/esphome/components/ld2410s/sensor.py b/esphome/components/ld2410s/sensor.py new file mode 100644 index 0000000000..96eccc08f4 --- /dev/null +++ b/esphome/components/ld2410s/sensor.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_DISTANCE, + DEVICE_CLASS_DISTANCE, + ICON_SIGNAL_DISTANCE_VARIANT, + STATE_CLASS_MEASUREMENT, + UNIT_CENTIMETER, +) +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent + +DEPENDENCIES = ["ld2410s"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Required(CONF_DISTANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_CENTIMETER, + icon=ICON_SIGNAL_DISTANCE_VARIANT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISTANCE, hub.set_distance_sensor) diff --git a/esphome/components/ld2410s/text_sensor.py b/esphome/components/ld2410s/text_sensor.py new file mode 100644 index 0000000000..d017d3f1a6 --- /dev/null +++ b/esphome/components/ld2410s/text_sensor.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent + +DEPENDENCIES = ["ld2410s"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Required(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), +} + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LD2410S_VERSION") + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 072abe8374..1a5ab82d93 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -97,6 +97,8 @@ #define USE_JSON #define USE_JSON_ARENA #define USE_RADIO_FREQUENCY +#define USE_LD2410S_PARAMETERS +#define USE_LD2410S_VERSION #define USE_LIGHT #define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT diff --git a/tests/components/ld2410s/common-bare.yaml b/tests/components/ld2410s/common-bare.yaml new file mode 100644 index 0000000000..2f0e569bb9 --- /dev/null +++ b/tests/components/ld2410s/common-bare.yaml @@ -0,0 +1,28 @@ +ld2410s: + id: ld2410s_bare + uart_id: uart_bus + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_bare + has_target: + name: Has Target + +# Entities of other platforms must not pull the parameter code into a bare hub +number: + - platform: template + name: Unrelated Number + optimistic: true + min_value: 0 + max_value: 10 + step: 1 + +select: + - platform: template + name: Unrelated Select + optimistic: true + options: ["a", "b"] + +text_sensor: + - platform: template + name: Unrelated Text diff --git a/tests/components/ld2410s/common.yaml b/tests/components/ld2410s/common.yaml index 33b213d363..5ae9649710 100644 --- a/tests/components/ld2410s/common.yaml +++ b/tests/components/ld2410s/common.yaml @@ -5,3 +5,31 @@ binary_sensor: - platform: ld2410s has_target: name: Presence + +sensor: + - platform: ld2410s + distance: + name: Distance + +text_sensor: + - platform: ld2410s + version: + name: Firmware + +number: + - platform: ld2410s + max_distance: + name: Max Distance + min_distance: + name: Min Distance + no_delay: + name: No Delay + status_reporting_frequency: + name: Status Reporting Frequency + distance_reporting_frequency: + name: Distance Reporting Frequency + +select: + - platform: ld2410s + response_speed: + name: Response Speed diff --git a/tests/components/ld2410s/test-bare.esp8266-ard.yaml b/tests/components/ld2410s/test-bare.esp8266-ard.yaml new file mode 100644 index 0000000000..bda2f21bb4 --- /dev/null +++ b/tests/components/ld2410s/test-bare.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + ld2410s: !include common-bare.yaml diff --git a/tests/integration/fixtures/uart_mock_ld2410s.yaml b/tests/integration/fixtures/uart_mock_ld2410s.yaml index 113e0881e6..858a689b39 100644 --- a/tests/integration/fixtures/uart_mock_ld2410s.yaml +++ b/tests/integration/fixtures/uart_mock_ld2410s.yaml @@ -22,16 +22,58 @@ uart_mock: id: mock_uart baud_rate: 115200 auto_start: false + on_tx: + - then: + - lambda: |- + // Parameter read: the first answer stops after one value so the read is resent after the + // 300 ms timeout; the resend gets gates 0 to 16, no delay 10 s, 8 Hz reporting, normal speed + static const std::vector PARAMETERS_READ = { + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x71, 0x00, 0x05, 0x00, 0x0A, 0x00, + 0x06, 0x00, 0x02, 0x00, 0x0C, 0x00, 0x0B, 0x00, 0x04, 0x03, 0x02, 0x01}; + static int reads = 0; + if (data != PARAMETERS_READ) { + return; + } + if (++reads == 1) { + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x71, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01}); + return; + } + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0xFD, 0xFC, 0xFB, 0xFA, 0x1C, 0x00, 0x71, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x50, 0x00, + 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01}); responses: # Config-mode start, acknowledged with protocol version 2 and buffer size 0x20 - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + # Version read, answered with device type 1 and firmware 1.2.3 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x02, 0x00, 0x03, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + # The parameter read is answered by the on_tx lambda below: cut short once, then in full # Output-mode switch to minimal, acknowledged - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x7A, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] # Config-mode end, acknowledged - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + # Setting the max distance to 7 m writes all six parameters inside a configuration-mode session + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + - expect_tx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x26, 0x00, 0x70, 0x00, 0x05, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x0A, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x02, 0x00, 0x50, 0x00, 0x00, 0x00, + 0x0C, 0x00, 0x50, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x05, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x70, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] injections: # Short frame, state 2 (target), distance 300 -> presence on - delay: 100ms @@ -76,6 +118,26 @@ binary_sensor: has_target: name: "Has Target" +sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + distance: + name: "Distance" + +text_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + version: + name: "Version" + +number: + - platform: ld2410s + ld2410s_id: ld2410s_dev + max_distance: + name: "Max Distance" + no_delay: + name: "No Delay" + button: - platform: template name: "Start Scenario" diff --git a/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml index 5f06985c7b..57b66f0d9d 100644 --- a/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml +++ b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml @@ -26,6 +26,25 @@ uart_mock: # Config-mode start, acknowledged with protocol version 2 and buffer size 0x20 - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + # Version read, answered with device type 1 and firmware 1.2.3 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x02, 0x00, 0x03, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + # Parameter read, answered with gates 0 to 16, no delay 10 s, 8 Hz reporting, normal speed + - expect_tx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x71, 0x00, 0x05, 0x00, 0x0A, 0x00, 0x06, 0x00, 0x02, 0x00, + 0x0C, 0x00, 0x0B, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x1C, 0x00, 0x71, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x05, 0x00, + 0x00, 0x00, 0x04, 0x03, 0x02, 0x01, + ] # Output-mode switch to minimal, acknowledged - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x7A, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] diff --git a/tests/integration/test_uart_mock_ld2410s.py b/tests/integration/test_uart_mock_ld2410s.py index 8cd9b4b8dd..44b808cd4c 100644 --- a/tests/integration/test_uart_mock_ld2410s.py +++ b/tests/integration/test_uart_mock_ld2410s.py @@ -1,9 +1,13 @@ """Integration tests for the LD2410S component with mock UART. test_uart_mock_ld2410s: - 1. Init sequence - config-mode start, output-mode switch and config-mode end go - out in order, each after the previous one is acknowledged, then the - component logs "Setup done" and clears its warning + 1. Init sequence - config-mode start, version read, parameter read, output-mode + switch and config-mode end go out in order, each after the previous one is + acknowledged, then the component logs "Setup done" and clears its warning; + the version and parameter answers are parsed and logged + 1b. Setting the max distance number to 7 m writes all six parameters (gate 10 + plus the values read at init) inside a configuration-mode session, and the + number reports 7 m 2. Presence from short frames (states 0/1 off, 2/3 on) and from a standard data frame 3. Resync - a noise byte or a partial header directly before a short frame @@ -34,15 +38,40 @@ from __future__ import annotations import asyncio import re -from aioesphomeapi import APIClient, ButtonInfo +from aioesphomeapi import APIClient, ButtonInfo, EntityState, NumberInfo, NumberState import pytest from .state_utils import InitialStateHelper, SensorStateCollector, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction CONFIG_START_TX = "FD:FC:FB:FA:04:00:FF:00:01:00:04:03:02:01" +VERSION_READ_TX = "FD:FC:FB:FA:02:00:00:00:04:03:02:01" +PARAMETERS_READ_TX = ( + "FD:FC:FB:FA:0E:00:71:00:05:00:0A:00:06:00:02:00:0C:00:0B:00:04:03:02:01" +) OUTPUT_MODE_TX = "FD:FC:FB:FA:08:00:7A:00:00:00:00:00:00:00:04:03:02:01" CONFIG_END_TX = "FD:FC:FB:FA:02:00:FE:00:04:03:02:01" +INIT_TX = [ + CONFIG_START_TX, + VERSION_READ_TX, + # The fixture cuts the first answer short, so the read goes out again + PARAMETERS_READ_TX, + PARAMETERS_READ_TX, + OUTPUT_MODE_TX, + CONFIG_END_TX, +] +# All six parameters: max gate 10, min gate 0, no delay 10, both frequencies 80, speed 5 +PARAMETERS_WRITE_TX = ( + "FD:FC:FB:FA:26:00:70:00:05:00:0A:00:00:00:0A:00:00:00:00:00:06:00:0A:00:00:00:02:00:50:00:00:00:" + "0C:00:50:00:00:00:0B:00:05:00:00:00:04:03:02:01" +) +WRITE_TX = [CONFIG_START_TX, PARAMETERS_WRITE_TX, CONFIG_END_TX] +# Logged once the version and parameter answers are parsed +PARSED_ANSWERS = [ + "Firmware 1.2.3", + "Parameter answer too short (4 bytes)", + "Parameters: gates 0 to 16, no delay 10 s", +] ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") @@ -119,12 +148,17 @@ async def test_uart_mock_ld2410s( setup_done = loop.create_future() warning_cleared = loop.create_future() dropped_states: list[str] = [] + parsed_answers: list[str] = [] + write_done = loop.create_future() def line_callback(line: str) -> None: if (frame := _tx_frame(line)) is not None: tx_frames.append(frame) + if len(tx_frames) >= 9 and not write_done.done(): + write_done.set_result(True) if "[ld2410s" in line and "Setup done" in line and not setup_done.done(): setup_done.set_result(True) + parsed_answers.extend(answer for answer in PARSED_ANSWERS if answer in line) if "ld2410s cleared Warning flag" in line and not warning_cleared.done(): warning_cleared.set_result(True) if "Dropping frame with impossible state " in line: @@ -133,8 +167,15 @@ async def test_uart_mock_ld2410s( ) collector = SensorStateCollector( - sensor_names=[], binary_sensor_names=["has_target"] + sensor_names=["distance"], binary_sensor_names=["has_target"] ) + number_states: list[float] = [] + + def on_state(state: EntityState) -> None: + collector.on_state(state) + if isinstance(state, NumberState) and state.key == max_distance_key: + number_states.append(state.state) + all_presence = collector.add_waiter( lambda: len(collector.binary_states["has_target"]) >= len(EXPECTED_PRESENCE) ) @@ -148,17 +189,41 @@ async def test_uart_mock_ld2410s( except TimeoutError: pytest.fail(f"Init sequence did not finish. TX frames: {tx_frames}") - assert tx_frames[:3] == [CONFIG_START_TX, OUTPUT_MODE_TX, CONFIG_END_TX] + assert tx_frames[:6] == INIT_TX + assert parsed_answers == PARSED_ANSWERS - await _start_scenario(client, collector) + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + max_distance_key = require_entity(entities, "max_distance", NumberInfo).key + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + # The values read from the module: gate 16 and 10 s + no_delay_key = require_entity(entities, "no_delay", NumberInfo).key + initial = initial_state_helper.initial_states + assert initial[max_distance_key].state == pytest.approx(11.2) + assert initial[no_delay_key].state == pytest.approx(10) + + client.number_command(max_distance_key, 7.0) + try: + await asyncio.wait_for(write_done, 5.0) + except TimeoutError: + pytest.fail(f"Parameter write not sent. TX frames: {tx_frames}") + assert tx_frames[6:9] == WRITE_TX, tx_frames + assert number_states == [pytest.approx(7.0)] + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) await _wait_for_presence(collector, all_presence) # Let any unexpected extra update arrive before comparing await asyncio.sleep(0.3) assert collector.binary_states["has_target"] == EXPECTED_PRESENCE assert dropped_states == EXPECTED_DROPPED_STATES + # A target 300 cm away, then none: the distance follows the presence + assert collector.sensor_states["distance"][:2] == [300, 0] # Data frames must not restart the init sequence - assert len(tx_frames) == 3, tx_frames + assert len(tx_frames) == 9, tx_frames @pytest.mark.asyncio