Compare commits

..
Author SHA1 Message Date
J. Nick Koston 97fda5ebfc Apply the periodic frame minimum to engineering frames too 2026-10-21 21:35:54 -10:00
J. Nick Koston 3fffc1becb Check that a periodic frame holds every value it is read for 2026-10-21 21:26:50 -10:00
J. Nick Koston 06d3900d76 Check the length of the distance resolution and light control answers before reading them 2026-10-21 21:09:03 -10:00
J. Nick Koston e441ee5b78 Trim the comments 2026-10-21 20:54:49 -10:00
J. Nick Koston 3993be10ca Drop to the next header with one call per frame 2026-10-21 20:51:37 -10:00
J. Nick Koston ca2aab69f0 Say that the frame handler may overwrite the buffer position 2026-10-21 20:48:45 -10:00
J. Nick Koston ace7f5bc45 Give the test fixture's protected methods trailing underscores for clang-tidy 2026-10-21 20:44:58 -10:00
J. Nick Koston ab4b24a151 Share the frame splitter through ld24xx 2026-10-21 20:40:51 -10:00
J. Nick Koston 1b4d284208 Count the footer when checking that a version or basic configuration answer is long enough 2026-10-21 20:25:22 -10:00
J. Nick Koston 5cd4ccf2ca Pick the header only once a byte is buffered, cover impossible counts and complete the waiter 2026-10-21 20:08:50 -10:00
J. Nick Koston 323c8bd311 Name the drop helper's parameters so clang-tidy sees no swapped argument 2026-10-21 19:18:45 -10:00
J. Nick Koston e8bef162eb Keep only bytes that begin a header after each frame drained from the buffer 2026-10-21 19:14:57 -10:00
J. Nick Koston d4763b6e7f Drain the buffer in a loop instead of replaying bytes through readline_ 2026-10-21 19:10:59 -10:00
J. Nick Koston cb0bf36ba9 Drop the unused gate answer length constant 2026-10-21 19:00:00 -10:00
J. Nick Koston 47daadd0fa Keep the largest declared frame length from wrapping around to zero 2026-10-21 18:54:39 -10:00
J. Nick Koston f2ac6f6f93 Space the mock phases past the sensor throttle and cover the replay after a dropped frame 2026-10-21 18:49:05 -10:00
J. Nick Koston 286b3c294e Drop the trailing return, fix the byte map and cover footer bytes inside a periodic frame 2026-10-21 18:36:47 -10:00
J. Nick Koston e137b11ed6 Keep the two headers apart, use the datasheet frame length and fix the synthetic frames 2026-10-21 18:29:03 -10:00
J. Nick Koston eedb824934 [ld2412] Split frames by their length instead of scanning for footer bytes 2026-10-21 18:17:47 -10:00
47 changed files with 616 additions and 1096 deletions
-8
View File
@@ -12,7 +12,6 @@ Checks: >-
-bugprone-multi-level-implicit-pointer-conversion,
-bugprone-narrowing-conversions,
-bugprone-tagged-union-member-count,
-bugprone-signed-bitwise,
-bugprone-signed-char-misuse,
-bugprone-switch-missing-default-case,
-cert-dcl50-cpp,
@@ -40,7 +39,6 @@ Checks: >-
-cppcoreguidelines-avoid-const-or-ref-data-members,
-cppcoreguidelines-avoid-do-while,
-cppcoreguidelines-avoid-magic-numbers,
-cppcoreguidelines-explicit-constructor,
-cppcoreguidelines-init-variables,
-cppcoreguidelines-macro-to-enum,
-cppcoreguidelines-macro-usage,
@@ -86,7 +84,6 @@ Checks: >-
-llvm-use-ranges,
-llvmlibc-*,
-misc-const-correctness,
-misc-explicit-constructor,
-misc-include-cleaner,
-misc-multiple-inheritance,
-misc-no-recursion,
@@ -116,7 +113,6 @@ Checks: >-
-objc-*,
-performance-enum-size,
-portability-avoid-pragma-once,
-portability-no-assembler,
-portability-template-virtual-member-function,
-readability-ambiguous-smartptr-reset-call,
-readability-avoid-nested-conditional-operator,
@@ -133,13 +129,9 @@ Checks: >-
-readability-named-parameter,
-readability-redundant-casting,
-readability-redundant-inline-specifier,
-readability-redundant-lambda-parameter-list,
-readability-redundant-member-init,
-readability-redundant-nested-if,
-readability-redundant-parentheses,
-readability-redundant-typename,
-readability-trailing-comma,
-readability-trivial-switch,
-readability-uppercase-literal-suffix,
-readability-use-anyofallof,
-readability-use-std-min-max,
+1 -1
View File
@@ -38,7 +38,7 @@ repos:
- id: yamllint
exclude: ^(\.clang-format|\.clang-tidy)$
- repo: https://github.com/pre-commit/mirrors-clang-format
rev: v23.1.3
rev: v22.1.8
hooks:
- id: clang-format
types_or: [c, c++]
-2
View File
@@ -608,7 +608,6 @@ message ListEntitiesCoverResponse {
bool supports_stop = 12;
uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"];
bool supports_stop_tilt = 14;
bool supports_toggle = 15;
}
// Deprecated in API version 1.1
@@ -671,7 +670,6 @@ message CoverCommandRequest {
bool stop = 8;
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
bool stop_tilt = 10;
bool toggle = 11;
}
// ==================== FAN ====================
@@ -555,7 +555,6 @@ uint16_t APIConnection::try_send_cover_info(EntityBase *entity, APIConnection *c
msg.supports_tilt = traits.get_supports_tilt();
msg.supports_stop = traits.get_supports_stop();
msg.supports_stop_tilt = traits.get_supports_stop_tilt();
msg.supports_toggle = traits.get_supports_toggle();
return fill_and_encode_entity_info_with_device_class(cover, msg, msg.device_class, conn, remaining_size);
}
void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) {
@@ -568,8 +567,6 @@ void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) {
call.set_command_stop();
if (msg.stop_tilt)
call.set_command_stop_tilt();
if (msg.toggle)
call.set_command_toggle();
call.perform();
}
#endif
-5
View File
@@ -484,7 +484,6 @@ uint8_t *ListEntitiesCoverResponse::encode_msg(const void *self, uint8_t *__rest
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_id);
#endif
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.supports_stop_tilt);
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 15, msg.supports_toggle);
return pos;
}
uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) {
@@ -507,7 +506,6 @@ uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) {
size += ProtoSize::calc_uint32(1, msg.device_id);
#endif
size += ProtoSize::calc_bool(1, msg.supports_stop_tilt);
size += ProtoSize::calc_bool(1, msg.supports_toggle);
return size;
}
uint8_t *CoverStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
@@ -567,9 +565,6 @@ void CoverCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *
case proto_tag(10, WIRE_TYPE_VARINT):
msg.stop_tilt = value.as_bool();
break;
case proto_tag(11, WIRE_TYPE_VARINT):
msg.toggle = value.as_bool();
break;
default:
break;
}
+2 -4
View File
@@ -911,7 +911,7 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage {
class ListEntitiesCoverResponse final : public InfoResponseProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 13;
static constexpr uint8_t ESTIMATED_SIZE = 61;
static constexpr uint8_t ESTIMATED_SIZE = 59;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_cover_response"); }
#endif
@@ -921,7 +921,6 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage {
ProgmemStringRef device_class{};
bool supports_stop{false};
bool supports_stop_tilt{false};
bool supports_toggle{false};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
@@ -959,7 +958,7 @@ class CoverStateResponse final : public StateResponseProtoMessage {
class CoverCommandRequest final : public CommandProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 30;
static constexpr uint8_t ESTIMATED_SIZE = 29;
static constexpr uint8_t ESTIMATED_SIZE = 27;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("cover_command_request"); }
#endif
@@ -969,7 +968,6 @@ class CoverCommandRequest final : public CommandProtoMessage {
float tilt{0.0f};
bool stop{false};
bool stop_tilt{false};
bool toggle{false};
void decode(const uint8_t *buffer, size_t length) {
ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);
}
-2
View File
@@ -1246,7 +1246,6 @@ const char *ListEntitiesCoverResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("supports_stop_tilt"), this->supports_stop_tilt);
dump_field(out, ESPHOME_PSTR("supports_toggle"), this->supports_toggle);
return out.c_str();
}
const char *CoverStateResponse::dump_to(DumpBuffer &out) const {
@@ -1272,7 +1271,6 @@ const char *CoverCommandRequest::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("stop_tilt"), this->stop_tilt);
dump_field(out, ESPHOME_PSTR("toggle"), this->toggle);
return out.c_str();
}
#endif
+1 -1
View File
@@ -301,7 +301,7 @@ constexpr uint32_t VARINT_MAX_1_BYTE = 1 << 7; // 128
constexpr uint32_t VARINT_MAX_2_BYTE = 1 << 14; // 16384
/// Generated encode body: writes the fields at pos, returns the cursor past them.
using ProtoEncodeFn = uint8_t *(*)(const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM);
using ProtoEncodeFn = uint8_t *(*) (const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM);
/// Static encode helpers for the generated encode bodies. Each takes the write cursor by value and
/// returns it advanced, so outlined calls at -Os chain through the return register instead of a
+5 -4
View File
@@ -111,9 +111,10 @@ static const uint8_t BL0906_USR_WRPROT = 0x9E;
// Reset Register
static const uint8_t BL0906_SOFT_RESET = 0x9F;
const uint8_t BL0906_INIT[2][6] = {// Reset to default
{BL0906_WRITE_COMMAND, BL0906_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x52},
// Enable User Operation Write
{BL0906_WRITE_COMMAND, BL0906_USR_WRPROT, 0x55, 0x55, 0x00, 0xB7}};
const uint8_t BL0906_INIT[2][6] = {
// Reset to default
{BL0906_WRITE_COMMAND, BL0906_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x52},
// Enable User Operation Write
{BL0906_WRITE_COMMAND, BL0906_USR_WRPROT, 0x55, 0x55, 0x00, 0xB7}};
} // namespace esphome::bl0906
-8
View File
@@ -79,10 +79,6 @@ class ClimateCall {
ClimateCall &set_fan_mode(optional<std::string> fan_mode);
/// Set the custom fan mode of the climate device.
ClimateCall &set_fan_mode(const char *custom_fan_mode);
/// Set the custom fan mode of the climate device (StringRef overload to resolve ambiguity, zero-copy).
ClimateCall &set_fan_mode(StringRef custom_fan_mode) {
return this->set_fan_mode(custom_fan_mode.c_str(), custom_fan_mode.size());
}
/// Set the custom fan mode of the climate device (zero-copy API path).
ClimateCall &set_fan_mode(const char *custom_fan_mode, size_t len);
/// Set the swing mode of the climate device.
@@ -103,10 +99,6 @@ class ClimateCall {
ClimateCall &set_preset(optional<std::string> preset);
/// Set the custom preset of the climate device.
ClimateCall &set_preset(const char *custom_preset);
/// Set the custom preset of the climate device (StringRef overload to resolve ambiguity, zero-copy).
ClimateCall &set_preset(StringRef custom_preset) {
return this->set_preset(custom_preset.c_str(), custom_preset.size());
}
/// Set the custom preset of the climate device (zero-copy API path).
ClimateCall &set_preset(const char *custom_preset, size_t len);
@@ -1,12 +0,0 @@
#include "epaper_goodisplay_3c.h"
namespace esphome::epaper_spi {
void EPaperGoodDisplay3C::power_on() {}
void EPaperGoodDisplay3C::refresh_screen(bool partial) {
EPaperWeAct3C::refresh_screen(partial);
this->next_delay_ = REFRESH_START_MS;
}
} // namespace esphome::epaper_spi
@@ -1,28 +0,0 @@
#pragma once
#include "epaper_weact_3c.h"
namespace esphome::epaper_spi {
/**
* Good Display 3-color e-paper displays (SSD1680 controller), e.g. GDEY029Z95.
*
* Driven as EPaperWeAct3C, except that there is no separate power-on: the full refresh (0xF7) powers the panel
* up itself. Busy then only goes high a little after the refresh starts, so the next state waits before
* checking it.
*/
class EPaperGoodDisplay3C : public EPaperWeAct3C {
public:
using EPaperWeAct3C::EPaperWeAct3C;
protected:
// Time from master activation (0x20) until busy can be relied on
static constexpr uint16_t REFRESH_START_MS = 50;
// With EPaperWeAct3C's power-on (0x22 0xF8) first, this panel's refresh ends almost at once and the image
// does not change.
void power_on() override;
void refresh_screen(bool partial) override;
};
} // namespace esphome::epaper_spi
@@ -4,17 +4,11 @@ Supported models:
- weact-2.13in-3c: 122x250 pixels (2.13" display)
- weact-2.9in-3c: 128x296 pixels (2.9" display)
- weact-4.2in-3c: 400x300 pixels (4.2" display)
- goodisplay-gdey029z95-2.9: 128x296 pixels (Good Display GDEY029Z95 2.9" display)
These displays use SSD1680 or SSD1683 controller and require a specific initialization
sequence. The DRV_OUT_CTL command is calculated from the display height.
The Good Display panel is driven as the WeAct ones, except that it must not get the
separate power-on before the refresh, so it has its own driver class, EPaperGoodDisplay3C.
"""
import esphome.config_validation as cv
from esphome.const import CONF_BUSY_PIN, CONF_RESET_PIN
from . import EpaperModel
@@ -23,10 +17,8 @@ class WeActBWR(EpaperModel):
source_files = ("epaper_weact_3c.cpp",)
def __init__(
self, name: str, class_name: str = "EPaperWeAct3C", **defaults: object
) -> None:
super().__init__(name, class_name, **defaults)
def __init__(self, name, **defaults):
super().__init__(name, "EPaperWeAct3C", **defaults)
def get_init_sequence(self, config):
"""Generate initialization sequence for WeAct BWR displays.
@@ -83,27 +75,3 @@ weact_4p2in3c = WeActBWR(
data_rate="10MHz",
minimum_update_interval="10s",
)
class GoodDisplayBWR(WeActBWR):
"""Good Display Black/White/Red displays using SSD1680 controller."""
source_files = ("epaper_weact_3c.cpp", "epaper_goodisplay_3c.cpp")
def __init__(self, name: str, **defaults: object) -> None:
super().__init__(name, "EPaperGoodDisplay3C", **defaults)
def option(self, name, fallback=cv.UNDEFINED) -> cv.Optional | cv.Required:
# Only a hardware reset wakes the panel from deep sleep, and refreshes are timed by busy
if name in (CONF_RESET_PIN, CONF_BUSY_PIN):
return cv.Required(name)
return super().option(name, fallback)
# Model: Good Display GDEY029Z95 2.9" BWR - 128x296 pixels, SSD1680 controller
GoodDisplayBWR(
"goodisplay-gdey029z95-2.9",
width=128,
height=296,
minimum_update_interval="10s",
)
+16 -9
View File
@@ -407,10 +407,12 @@ void LD2410Component::handle_periodic_data_() {
}
#ifdef USE_NUMBER
void set_number_value(number::Number *n, float value) {
std::function<void()> set_number_value(number::Number *n, float value) {
if (n != nullptr && (!n->has_state() || n->state != value)) {
n->publish_state(value);
n->state = value;
return [n, value]() { n->publish_state(value); };
}
return []() {};
}
#endif
@@ -558,24 +560,29 @@ bool LD2410Component::handle_ack_data_() {
Moving distance range: 13th byte
Still distance range: 14th byte
*/
set_number_value(this->max_move_distance_gate_number_, this->buffer_data_[12]);
set_number_value(this->max_still_distance_gate_number_, this->buffer_data_[13]);
std::vector<std::function<void()>> updates;
updates.push_back(set_number_value(this->max_move_distance_gate_number_, this->buffer_data_[12]));
updates.push_back(set_number_value(this->max_still_distance_gate_number_, this->buffer_data_[13]));
/*
Moving Sensitivities: 15~23th bytes
*/
for (size_t i = 0; i < TOTAL_GATES; i++) {
set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[14 + i]);
for (std::vector<number::Number *>::size_type i = 0; i != this->gate_move_threshold_numbers_.size(); i++) {
updates.push_back(set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[14 + i]));
}
/*
Still Sensitivities: 24~32th bytes
*/
for (size_t i = 0; i < TOTAL_GATES; i++) {
set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[23 + i]);
for (std::vector<number::Number *>::size_type i = 0; i != this->gate_still_threshold_numbers_.size(); i++) {
updates.push_back(set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[23 + i]));
}
/*
None Duration: 33~34th bytes
*/
set_number_value(this->timeout_number_, encode_uint16(this->buffer_data_[33], this->buffer_data_[32]));
updates.push_back(
set_number_value(this->timeout_number_, encode_uint16(this->buffer_data_[33], this->buffer_data_[32])));
for (auto &update : updates) {
update();
}
#endif
break;
}
+52 -64
View File
@@ -186,23 +186,19 @@ static constexpr uint8_t CMD_DURATION_VALUE = 0x02;
// Bitmasks for target states
static constexpr uint8_t MOVE_BITMASK = 0x01;
static constexpr uint8_t STILL_BITMASK = 0x02;
// Header & Footer size
static constexpr uint8_t HEADER_FOOTER_SIZE = 4;
// Command Header & Footer
static constexpr uint8_t CMD_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xFD, 0xFC, 0xFB, 0xFA};
static constexpr uint8_t CMD_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0x04, 0x03, 0x02, 0x01};
using ld24xx::CMD_FRAME_FOOTER;
using ld24xx::CMD_FRAME_HEADER;
using ld24xx::HEADER_FOOTER_SIZE;
using ld24xx::validate_header_footer;
// Smallest frames whose values can all be read: the last value, data footer and check, then the frame footer
static constexpr uint8_t PERIODIC_FRAME_MIN = STILL_ENERGY + 3 + HEADER_FOOTER_SIZE;
static constexpr uint8_t ENGINEERING_FRAME_MIN = LIGHT_SENSOR + 3 + HEADER_FOOTER_SIZE;
// Data Header & Footer
static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1};
static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5};
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
static constexpr uint8_t GATE_SENS_ACK_SIZE = ACK_PAYLOAD + TOTAL_GATES + HEADER_FOOTER_SIZE;
// The length field counts the command word, the status word and the gates
static constexpr uint16_t GATE_SENS_ACK_LENGTH = GATE_SENS_ACK_SIZE - COMMAND - HEADER_FOOTER_SIZE;
static inline bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) {
return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0;
}
void LD2412Component::dump_config() {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
@@ -371,6 +367,9 @@ void LD2412Component::handle_periodic_data_() {
0x02: Normal mode
*/
bool engineering_mode = this->buffer_data_[DATA_TYPES] == 0x01;
if (this->buffer_pos_ < PERIODIC_FRAME_MIN) {
return; // too short to hold the values
}
#ifdef USE_SWITCH
if (this->engineering_mode_switch_ != nullptr) {
this->engineering_mode_switch_->publish_state(engineering_mode);
@@ -421,8 +420,7 @@ void LD2412Component::handle_periodic_data_() {
this->detection_distance_sensor_.publish_state_if_not_dup(new_detect_distance);
}
if (engineering_mode) {
// Engineering mode needs at least LIGHT_SENSOR + 1 bytes
if (this->buffer_pos_ < LIGHT_SENSOR + 1) {
if (this->buffer_pos_ < ENGINEERING_FRAME_MIN) {
ESP_LOGW(TAG, "Engineering mode packet too short: %u", this->buffer_pos_);
} else {
/*
@@ -477,24 +475,15 @@ static constexpr uint32_t GATE_QUERY_TIMEOUT_ID = 0;
static constexpr uint32_t GATE_QUERY_TIMEOUT_MS = 2000;
#endif
bool LD2412Component::handle_ack_data_() {
void LD2412Component::handle_ack_data_() {
ESP_LOGV(TAG, "Handling ACK DATA for COMMAND %02X", this->buffer_data_[COMMAND]);
if (this->buffer_pos_ < 10) {
ESP_LOGW(TAG, "Invalid length");
return true;
}
if (!ld2412::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_LOGW(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]) {
@@ -517,8 +506,8 @@ bool LD2412Component::handle_ack_data_() {
break;
case CMD_QUERY_VERSION: {
if (this->buffer_pos_ < 12 + sizeof(this->version_)) {
return false;
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];
@@ -532,6 +521,9 @@ bool LD2412Component::handle_ack_data_() {
break;
}
case CMD_QUERY_DISTANCE_RESOLUTION: {
if (this->buffer_pos_ < 11 + HEADER_FOOTER_SIZE) {
break; // too short to hold the resolution
}
const auto *distance_resolution = find_str(DISTANCE_RESOLUTIONS_BY_UINT, this->buffer_data_[10]);
ESP_LOGV(TAG, "Distance resolution: %s", distance_resolution);
#ifdef USE_SELECT
@@ -543,6 +535,9 @@ bool LD2412Component::handle_ack_data_() {
}
case CMD_QUERY_LIGHT_CONTROL: {
if (this->buffer_pos_ < 12 + HEADER_FOOTER_SIZE) {
break; // too short to hold the light settings
}
this->light_function_ = this->buffer_data_[10];
this->light_threshold_ = this->buffer_data_[11];
const auto *light_function_str = find_str(LIGHT_FUNCTIONS_BY_UINT, this->light_function_);
@@ -562,7 +557,7 @@ bool LD2412Component::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;
@@ -591,6 +586,9 @@ bool LD2412Component::handle_ack_data_() {
break;
case CMD_QUERY_DYNAMIC_BACKGROUND_CORRECTION: {
if (this->buffer_pos_ < 11 + HEADER_FOOTER_SIZE) {
break; // too short to hold the state
}
ESP_LOGV(TAG, "Handled query dynamic background correction");
bool dynamic_background_correction_active = (this->buffer_data_[10] != 0x00);
#ifdef USE_BINARY_SENSOR
@@ -613,14 +611,10 @@ bool LD2412Component::handle_ack_data_() {
case CMD_QUERY_MOTION_GATE_SENS:
case CMD_QUERY_STATIC_GATE_SENS: {
#ifdef USE_NUMBER
const uint16_t declared = encode_uint16(this->buffer_data_[FRAME_LENGTH + 1], this->buffer_data_[FRAME_LENGTH]);
if (declared != GATE_SENS_ACK_LENGTH || this->buffer_pos_ > GATE_SENS_ACK_SIZE) {
// A truncated answer merged with the next frame, or a length this driver does not know
if (this->buffer_pos_ != GATE_SENS_ACK_SIZE) {
// A length this driver does not know
ESP_LOGW(TAG, "Dropping gate threshold answer of %u bytes", this->buffer_pos_);
return true;
}
if (this->buffer_pos_ < GATE_SENS_ACK_SIZE) {
return false; // the footer pattern appeared inside the payload, keep reading
return;
}
const bool motion = this->buffer_data_[COMMAND] == CMD_QUERY_MOTION_GATE_SENS;
GateThresholds &group = this->gate_thresholds_(motion);
@@ -646,8 +640,8 @@ bool LD2412Component::handle_ack_data_() {
case CMD_QUERY_BASIC_CONF: // Query parameters response
{
#ifdef USE_NUMBER
if (this->buffer_pos_ < 15) {
return false;
if (this->buffer_pos_ < 15 + HEADER_FOOTER_SIZE) {
break; // too short to hold the configuration
}
/*
Moving distance range: 9th byte
@@ -677,42 +671,36 @@ bool LD2412Component::handle_ack_data_() {
default:
break;
}
return true;
}
void LD2412Component::readline_(int readch) {
if (readch < 0) {
return; // No data available
}
if (this->buffer_pos_ < HEADER_FOOTER_SIZE && readch != DATA_FRAME_HEADER[this->buffer_pos_] &&
readch != CMD_FRAME_HEADER[this->buffer_pos_]) {
this->buffer_pos_ = 0;
return;
}
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;
}
if (this->buffer_pos_ < 4) {
return; // Not enough data to process yet
}
if (ld2412::validate_header_footer(DATA_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) {
// Periodic and command frames both carry their count after the header
ld24xx::feed_frame_byte(
this->buffer_data_, this->buffer_pos_, readch, DATA_FRAME_HEADER, ld24xx::cmd_frame_length,
[this](uint8_t frame_len) { return this->handle_frame_(frame_len); }, []() { ESP_LOGW(TAG, "Invalid length"); });
}
// Dispatches the frame of frame_len bytes at the start of the buffer when its footer is in place
bool LD2412Component::handle_frame_(uint8_t frame_len) {
this->buffer_pos_ = frame_len; // the handlers take the frame length from here
const uint8_t *tail = &this->buffer_data_[frame_len - HEADER_FOOTER_SIZE];
if (this->buffer_data_[0] == DATA_FRAME_HEADER[0]) {
if (!ld2412::validate_header_footer(DATA_FRAME_FOOTER, tail)) {
return false;
}
ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s");
this->handle_periodic_data_();
this->buffer_pos_ = 0; // Reset position index for next message
} else if (ld2412::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) {
ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s");
if (this->handle_ack_data_()) {
this->buffer_pos_ = 0; // Reset position index for next message
} else {
ESP_LOGV(TAG, "Ack Data incomplete");
}
return true;
}
if (!ld2412::validate_header_footer(CMD_FRAME_FOOTER, tail)) {
return false;
}
ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s");
this->handle_ack_data_();
return true;
}
void LD2412Component::set_config_mode_(bool enable) {
+2 -1
View File
@@ -111,7 +111,8 @@ class LD2412Component final : public Component, public uart::UARTDevice {
void send_command_(uint8_t command_str, const uint8_t *command_value, uint8_t command_value_len);
void set_config_mode_(bool enable);
void handle_periodic_data_();
bool handle_ack_data_();
void handle_ack_data_();
bool handle_frame_(uint8_t frame_len);
void readline_(int readch);
void query_parameters_();
void get_version_();
+70
View File
@@ -3,6 +3,8 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include <algorithm>
#include <cstring>
#include <memory>
#include <span>
@@ -53,6 +55,74 @@ inline void format_version_str(const uint8_t *version, std::span<char, 20> buffe
version[2]);
}
// Commands and their answers share this header, a little-endian byte count and this footer
static constexpr uint8_t HEADER_FOOTER_SIZE = 4;
static constexpr uint8_t CMD_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xFD, 0xFC, 0xFB, 0xFA};
static constexpr uint8_t CMD_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0x04, 0x03, 0x02, 0x01};
inline bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) {
return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0;
}
// Whole command frame length, or 0 until the count arrives; 32 bit so 0xFFFF cannot wrap to 0
inline uint32_t cmd_frame_length(const uint8_t *buffer, uint8_t count) {
if (count < HEADER_FOOTER_SIZE + 2) {
return 0;
}
return HEADER_FOOTER_SIZE + 2 + encode_value_le<uint16_t>(&buffer[HEADER_FOOTER_SIZE]) + HEADER_FOOTER_SIZE;
}
// Drops the first `from` bytes and anything after them that does not begin a header; returns the bytes kept
inline uint8_t drop_to_next_header(uint8_t *buffer, uint8_t count, uint8_t from, const uint8_t *data_header) {
for (; from < count; from++) {
const size_t n = std::min<size_t>(HEADER_FOOTER_SIZE, count - from);
if (std::memcmp(&buffer[from], CMD_FRAME_HEADER, n) == 0 || std::memcmp(&buffer[from], data_header, n) == 0) {
std::memmove(buffer, &buffer[from], count - from);
return count - from;
}
}
return 0;
}
// Feeds one byte into a frame buffer. A frame opens with the command header or `data_header` and ends at
// `frame_length(buffer, count)` bytes (0 while unknown); `handle_frame(len)` returns false for a footer out
// of place. A dropped or oversized frame calls `on_drop()`. The handler may overwrite `pos`; the splitter
// works from the count it saved.
template<size_t N, typename LengthFn, typename HandleFn, typename DropFn>
void feed_frame_byte(uint8_t (&buffer)[N], uint8_t &pos, uint8_t byte, const uint8_t *data_header,
LengthFn frame_length, HandleFn handle_frame, DropFn on_drop) {
// The first byte picks the header; bytes that do not continue it are dropped
if (pos > 0 && pos < HEADER_FOOTER_SIZE) {
const uint8_t *header = buffer[0] == data_header[0] ? data_header : CMD_FRAME_HEADER;
if (byte != header[pos]) {
pos = 0;
}
}
if (pos == 0 && byte != data_header[0] && byte != CMD_FRAME_HEADER[0]) {
return;
}
buffer[pos++] = byte;
// Drain every whole frame left in the buffer
while (true) {
const uint32_t frame_len = frame_length(buffer, pos);
if (frame_len == 0 || (frame_len <= N && pos < frame_len)) {
return;
}
const uint8_t total = pos;
// Keep only what begins a header: after the frame, or after its first byte when it is dropped
uint8_t from = 1;
if (frame_len <= N && handle_frame(static_cast<uint8_t>(frame_len))) {
from = frame_len;
} else {
on_drop();
}
pos = drop_to_next_header(buffer, total, from, data_header);
if (pos == 0) {
return;
}
}
}
#ifdef USE_SENSOR
template<typename T> using SensorWithDedup = sensor::SensorWithDedup<T>;
#endif
+2 -2
View File
@@ -244,9 +244,9 @@ class MipiSpi : public display::Display,
char hex_buf[format_hex_pretty_size(MIPI_SPI_MAX_CMD_LOG_BYTES)];
// Don't spam the log after setup
if (this->init_sequence_len_ == 0) {
esph_log_v(TAG, "Command %02X, length %zu, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
esph_log_v(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
} else {
esph_log_d(TAG, "Command %02X, length %zu, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
esph_log_d(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
}
if constexpr (BUS_TYPE == BUS_TYPE_QUAD) {
this->enable();
-21
View File
@@ -39,16 +39,13 @@ from esphome.const import (
CONF_REFERENCE_TEMPERATURE,
CONF_SEND_EVERY,
CONF_SEND_FIRST_AT,
CONF_SENSOR_ID,
CONF_STATE_CLASS,
CONF_TEMPERATURE,
CONF_TEMPERATURE_OFFSET,
CONF_TIMEOUT,
CONF_TO,
CONF_TRIGGER_ID,
CONF_TYPE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USE_FAHRENHEIT,
CONF_VALUE,
CONF_WEB_SERVER,
CONF_WINDOW_SIZE,
@@ -313,7 +310,6 @@ OrFilter = sensor_ns.class_("OrFilter", Filter)
CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter)
ToNTCResistanceFilter = sensor_ns.class_("ToNTCResistanceFilter", Filter)
ToNTCTemperatureFilter = sensor_ns.class_("ToNTCTemperatureFilter", Filter)
RHCorrectionFilter = sensor_ns.class_("RHCorrectionFilter", Filter)
CalibratePolynomialFilter = sensor_ns.class_("CalibratePolynomialFilter", Filter)
SensorInRangeCondition = sensor_ns.class_("SensorInRangeCondition", Filter)
ClampFilter = sensor_ns.class_("ClampFilter", Filter)
@@ -1285,23 +1281,6 @@ async def calibrate_ntc_temperature_filter_to_code(config, filter_id):
)
@FILTER_REGISTRY.register(
"rh_correction",
RHCorrectionFilter,
cv.Schema(
{
cv.Required(CONF_TEMPERATURE_OFFSET): cv.templatable(cv.float_),
cv.Required(CONF_SENSOR_ID): cv.use_id(Sensor),
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
}
),
)
async def rh_correction_filter_to_code(config: ConfigType, filter_id: ID) -> MockObj:
sensor = await cg.get_variable(config[CONF_SENSOR_ID])
offset = await cg.templatable(config[CONF_TEMPERATURE_OFFSET], [], cg.float_)
return cg.new_Pvariable(filter_id, offset, sensor, config[CONF_USE_FAHRENHEIT])
def _mean(xs):
return sum(xs) / len(xs)
-42
View File
@@ -493,48 +493,6 @@ optional<float> ToNTCTemperatureFilter::new_value(float value) {
return temp;
}
// RHCorrectionFilter
static constexpr float MAGNUS_B = 17.67f, MAGNUS_C = 243.5f;
static bool rh_correction_temp_in_band(float t) { return t >= -40.0f && t <= 50.0f; }
optional<float> RHCorrectionFilter::new_value(float value) {
if (!this->temperature_sensor_->has_state()) {
if (!this->warned_) {
ESP_LOGW(TAG, "RHCorrectionFilter(%p): no temperature yet, passing humidity through uncorrected", this);
this->warned_ = true;
}
return value;
}
float offset = this->temperature_offset_.value();
float tc = this->temperature_sensor_->get_state();
if (this->use_fahrenheit_) {
tc = fahrenheit_to_celsius(tc);
offset /= 1.8f;
}
const float tnc = tc - offset;
// A NaN temperature or offset is a faulted input: keep the last corrected value
if (std::isnan(tnc)) {
if (!this->warned_) {
ESP_LOGW(TAG, "RHCorrectionFilter(%p): temperature or offset is NaN, dropping humidity updates", this);
this->warned_ = true;
}
return {};
}
if (!rh_correction_temp_in_band(tc) || !rh_correction_temp_in_band(tnc)) {
if (!this->warned_) {
ESP_LOGW(TAG, "RHCorrectionFilter(%p): Temperature out of range -40..50 °C. Tc=%f Tnc=%f", this, tc, tnc);
this->warned_ = true;
}
return value;
}
this->warned_ = false;
const float rh =
clamp(value * std::exp(MAGNUS_B * MAGNUS_C * (tnc - tc) / ((tnc + MAGNUS_C) * (tc + MAGNUS_C))), 0.0f, 100.0f);
ESP_LOGVV(TAG, "RHCorrectionFilter(%p)::new_value(%f) -> %f", this, value, rh);
return rh;
}
// StreamingFilter (base class)
StreamingFilter::StreamingFilter(uint16_t window_size, uint16_t send_first_at)
: window_size_(window_size), send_first_at_(send_first_at) {}
-20
View File
@@ -647,26 +647,6 @@ class ToNTCTemperatureFilter : public Filter {
double c_;
};
/** RH Temperature correction filter
*
* Allows for Relative Humidity Correction when a temperature offset is applied to the temperature sensor.
* Uses the Magnus formula, rh = rh_nc * exp(b*(t_nc/(c+t_nc) - t/(c+t))) with b = 17.67, c = 243.5 °C;
* see https://en.wikipedia.org/wiki/Dew_point
*/
class RHCorrectionFilter : public Filter {
public:
RHCorrectionFilter(TemplatableFn<float> offset, const Sensor *temperature_sensor, bool use_fahrenheit)
: temperature_offset_(offset), temperature_sensor_(temperature_sensor), use_fahrenheit_(use_fahrenheit) {}
optional<float> new_value(float value) override;
protected:
TemplatableFn<float> temperature_offset_;
const Sensor *temperature_sensor_;
bool use_fahrenheit_;
bool warned_{false};
};
/** Base class for streaming filters (batch windows where window_size == send_every).
*
* When window_size equals send_every, we don't need a sliding window.
+13 -13
View File
@@ -1043,7 +1043,7 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa
if (is_on) {
parse_cstr_param_(
request, ESPHOME_F("effect"), call,
static_cast<light::LightCall &(light::LightCall::*)(const char *, size_t)>(&decltype(call)::set_effect));
static_cast<light::LightCall &(light::LightCall::*) (const char *, size_t)>(&decltype(call)::set_effect));
}
DEFER_ACTION(call, call.perform());
@@ -1404,7 +1404,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat
auto call = obj->make_call();
parse_cstr_param_(
request, ESPHOME_F("value"), call,
static_cast<text::TextCall &(text::TextCall::*)(const char *, size_t)>(&decltype(call)::set_value));
static_cast<text::TextCall &(text::TextCall::*) (const char *, size_t)>(&decltype(call)::set_value));
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1464,7 +1464,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM
auto call = obj->make_call();
parse_cstr_param_(
request, ESPHOME_F("option"), call,
static_cast<select::SelectCall &(select::SelectCall::*)(const char *, size_t)>(&decltype(call)::set_option));
static_cast<select::SelectCall &(select::SelectCall::*) (const char *, size_t)>(&decltype(call)::set_option));
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1522,26 +1522,26 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url
// Parse string mode parameters
parse_cstr_param_(
request, ESPHOME_F("mode"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(&decltype(call)::set_mode));
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(&decltype(call)::set_mode));
parse_cstr_param_(request, ESPHOME_F("fan_mode"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(
&decltype(call)::set_fan_mode));
parse_cstr_param_(request, ESPHOME_F("swing_mode"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(
&decltype(call)::set_swing_mode));
parse_cstr_param_(request, ESPHOME_F("preset"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(
&decltype(call)::set_preset));
// Parse temperature parameters
// static_cast needed to disambiguate overloaded setters (float vs optional<float>)
using ClimateCall = decltype(call);
parse_num_param_(request, ESPHOME_F("target_temperature_high"), call,
static_cast<ClimateCall &(ClimateCall::*)(float)>(&ClimateCall::set_target_temperature_high));
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature_high));
parse_num_param_(request, ESPHOME_F("target_temperature_low"), call,
static_cast<ClimateCall &(ClimateCall::*)(float)>(&ClimateCall::set_target_temperature_low));
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature_low));
parse_num_param_(request, ESPHOME_F("target_temperature"), call,
static_cast<ClimateCall &(ClimateCall::*)(float)>(&ClimateCall::set_target_temperature));
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature));
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1818,8 +1818,8 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques
auto call = obj->make_call();
parse_cstr_param_(
request, ESPHOME_F("code"), call,
static_cast<alarm_control_panel::AlarmControlPanelCall &(alarm_control_panel::AlarmControlPanelCall::*)(
const char *, size_t)>(&decltype(call)::set_code));
static_cast<alarm_control_panel::AlarmControlPanelCall &(
alarm_control_panel::AlarmControlPanelCall::*) (const char *, size_t)>(&decltype(call)::set_code));
static constexpr ActionEntry<alarm_control_panel::AlarmControlPanelCall> ACTIONS[] PROGMEM = {
{"disarm", &alarm_control_panel::AlarmControlPanelCall::disarm},
@@ -1893,7 +1893,7 @@ void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, cons
// Parse mode parameter
parse_cstr_param_(
request, ESPHOME_F("mode"), base_call,
static_cast<water_heater::WaterHeaterCall &(water_heater::WaterHeaterCall::*)(const char *, size_t)>(
static_cast<water_heater::WaterHeaterCall &(water_heater::WaterHeaterCall::*) (const char *, size_t)>(
&water_heater::WaterHeaterCall::set_mode));
// Parse temperature parameters
@@ -5,7 +5,7 @@ namespace esphome::xiaomi_hhccpot002 {
ESPHOME_LOG_TAG(TAG, "xiaomi_hhccpot002");
void XiaomiHHCCPOT002::dump_config() {
void XiaomiHHCCPOT002 ::dump_config() {
ESP_LOGCONFIG(TAG, "Xiaomi HHCCPOT002");
LOG_SENSOR(" ", "Moisture", this->moisture_);
LOG_SENSOR(" ", "Conductivity", this->conductivity_);
+2 -4
View File
@@ -59,15 +59,13 @@ template<typename T> class PlainAtomic {
constexpr PlainAtomic(T value) : value_(value) {}
T load(std::memory_order order = std::memory_order_seq_cst) const {
T value = value_;
if (order != std::memory_order_relaxed) {
if (order != std::memory_order_relaxed)
ESPHOME_LFQ_COMPILER_BARRIER(); // acquire: later reads may not hoist above this load
}
return value;
}
void store(T value, std::memory_order order = std::memory_order_seq_cst) {
if (order != std::memory_order_relaxed) {
if (order != std::memory_order_relaxed)
ESPHOME_LFQ_COMPILER_BARRIER(); // release: earlier writes may not sink below this store
}
value_ = value;
}
T fetch_add(T amount, std::memory_order /*order*/ = std::memory_order_seq_cst) {
+1 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.2.0
esptool==5.6.0
click==8.5.0
aioesphomeapi==46.19.0
aioesphomeapi==46.18.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5
puremagic==2.2.0
+2 -2
View File
@@ -1,4 +1,4 @@
# Useful stuff when working in a development environment
clang-format==23.1.3 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
clang-tidy==23.1.3
clang-format==22.1.8 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
clang-tidy==22.1.8
yamllint==1.38.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
+14 -8
View File
@@ -319,15 +319,21 @@ def run_tidy(
disabled_checks = []
if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx")):
# MMIO peripheral access on these bare-metal platforms is all
# fixed-address. clang-tidy 23 moved the checker under optin.
disabled_checks += [
"-clang-analyzer-core.FixedAddressDereference",
"-clang-analyzer-optin.core.FixedAddressDereference",
]
# fixed-address.
disabled_checks.append("-clang-analyzer-core.FixedAddressDereference")
if args.environment.startswith("host"):
# glibc's assert() expands to a call the check mistakes for a
# constant condition.
disabled_checks += ["-misc-static-assert", "-cert-dcl03-c"]
if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx", "host")):
# bugprone-pointer-arithmetic-on-polymorphic-object (and its
# cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher
# recursion on lvgl_esphome.h under the RP2 defines and on the
# all-include file under the host defines.
disabled_checks += [
"-bugprone-pointer-arithmetic-on-polymorphic-object",
"-cert-ctr56-cpp",
]
if os.path.normpath(path) in ANALYZER_SKIPPED_FILES:
disabled_checks.append("-clang-analyzer-*")
if disabled_checks:
@@ -517,7 +523,7 @@ def main():
failed_files = []
timings = []
try:
executable = get_binary("clang-tidy", 23)
executable = get_binary("clang-tidy", 22)
task_queue = queue.Queue(args.jobs)
lock = threading.Lock()
for _ in range(args.jobs):
@@ -565,13 +571,13 @@ def main():
try:
try:
subprocess.call(
["clang-apply-replacements-23", tmpdir], close_fds=False
["clang-apply-replacements-22", tmpdir], close_fds=False
)
except FileNotFoundError:
subprocess.call(["clang-apply-replacements", tmpdir], close_fds=False)
except FileNotFoundError:
print(
"Error please install clang-apply-replacements-23 or clang-apply-replacements.\n",
"Error please install clang-apply-replacements-22 or clang-apply-replacements.\n",
file=sys.stderr,
)
except:
@@ -621,69 +621,6 @@ def test_uc8279_width_must_be_a_multiple_of_8(
)
GDEY029Z95 = "goodisplay-gdey029z95-2.9"
def _gdey029z95_config(**overrides: Any) -> ConfigType:
config: ConfigType = {
"id": "test_display",
"model": GDEY029Z95,
CONF_DC_PIN: 21,
CONF_RESET_PIN: 22,
CONF_BUSY_PIN: 23,
}
config.update(overrides)
return config
def test_gdey029z95_uses_the_good_display_driver() -> None:
"""The GDEY029Z95 uses EPaperGoodDisplay3C with the WeAct init sequence."""
model = MODELS[GDEY029Z95.upper()]
assert model.class_name == "EPaperGoodDisplay3C"
assert model.get_init_sequence({}) == (
(0x01, 0x27, 0x01, 0x00),
(0x11, 0x03),
(0x3C, 0x05),
(0x18, 0x80),
(0x21, 0x00, 0x80),
)
@pytest.mark.parametrize("pin", [CONF_RESET_PIN, CONF_BUSY_PIN])
def test_gdey029z95_requires_reset_and_busy_pins(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
pin: str,
) -> None:
"""Only a hardware reset wakes the panel from deep sleep, and refreshes are timed by busy."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = _gdey029z95_config()
del config[pin]
with pytest.raises(cv.Invalid, match=pin):
run_schema_validation(config)
def test_gdey029z95_has_no_partial_update(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
run_schema_validation(_gdey029z95_config())
with pytest.raises(cv.Invalid, match="does not support partial update"):
run_schema_validation(_gdey029z95_config(full_update_every=2))
def test_uc8279_x4pro_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -1,24 +0,0 @@
esphome:
name: test
esp32:
board: esp32dev
sensor:
- platform: template
id: temperature
filters:
- offset: -7.3
- platform: template
id: constant_offset
filters:
- rh_correction:
sensor_id: temperature
temperature_offset: -7.3
- platform: template
id: templated_offset
filters:
- rh_correction:
sensor_id: temperature
temperature_offset: !lambda return -7.3;
use_fahrenheit: true
@@ -1,60 +0,0 @@
"""The rh_correction filter: schema and the constructor call it generates."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.components.sensor import validate_filters
import esphome.config_validation as cv
@pytest.mark.parametrize(
"filters",
[
pytest.param([{"rh_correction": {"sensor_id": "t"}}], id="no_offset"),
pytest.param([{"rh_correction": {"temperature_offset": -7.3}}], id="no_sensor"),
pytest.param(
[
{
"rh_correction": {
"sensor_id": "t",
"temperature_offset": -7.3,
"use_fahrenheit": "maybe",
}
}
],
id="bad_fahrenheit",
),
],
)
def test_rh_correction_rejects_incomplete_config(filters: list[dict]) -> None:
with pytest.raises(cv.Invalid):
validate_filters(filters)
def test_rh_correction_defaults_to_celsius() -> None:
config = validate_filters(
[{"rh_correction": {"sensor_id": "t", "temperature_offset": -7.3}}]
)
assert config[0]["rh_correction"]["use_fahrenheit"] is False
def test_rh_correction_codegen(
generate_main: Callable[[str | Path], str],
) -> None:
main_cpp = generate_main("tests/component_tests/sensor/rh_correction.yaml")
# A constant offset is wrapped in a stateless lambda so the filter stores a function pointer
assert re.search(
r"sensor::RHCorrectionFilter\(\[\]\(\) -> float \{\s*return -7\.3f;\s*\}, "
r"temperature, false\);\s*constant_offset->add_filter\(",
main_cpp,
)
# A templated offset keeps the user's lambda body and passes use_fahrenheit through
assert re.search(
r"sensor::RHCorrectionFilter\(\[\]\(\) -> float \{[^}]*return -7\.3;[^}]*\}, "
r"temperature, true\);\s*templated_offset->add_filter\(",
main_cpp,
)
-8
View File
@@ -100,14 +100,6 @@ button:
id: climate_test_thermostat
custom_fan_mode: !lambda return "quiet";
custom_preset: !lambda return "Default";
# Inlined lambdas returning a StringRef, the shape an api action string variable takes (issue #21327)
- platform: template
name: "Climate Control StringRef Lambdas"
on_press:
- climate.control:
id: climate_test_thermostat
custom_fan_mode: !lambda return id(climate_test_thermostat).get_custom_fan_mode();
custom_preset: !lambda return id(climate_test_thermostat).get_custom_preset();
# Exercise climate.control inside a trigger with non-empty Ts (number on_value
# passes float).
@@ -1,164 +0,0 @@
#include <gtest/gtest.h>
#include <utility>
#include <vector>
#include "../common.h"
#include "esphome/components/epaper_spi/epaper_goodisplay_3c.h"
namespace esphome::epaper_spi::testing {
namespace {
class TestableGoodDisplay3C : public EPaperGoodDisplay3C {
public:
TestableGoodDisplay3C(uint16_t width, uint16_t height) : EPaperGoodDisplay3C("test", width, height, nullptr, 0) {}
void install(spi::SPIDelegate *delegate) {
this->delegate_ = delegate;
this->set_dc_pin(&this->dc);
ASSERT_TRUE(this->init_buffer_(this->buffer_length_));
}
void draw(int x, int y, Color color) { this->draw_pixel_at(x, y, color); }
/// Both planes of one push; returns how many calls it took.
int run_push() {
int calls = 1;
while (!this->transfer_data())
calls++;
return calls;
}
/// A whole update with the frame already drawn, run through loop() as the main loop would.
void run_update() {
this->set_auto_clear(false);
this->state_ = EPaperState::UPDATE;
const uint32_t deadline = millis() + 2000;
while (this->state_ != EPaperState::IDLE) {
ASSERT_LT(millis(), deadline) << "update did not finish";
this->loop();
}
}
using EPaperGoodDisplay3C::REFRESH_START_MS;
RecordingPin dc;
};
/// Also notes when each command byte was sent.
class TimedCommandDelegate : public RecordingDelegate {
public:
using RecordingDelegate::RecordingDelegate;
void write_array(const uint8_t *ptr, size_t length) override {
if (!this->dc_->level) {
for (size_t i = 0; i != length; i++)
this->sent.emplace_back(ptr[i], millis());
}
RecordingDelegate::write_array(ptr, length);
}
/// When the n-th (from 0) copy of a command was sent.
uint32_t time_of(uint8_t command, int n = 0) const {
for (const auto &[byte, time] : this->sent) {
if (byte == command && n-- == 0)
return time;
}
ADD_FAILURE() << "command " << int(command) << " not sent";
return 0;
}
std::vector<std::pair<uint8_t, uint32_t>> sent;
};
using Bytes = std::vector<uint8_t>;
const Color RED(255, 0, 0);
/// One row of 16 pixels: white, black, red, then white. The red plane goes first as 1=red, then the black/white
/// plane as 1=white with red pixels sent as 0.
TEST(EPaperGoodDisplay3CTransfer, PixelsReachThePanel) {
TestableGoodDisplay3C display(16, 1);
RecordingDelegate bus(&display.dc);
display.install(&bus);
display.fill(Color::WHITE);
display.draw(1, 0, Color::BLACK);
display.draw(2, 0, RED);
EXPECT_EQ(display.run_push(), 1);
EXPECT_EQ(bus.commands, (Bytes{0x44, 0x45, 0x4E, 0x4F, 0x26, 0x24}));
EXPECT_EQ(bus.data[0x44], (Bytes{0x00, 0x01}));
EXPECT_EQ(bus.data[0x45], (Bytes{0x00, 0x00, 0x00, 0x00}));
EXPECT_EQ(bus.data[0x24], (Bytes{0x9F, 0xFF}));
EXPECT_EQ(bus.data[0x26], (Bytes{0x20, 0x00}));
}
/// A push that yields to the main loop after every chunk sends the same commands and bytes as one that runs to
/// completion in one call, and a second push starts again from the window setup.
TEST(EPaperGoodDisplay3CTransfer, ResumedPushMatches) {
// 128x20 pixels is 320 bytes per plane, so each plane takes three chunks of up to MAX_TRANSFER_SIZE.
constexpr int width = 128;
constexpr int height = 20;
const Color colors[] = {Color::WHITE, Color::BLACK, RED};
TestableGoodDisplay3C reference(width, height);
RecordingDelegate reference_bus(&reference.dc);
reference.install(&reference_bus);
TestableGoodDisplay3C display(width, height);
// Every data write takes longer than MAX_TRANSFER_TIME, so the push yields after each chunk.
RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1);
display.install(&bus);
for (int y = 0; y != height; y++) {
for (int x = 0; x != width; x++) {
reference.draw(x, y, colors[(x * 7 + y * 3) % 3]);
display.draw(x, y, colors[(x * 7 + y * 3) % 3]);
}
}
EXPECT_EQ(reference.run_push(), 1);
EXPECT_GT(display.run_push(), 2);
EXPECT_EQ(bus.commands, reference_bus.commands);
EXPECT_EQ(bus.data[0x24].size(), 320u);
EXPECT_EQ(bus.data[0x24], reference_bus.data[0x24]);
EXPECT_EQ(bus.data[0x26], reference_bus.data[0x26]);
bus.clear();
display.run_push();
EXPECT_EQ(bus.commands, reference_bus.commands);
}
/// A whole update sends no power-on (0x22 0xF8) before the refresh (0x22 0xF7): with it, this panel does not
/// refresh. Power off (0x83) and deep sleep follow.
TEST(EPaperGoodDisplay3CTransfer, UpdateHasNoSeparatePowerOn) {
TestableGoodDisplay3C display(16, 1);
RecordingDelegate bus(&display.dc);
display.install(&bus);
display.fill(RED);
display.run_update();
EXPECT_EQ(bus.commands, (Bytes{0x44, 0x45, 0x4E, 0x4F, 0x26, 0x24, 0x4E, 0x4F, 0x22, 0x20, 0x22, 0x20, 0x10}));
EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0xFF}));
EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0x00}));
EXPECT_EQ(bus.data[0x22], (Bytes{0xF7, 0x83}));
EXPECT_EQ(bus.data[0x10], (Bytes{0x01}));
}
/// Busy only goes high a little after master activation (0x20), so power off is not sent until REFRESH_START_MS
/// after the refresh starts.
TEST(EPaperGoodDisplay3CTransfer, WaitsForBusyAfterTheRefreshStarts) {
TestableGoodDisplay3C display(16, 1);
TimedCommandDelegate bus(&display.dc);
display.install(&bus);
display.fill(RED);
display.run_update();
// The first 0x20 starts the refresh; the second 0x22 selects power off.
EXPECT_GE(bus.time_of(0x22, 1) - bus.time_of(0x20, 0), TestableGoodDisplay3C::REFRESH_START_MS);
}
} // namespace
} // namespace esphome::epaper_spi::testing
@@ -209,27 +209,6 @@ display:
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# Good Display GDEY029Z95 2.9" 3-color e-paper (128x296, SSD1680)
- platform: epaper_spi
spi_id: spi_bus
model: goodisplay-gdey029z95-2.9
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# WeAct 4.2" 3-color e-paper (400x300, SSD1683)
- platform: epaper_spi
spi_id: spi_bus
@@ -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
-11
View File
@@ -1,11 +0,0 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config: ConfigType) -> None:
# filter.cpp only compiles with the define the real to_code emits for configured filters
cg.add_define("USE_SENSOR_FILTER")
manifest.to_code = to_code_testing
-18
View File
@@ -254,21 +254,3 @@ sensor:
lambda: return 42.0;
update_interval: 1s
state_class: "measurement_angle"
# Testing rh_correction filter
- platform: copy
source_id: source_sensor
name: "RH Correction Filter Fahrenheit"
filters:
- rh_correction:
sensor_id: source_sensor
temperature_offset: -10.0
use_fahrenheit: true
- platform: copy
source_id: source_sensor
name: "RH Correction Filter Template"
filters:
- rh_correction:
sensor_id: source_sensor
temperature_offset: !lambda return -10.0;
-66
View File
@@ -1,66 +0,0 @@
#include <gtest/gtest.h>
#include <cmath>
#include "esphome/components/sensor/filter.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::sensor::testing {
// 40 % read 7.3 °C warm at a true 25 °C, in single precision Magnus arithmetic
static constexpr float CORRECTED = 61.13f;
struct RhFixture {
Sensor temperature;
RHCorrectionFilter filter;
RhFixture(float (*offset)(), bool use_fahrenheit = false) : filter(offset, &this->temperature, use_fahrenheit) {}
};
TEST(RHCorrectionFilter, CorrectsForTheOffset) {
RhFixture f([]() -> float { return -7.3f; });
f.temperature.publish_state(25.0f);
EXPECT_NEAR(f.filter.new_value(40.0f).value_or(NAN), CORRECTED, 0.05f);
}
TEST(RHCorrectionFilter, PositiveOffsetLowersTheHumidity) {
RhFixture f([]() -> float { return 7.3f; });
f.temperature.publish_state(25.0f);
EXPECT_LT(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
}
TEST(RHCorrectionFilter, FahrenheitReadingsGiveTheSameResult) {
// 77 °F is 25 °C and -13.14 °F is -7.3 °C of offset
RhFixture f([]() -> float { return -13.14f; }, true);
f.temperature.publish_state(77.0f);
EXPECT_NEAR(f.filter.new_value(40.0f).value_or(NAN), CORRECTED, 0.05f);
}
TEST(RHCorrectionFilter, ClampsToOneHundredPercent) {
RhFixture f([]() -> float { return -7.3f; });
f.temperature.publish_state(25.0f);
EXPECT_FLOAT_EQ(f.filter.new_value(90.0f).value_or(NAN), 100.0f);
}
TEST(RHCorrectionFilter, PassesThroughWithoutAUsableTemperature) {
RhFixture f([]() -> float { return -7.3f; });
// No temperature reading yet
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
// Out of the -40..50 °C band
f.temperature.publish_state(60.0f);
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
// The band also covers the reconstructed enclosure temperature
f.temperature.publish_state(45.0f);
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
}
TEST(RHCorrectionFilter, DropsTheUpdateOnNaN) {
RhFixture f([]() -> float { return -7.3f; });
f.temperature.publish_state(NAN);
EXPECT_FALSE(f.filter.new_value(40.0f).has_value());
RhFixture nan_offset([]() -> float { return NAN; });
nan_offset.temperature.publish_state(25.0f);
EXPECT_FALSE(nan_offset.filter.new_value(40.0f).has_value());
}
} // namespace esphome::sensor::testing
@@ -409,10 +409,6 @@ cover:
id: template_cover_actions
position: 0.0
tilt: 0.0
toggle_action:
- cover.template.publish:
id: template_cover_actions
current_operation: IDLE
stop_action:
- cover.template.publish:
id: template_cover_actions
@@ -1,26 +0,0 @@
esphome:
name: api-cover-toggle-test
host:
api:
logger:
level: DEBUG
cover:
- platform: template
name: "Toggle Cover"
id: toggle_cover
toggle_action:
- cover.template.publish:
id: toggle_cover
position: 0.5
stop_action:
- cover.template.publish:
id: toggle_cover
position: 0.25
- platform: template
name: "Plain Cover"
id: plain_cover
stop_action:
- cover.template.publish:
id: plain_cover
position: 0.25
@@ -1,102 +0,0 @@
esphome:
name: test-rh-correction
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
sensor:
- platform: template
name: "Temperature C"
id: temperature_c
accuracy_decimals: 1
- platform: template
name: "Temperature F"
id: temperature_f
accuracy_decimals: 1
- platform: template
name: "Humidity Source"
id: humidity_source
accuracy_decimals: 1
- platform: copy
source_id: humidity_source
name: "RH Celsius"
id: rh_celsius
accuracy_decimals: 2
filters:
- rh_correction:
sensor_id: temperature_c
temperature_offset: -7.3
- platform: copy
source_id: humidity_source
name: "RH Fahrenheit"
id: rh_fahrenheit
accuracy_decimals: 2
filters:
- rh_correction:
sensor_id: temperature_f
temperature_offset: -13.14
use_fahrenheit: true
script:
- id: test_rh_correction
then:
# Both temperature sensors read 25 °C after a -7.3 °C (-13.14 °F) offset
- sensor.template.publish:
id: temperature_c
state: 25.0
- sensor.template.publish:
id: temperature_f
state: 77.0
- sensor.template.publish:
id: humidity_source
state: 40.0
- delay: 20ms
# Clamped to 100 %
- sensor.template.publish:
id: humidity_source
state: 90.0
- delay: 20ms
# Out of band: passes through unchanged
- sensor.template.publish:
id: temperature_c
state: 60.0
- sensor.template.publish:
id: temperature_f
state: 140.0
- sensor.template.publish:
id: humidity_source
state: 40.0
- delay: 20ms
# NaN temperature: the update is dropped
- sensor.template.publish:
id: temperature_c
state: !lambda "return NAN;"
- sensor.template.publish:
id: temperature_f
state: !lambda "return NAN;"
- sensor.template.publish:
id: humidity_source
state: 50.0
- delay: 20ms
- sensor.template.publish:
id: temperature_c
state: 25.0
- sensor.template.publish:
id: temperature_f
state: 77.0
- sensor.template.publish:
id: humidity_source
state: 40.0
button:
- platform: template
name: "Test RH Correction"
on_press:
- script.execute: test_rh_correction
@@ -30,7 +30,7 @@ uart_mock:
# Target state: 0x03 (moving + still)
# detection_distance = 100 (LD2412 computes from moving target when MOVE_BITMASK set)
#
# Frame layout (24 bytes):
# Frame layout (23 bytes):
# [0-3] F4 F3 F2 F1 = data frame header
# [4-5] 0D 00 = length 13
# [6] 02 = data type (normal)
@@ -41,10 +41,9 @@ uart_mock:
# [12-13] 78 00 = still distance 120 (0x0078)
# [14] 19 = still energy 25
# [15-16] 64 00 = detect distance bytes (ignored by LD2412 code)
# [17] 00 = padding
# [18] 55 = data footer marker
# [19] 00 = CRC/check
# [20-23] F8 F7 F6 F5 = data frame footer
# [17] 55 = data footer marker
# [18] 00 = CRC/check
# [19-22] F8 F7 F6 F5 = data frame footer
- delay: 100ms
inject_rx:
[
@@ -57,7 +56,6 @@ uart_mock:
0x78, 0x00,
0x19,
0x64, 0x00,
0x00,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
@@ -69,17 +67,13 @@ uart_mock:
inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22]
# Phase 3 (t=400ms): Truncated frame (header + partial data, no footer)
# Starts with valid data frame header so parser accepts it.
# After this, buffer_pos_ = 8.
# Starts with a valid data frame header, so the parser waits for the 23 bytes its length announces.
- delay: 100ms
inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x0D, 0x00, 0x02, 0xAA]
# Phase 4 (t=600ms): Overflow - inject 60 bytes of 0xFF (MAX_LINE_LENGTH=54)
# Buffer has 8 bytes from phase 3 (garbage in phase 2 was rejected).
# Overflow math: buffer_pos_ starts at 8, overflow triggers when
# buffer_pos_ reaches 53 (MAX_LINE_LENGTH - 1). Need 45 more bytes to
# fill positions 8-52, then byte 46 triggers overflow. After overflow,
# buffer_pos_ = 0 and remaining 0xFF bytes are rejected (don't match header).
# Phase 4 (t=600ms): 60 bytes of 0xFF
# The first 15 complete the truncated frame's 23 bytes without its footer, so it is dropped
# with an 'Invalid length' warning; the rest cannot start a header and are dropped too.
- delay: 200ms
inject_rx:
[
@@ -91,8 +85,8 @@ uart_mock:
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 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.
# Moving target: 50cm, energy 100
# Still target: 75cm, energy 80
# detection_distance = 50 (moving target distance, since MOVE_BITMASK set)
@@ -108,7 +102,72 @@ uart_mock:
0x4B, 0x00,
0x50,
0x32, 0x00,
0x00,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 6 (t=3200ms): Values that spell the command footer 04 03 02 01
# Moving distance 772 (0x0304), moving energy 2, still distance 1: bytes 9 to 12 are the
# command frame footer, which must not end the frame early.
# Still energy 5, detection_distance = 772 (moving target distance, since MOVE_BITMASK set).
# The sensors hold and throttle values for a second, so the gap lets the phase 5 states
# reach the test before these replace them.
- delay: 2500ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x0D, 0x00,
0x02, 0xAA,
0x03,
0x04, 0x03,
0x02,
0x01, 0x00,
0x05,
0x04, 0x03,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 7 (t=5700ms): Truncated frame merged with a complete one
# The eight header bytes announce a 23 byte frame, so its window takes in the first 15 bytes of
# the valid frame that follows. The merged frame has no footer where the count says, so it is
# dropped and the bytes after its header are replayed, which recovers the valid frame.
# Moving distance 400, moving energy 7, still distance 200, still energy 9, detection_distance = 400
- delay: 2500ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1, 0x0D, 0x00, 0x02, 0xAA,
0xF4, 0xF3, 0xF2, 0xF1,
0x0D, 0x00,
0x02, 0xAA,
0x03,
0x90, 0x01,
0x07,
0xC8, 0x00,
0x09,
0x90, 0x01,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 8 (t=8200ms): Impossible counts, then a complete frame
# A command header counting 0xFFF6 bytes (65536 in total, which must not wrap to 0) and one counting
# 0xFFFF are each dropped as soon as the count arrives, and the valid frame after them is parsed.
# Moving distance 600, moving energy 11, still distance 300, still energy 13, detection distance 600
- delay: 2500ms
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0xF6, 0xFF,
0xFD, 0xFC, 0xFB, 0xFA, 0xFF, 0xFF,
0xF4, 0xF3, 0xF2, 0xF1,
0x0D, 0x00,
0x02, 0xAA,
0x03,
0x58, 0x02,
0x0B,
0x2C, 0x01,
0x0D,
0x58, 0x02,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
@@ -0,0 +1,88 @@
esphome:
name: uart-mock-ld2412-short-answers
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
# Answers cut short, so the footer sits where their values would be, each followed by a whole one
uart_mock:
id: mock_uart
baud_rate: 256000
auto_start: false
injections:
# Version answer cut after two version bytes
- delay: 100ms
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xA0, 0x01, 0x00, 0x00,
0x00, 0x01, 0x07, 0x02,
0x04, 0x03, 0x02, 0x01,
]
# Basic configuration answer cut after the minimum gate 7
- delay: 100ms
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x05, 0x00, 0x12, 0x01, 0x00, 0x00,
0x07,
0x04, 0x03, 0x02, 0x01,
]
# Whole version answer: firmware type 1, version 2.07.22062416
- delay: 100ms
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x0C, 0x00, 0xA0, 0x01, 0x00, 0x00,
0x00, 0x01, 0x07, 0x02, 0x16, 0x24, 0x06, 0x22,
0x04, 0x03, 0x02, 0x01,
]
# Whole basic configuration answer: gates 3 to 9, timeout 10 s, output pin level low
- delay: 100ms
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x09, 0x00, 0x12, 0x01, 0x00, 0x00,
0x03, 0x09, 0x0A, 0x00, 0x01,
0x04, 0x03, 0x02, 0x01,
]
ld2412:
id: ld2412_dev
uart_id: mock_uart
number:
- platform: ld2412
ld2412_id: ld2412_dev
min_distance_gate:
name: "Min Distance Gate"
max_distance_gate:
name: "Max Distance Gate"
timeout:
name: "Timeout"
text_sensor:
- platform: ld2412
ld2412_id: ld2412_dev
version:
name: "Version"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(mock_uart).start_scenario();'
@@ -37,7 +37,7 @@ uart_mock:
0x04, 0x00, # length 4
0xFE, 0x01, # answer to command 0x00FE
0x00, 0x00, # status: success
0x04, 0x03, 0x02, 0x01, # its footer; the merged 40-byte frame gets dropped
0x04, 0x03, 0x02, 0x01, # its footer; the 28 bytes the answer announced hold no footer, so it is dropped
]
# A complete answer after setup proves the parser recovered
injections:
+5 -12
View File
@@ -1,7 +1,7 @@
"""Minimal plaintext native-api client over a raw socket.
Reads only when told to, so tests control when the TCP pipe backs up toward
the device; message types are counted and each frame is kept for inspection.
the device; payloads are skipped and only message types are counted.
"""
from __future__ import annotations
@@ -59,17 +59,15 @@ class FrameParser:
def __init__(self) -> None:
self._buf = bytearray()
self.frames: list[tuple[int, bytes]] = []
def feed(self, data: bytes) -> list[int]:
self._buf.extend(data)
types: list[int] = []
while (frame := self._try_parse()) is not None:
self.frames.append(frame)
types.append(frame[0])
while (msg_type := self._try_parse()) is not None:
types.append(msg_type)
return types
def _try_parse(self) -> tuple[int, bytes] | None:
def _try_parse(self) -> int | None:
buf = self._buf
if not buf:
return None
@@ -82,9 +80,8 @@ class FrameParser:
msg_type, pos = type_decoded
if len(buf) - pos < size:
return None
payload = bytes(buf[pos : pos + size])
del buf[: pos + size]
return msg_type, payload
return msg_type
class RawApiClient:
@@ -148,9 +145,5 @@ class RawApiClient:
await asyncio.wait_for(_read_loop(), timeout)
def frames(self, msg_type: int) -> list[bytes]:
"""Payloads of every frame of msg_type received so far."""
return [payload for kind, payload in self._parser.frames if kind == msg_type]
def close(self) -> None:
self._sock.close()
-129
View File
@@ -1,129 +0,0 @@
"""The native API advertises cover toggle support and runs the toggle action on request."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
import struct
from aioesphomeapi import CoverInfo, CoverState, EntityState, api_pb2
import pytest
from .log_utils import LineWaiter
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient, decode_varint, encode_varint
from .state_utils import InitialStateHelper, StateWaiter, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
COVER_COMMAND = MESSAGE_TYPE_OF[api_pb2.CoverCommandRequest]
LIST_COVER = MESSAGE_TYPE_OF[api_pb2.ListEntitiesCoverResponse]
LIST_DONE = MESSAGE_TYPE_OF[api_pb2.ListEntitiesDoneResponse]
WIRE_VARINT, WIRE_LENGTH, WIRE_FIXED32 = 0, 2, 5
# ListEntitiesCoverResponse fields; the installed client library does not know supports_toggle yet
FIELD_ENTITY_KEY, FIELD_SUPPORTS_TOGGLE = 2, 15
# CoverCommandRequest fields
FIELD_KEY, FIELD_STOP, FIELD_TOGGLE = 1, 8, 11
def tag(field: int, wire_type: int) -> bytes:
return encode_varint((field << 3) | wire_type)
def decode_fields(payload: bytes) -> dict[int, list[int | bytes]]:
"""Split a protobuf payload into field number -> raw values."""
fields: dict[int, list[int | bytes]] = {}
buf = bytearray(payload)
pos = 0
while pos < len(buf):
key, pos = decode_varint(buf, pos)
field, wire_type = key >> 3, key & 0x07
if wire_type == WIRE_VARINT:
value, pos = decode_varint(buf, pos)
elif wire_type == WIRE_LENGTH:
length, pos = decode_varint(buf, pos)
value = bytes(buf[pos : pos + length])
pos += length
elif wire_type == WIRE_FIXED32:
value = struct.unpack_from("<I", buf, pos)[0]
pos += 4
else:
raise AssertionError(f"unexpected wire type {wire_type} for field {field}")
fields.setdefault(field, []).append(value)
return fields
@pytest.mark.asyncio
async def test_api_cover_toggle(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
unused_tcp_port: int,
) -> None:
lines = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=lines.callback),
api_client_connected() as client,
RawApiClient(unused_tcp_port) as raw,
):
entities, _ = await client.list_entities_services()
toggle_cover = require_entity(entities, "toggle_cover", CoverInfo, exact=True)
plain_cover = require_entity(entities, "plain_cover", CoverInfo, exact=True)
positions: dict[int, list[float]] = {toggle_cover.key: [], plain_cover.key: []}
waiter = StateWaiter()
def on_state(state: EntityState) -> None:
if isinstance(state, CoverState) and state.key in positions:
positions[state.key].append(state.position)
waiter.on_state(state)
def position_is(key: int, value: float) -> Callable[[EntityState], bool]:
return lambda s: (
isinstance(s, CoverState)
and s.key == key
and s.position == pytest.approx(value, abs=0.01)
)
initial = InitialStateHelper(entities)
client.subscribe_states(initial.on_state_wrapper(on_state))
await initial.wait_for_initial_states()
# Capability discovery: only the cover with a toggle_action advertises toggle
await raw.connect()
await raw.send_message(api_pb2.ListEntitiesRequest())
await raw.read_until_frame(LIST_DONE)
supports_toggle = {}
for payload in raw.frames(LIST_COVER):
fields = decode_fields(payload)
key = fields[FIELD_ENTITY_KEY][0]
supports_toggle[key] = bool(fields.get(FIELD_SUPPORTS_TOGGLE, [0])[0])
assert supports_toggle == {toggle_cover.key: True, plain_cover.key: False}
def key_field(key: int) -> bytes:
return tag(FIELD_KEY, WIRE_FIXED32) + struct.pack("<I", key)
toggle = tag(FIELD_TOGGLE, WIRE_VARINT) + b"\x01"
stop = tag(FIELD_STOP, WIRE_VARINT) + b"\x01"
# A toggle request runs the toggle action, which publishes 0.5
toggled = waiter.expect(
position_is(toggle_cover.key, 0.5), label="toggle action"
)
await raw.send_raw(COVER_COMMAND, key_field(toggle_cover.key) + toggle)
await toggled
# A cover without toggle_action drops the request with a warning; its state does not change
await raw.send_raw(COVER_COMMAND, key_field(plain_cover.key) + toggle)
await lines.wait_for("Plain Cover", "toggle unsupported", timeout=2.0)
await asyncio.sleep(0.5)
assert all(p == pytest.approx(1.0) for p in positions[plain_cover.key])
# Stop and toggle in one request: stop wins and the toggle action does not run
before = len(positions[toggle_cover.key])
stopped = waiter.expect(
position_is(toggle_cover.key, 0.25), label="stop action"
)
await raw.send_raw(COVER_COMMAND, key_field(toggle_cover.key) + stop + toggle)
await stopped
await asyncio.sleep(0.5)
after = positions[toggle_cover.key][before:]
assert all(p == pytest.approx(0.25, abs=0.01) for p in after), after
@@ -1,55 +0,0 @@
"""Test the sensor rh_correction filter numerically."""
from __future__ import annotations
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
# 40 % at 25 °C, measured 7.3 °C warmer, in single precision Magnus arithmetic
CORRECTED = pytest.approx(61.13, abs=0.05)
# Corrected, clamped, passed through out of band, corrected again; the NaN step is dropped
EXPECTED = [CORRECTED, 100.0, 40.0, CORRECTED]
@pytest.mark.asyncio
async def test_sensor_filters_rh_correction(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
collector = SensorStateCollector(sensor_names=["rh_celsius", "rh_fahrenheit"])
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(
initial_state_helper.on_state_wrapper(collector.on_state)
)
await initial_state_helper.wait_for_initial_states()
done = collector.add_waiter(
lambda: all(
len(values) == len(EXPECTED)
for values in collector.sensor_states.values()
)
)
button = require_entity(entities, "test_rh_correction", ButtonInfo, exact=True)
client.button_command(button.key)
await wait_or_fail(
done,
2.0,
"Timed out waiting for the corrected humidity values",
values=collector.sensor_states,
)
assert collector.sensor_states["rh_celsius"] == EXPECTED
# The same readings in °F give the same corrected humidity
assert collector.sensor_states["rh_fahrenheit"] == EXPECTED
+108 -15
View File
@@ -5,9 +5,12 @@ test_uart_mock_ld2412 (normal mode):
1. Happy path - valid data frame publishes correct sensor values
2. Garbage resilience - random bytes don't crash the component
3. Truncated frame handling - partial frame doesn't corrupt state
4. Buffer overflow recovery - overflow resets the parser
5. Post-overflow parsing - next valid frame after overflow is parsed correctly
6. TX logging - verifies LD2412 sends expected setup commands
4. Truncated frame recovery - the frame is dropped once its length is reached
5. Post-garbage parsing - the next valid frame is parsed correctly
6. Footer bytes in the values - a frame whose values spell the command footer is parsed whole
7. Replay after a drop - a valid frame caught inside a dropped frame's window is still parsed
8. Impossible counts - a length field of 0xFFF6 or 0xFFFF is dropped and the next frame is parsed
9. TX logging - verifies LD2412 sends expected setup commands
test_uart_mock_ld2412_engineering (engineering mode):
1. Engineering mode frames with per-gate energy data and light sensor
@@ -24,6 +27,7 @@ test_uart_mock_ld2412_engineering_truncated (truncated engineering mode):
The gate threshold tests (footer_in_payload, truncated_answer, requery_after_giveup) cover
answers that hold the frame footer or are truncated, writes that fill the gates without a
number from the module's values, and writes held back until the module reports them.
test_uart_mock_ld2412_short_answers: answers cut short publish nothing, the whole ones after them do.
"""
from __future__ import annotations
@@ -39,6 +43,7 @@ from aioesphomeapi import (
EntityState,
NumberInfo,
NumberState,
TextSensorState,
)
import pytest
@@ -138,18 +143,18 @@ async def test_uart_mock_ld2412(
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test LD2412 data parsing with happy path, garbage, overflow, and recovery."""
"""Test LD2412 data parsing with happy path, garbage, a truncated frame, and recovery."""
loop = asyncio.get_running_loop()
# Track overflow warning in logs
overflow_seen = loop.create_future()
# Track the warning for the dropped truncated frame in logs
resync_seen = loop.create_future()
# Track TX data logged by the mock for assertions
tx_log_lines: list[str] = []
def line_callback(line: str) -> None:
if "Max command length exceeded" in line and not overflow_seen.done():
overflow_seen.set_result(True)
if "Invalid length" in line and not resync_seen.done():
resync_seen.set_result(True)
# Capture all TX log lines from uart_mock
if _is_tx(line):
tx_log_lines.append(line)
@@ -177,6 +182,22 @@ async def test_uart_mock_ld2412(
and pytest.approx(100.0) in collector.sensor_states["moving_energy"]
and pytest.approx(80.0) in collector.sensor_states["still_energy"]
and pytest.approx(50.0) in collector.sensor_states["detection_distance"]
and pytest.approx(772.0) in collector.sensor_states["moving_distance"]
and pytest.approx(400.0) in collector.sensor_states["moving_distance"]
# Every value asserted below, so no state can still be in flight when the waiter fires
and pytest.approx(2.0) in collector.sensor_states["moving_energy"]
and pytest.approx(1.0) in collector.sensor_states["still_distance"]
and pytest.approx(5.0) in collector.sensor_states["still_energy"]
and pytest.approx(772.0) in collector.sensor_states["detection_distance"]
and pytest.approx(7.0) in collector.sensor_states["moving_energy"]
and pytest.approx(200.0) in collector.sensor_states["still_distance"]
and pytest.approx(9.0) in collector.sensor_states["still_energy"]
and pytest.approx(400.0) in collector.sensor_states["detection_distance"]
and pytest.approx(600.0) in collector.sensor_states["moving_distance"]
and pytest.approx(11.0) in collector.sensor_states["moving_energy"]
and pytest.approx(300.0) in collector.sensor_states["still_distance"]
and pytest.approx(13.0) in collector.sensor_states["still_energy"]
and pytest.approx(600.0) in collector.sensor_states["detection_distance"]
)
)
@@ -204,18 +225,16 @@ async def test_uart_mock_ld2412(
assert collector.sensor_states["detection_distance"][0] == pytest.approx(100.0)
# Wait for the recovery frame (Phase 5) to be parsed
# This proves the component survived garbage + truncated + overflow
# This proves the component survived garbage + truncated frame + filler bytes
await wait_or_fail(
recovery_received,
3.0,
15.0,
"Timeout waiting for recovery frame",
sensor_states=collector.sensor_states,
)
# Verify overflow warning was logged
assert overflow_seen.done(), (
"Expected 'Max command length exceeded' warning in logs"
)
# Verify the truncated frame was dropped with a warning
assert resync_seen.done(), "Expected 'Invalid length' warning in logs"
# Verify LD2412 sent setup commands (TX logging)
assert len(tx_log_lines) > 0, "Expected TX log lines from uart_mock"
@@ -235,6 +254,27 @@ async def test_uart_mock_ld2412(
assert pytest.approx(80.0) in collector.sensor_states["still_energy"]
assert pytest.approx(50.0) in collector.sensor_states["detection_distance"]
# Phase 6: the values 04 03 02 01 did not end the frame early
assert pytest.approx(772.0) in collector.sensor_states["moving_distance"]
assert pytest.approx(2.0) in collector.sensor_states["moving_energy"]
assert pytest.approx(1.0) in collector.sensor_states["still_distance"]
assert pytest.approx(5.0) in collector.sensor_states["still_energy"]
assert pytest.approx(772.0) in collector.sensor_states["detection_distance"]
# Phase 7: the valid frame inside the dropped frame's window was replayed and parsed
assert pytest.approx(400.0) in collector.sensor_states["moving_distance"]
assert pytest.approx(7.0) in collector.sensor_states["moving_energy"]
assert pytest.approx(200.0) in collector.sensor_states["still_distance"]
assert pytest.approx(9.0) in collector.sensor_states["still_energy"]
assert pytest.approx(400.0) in collector.sensor_states["detection_distance"]
# Phase 8: both impossible counts were dropped and the frame after them was parsed
assert pytest.approx(600.0) in collector.sensor_states["moving_distance"]
assert pytest.approx(11.0) in collector.sensor_states["moving_energy"]
assert pytest.approx(300.0) in collector.sensor_states["still_distance"]
assert pytest.approx(13.0) in collector.sensor_states["still_energy"]
assert pytest.approx(600.0) in collector.sensor_states["detection_distance"]
# Verify binary sensors detected targets (from Phase 1 frame)
assert collector.binary_states["has_target"][0] is True
assert collector.binary_states["has_moving_target"][0] is True
@@ -524,7 +564,7 @@ async def test_uart_mock_ld2412_truncated_answer(
def line_callback(line: str) -> None:
if "[W][ld2412" in line:
warnings.append(line)
if "Dropping gate threshold answer" in line and not dropped.done():
if "Invalid length" in line and not dropped.done():
dropped.set_result(True)
# Gate bytes of the truncated answer
@@ -681,3 +721,56 @@ async def test_uart_mock_ld2412_requery_after_giveup(
assert gate_0_state.state == pytest.approx(25.0)
assert gate_5_values
assert all(value == pytest.approx(30.0) for value in gate_5_values)
@pytest.mark.asyncio
async def test_uart_mock_ld2412_short_answers(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that answers cut short publish nothing and the whole answers after them do."""
loop = asyncio.get_running_loop()
# Number object_id: the value from the whole basic configuration answer
expected = {"min_distance_gate": 3.0, "max_distance_gate": 8.0, "timeout": 10.0}
number_states: dict[str, list[float]] = {name: [] for name in expected}
version_states: list[str] = []
key_to_name: dict[int, str] = {}
all_received = loop.create_future()
def on_state(state: EntityState) -> None:
if isinstance(state, TextSensorState) and not state.missing_state:
version_states.append(state.state)
elif (
isinstance(state, NumberState)
and not state.missing_state
and (name := key_to_name.get(state.key)) is not None
):
number_states[name].append(state.state)
if not all_received.done() and version_states and all(number_states.values()):
all_received.set_result(True)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
key_to_name.update(
{require_entity(entities, name, NumberInfo).key: name for name in expected}
)
await _wait_initial(client, entities, on_state)
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
await wait_or_fail(
all_received,
5.0,
"Timeout waiting for the whole answers",
number_states=number_states,
version_states=version_states,
)
# Each entity saw exactly one value, the one from the whole answer
assert version_states == ["2.07.22062416"]
for name, value in expected.items():
assert number_states[name] == pytest.approx([value]), name