Compare commits

..
Author SHA1 Message Date
J. Nick Koston dffb52af4f [climate] Add StringRef overloads for ClimateCall::set_fan_mode and set_preset 2026-10-21 18:27:33 -10:00
e1077748be [api][cover] Expose toggle support in native API (#16506)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-21 18:11:09 -10:00
cc89a011e2 Bump clang-tidy + clang-format from 22.1.8 to 23.1.3 (#21296)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-21 17:45:36 -10:00
Craig Andrews c76e9e1d62 [epaper_spi] Support Good Display GDEY029Z95 2.9" 3-color e-paper (128x296, SSD1680) (#21218) 2026-10-21 17:09:44 -10:00
esphome[bot] 7a3aac407f Bump aioesphomeapi from 46.18.0 to 46.19.0 (#21345) 2026-10-21 17:03:34 -10:00
J. Nick Koston b0a1054e8d [ld2410] Publish queried number values directly (#21346) 2026-10-21 16:56:51 -10:00
a8a8acb918 [sensor] Add Relative Humidity Correction Filter (#18287)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-10-21 16:33:11 -10:00
46 changed files with 1100 additions and 337 deletions
+8
View File
@@ -12,6 +12,7 @@ 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,
@@ -39,6 +40,7 @@ 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,
@@ -84,6 +86,7 @@ Checks: >-
-llvm-use-ranges,
-llvmlibc-*,
-misc-const-correctness,
-misc-explicit-constructor,
-misc-include-cleaner,
-misc-multiple-inheritance,
-misc-no-recursion,
@@ -113,6 +116,7 @@ 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,
@@ -129,9 +133,13 @@ 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: v22.1.8
rev: v23.1.3
hooks:
- id: clang-format
types_or: [c, c++]
+2
View File
@@ -608,6 +608,7 @@ 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
@@ -670,6 +671,7 @@ message CoverCommandRequest {
bool stop = 8;
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
bool stop_tilt = 10;
bool toggle = 11;
}
// ==================== FAN ====================
@@ -555,6 +555,7 @@ 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) {
@@ -567,6 +568,8 @@ 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,6 +484,7 @@ 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) {
@@ -506,6 +507,7 @@ 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) {
@@ -565,6 +567,9 @@ 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;
}
+4 -2
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 = 59;
static constexpr uint8_t ESTIMATED_SIZE = 61;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_cover_response"); }
#endif
@@ -921,6 +921,7 @@ 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);
@@ -958,7 +959,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 = 27;
static constexpr uint8_t ESTIMATED_SIZE = 29;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("cover_command_request"); }
#endif
@@ -968,6 +969,7 @@ 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,6 +1246,7 @@ 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 {
@@ -1271,6 +1272,7 @@ 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
+4 -5
View File
@@ -111,10 +111,9 @@ 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,6 +79,10 @@ 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.
@@ -99,6 +103,10 @@ 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);
@@ -0,0 +1,12 @@
#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
@@ -0,0 +1,28 @@
#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,11 +4,17 @@ 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
@@ -17,8 +23,10 @@ class WeActBWR(EpaperModel):
source_files = ("epaper_weact_3c.cpp",)
def __init__(self, name, **defaults):
super().__init__(name, "EPaperWeAct3C", **defaults)
def __init__(
self, name: str, class_name: str = "EPaperWeAct3C", **defaults: object
) -> None:
super().__init__(name, class_name, **defaults)
def get_init_sequence(self, config):
"""Generate initialization sequence for WeAct BWR displays.
@@ -75,3 +83,27 @@ 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",
)
+106 -63
View File
@@ -9,12 +9,27 @@
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/progmem.h"
namespace esphome::ld2410 {
ESPHOME_LOG_TAG(TAG, "ld2410");
enum BaudRate : uint8_t {
BAUD_RATE_9600 = 1,
BAUD_RATE_19200 = 2,
BAUD_RATE_38400 = 3,
BAUD_RATE_57600 = 4,
BAUD_RATE_115200 = 5,
BAUD_RATE_230400 = 6,
BAUD_RATE_256000 = 7,
BAUD_RATE_460800 = 8,
};
enum DistanceResolution : uint8_t {
DISTANCE_RESOLUTION_0_2 = 0x01,
DISTANCE_RESOLUTION_0_75 = 0x00,
};
enum LightFunction : uint8_t {
LIGHT_FUNCTION_OFF = 0x00,
LIGHT_FUNCTION_BELOW = 0x01,
@@ -54,17 +69,73 @@ enum AckData : uint8_t {
COMMAND_STATUS = 7,
};
// Index + 1 is the BaudRate value; the select options use the same order
PROGMEM_STRING_TABLE(BaudRateStrings, "9600", "19200", "38400", "57600", "115200", "230400", "256000", "460800");
static_assert(BAUD_RATE_9600 == 1 && BAUD_RATE_460800 == BaudRateStrings::COUNT, "BaudRate is index + 1");
// Same order as DISTANCE_RESOLUTIONS
PROGMEM_STRING_TABLE(DistanceResolutionStrings, "0.2m", "0.75m");
// The light function and out pin level select options are in enum order
static_assert(LIGHT_FUNCTION_OFF == 0 && LIGHT_FUNCTION_BELOW == 1 && LIGHT_FUNCTION_ABOVE == 2,
"LightFunction is the select option index");
static_assert(OUT_PIN_LEVEL_LOW == 0 && OUT_PIN_LEVEL_HIGH == 1, "OutPinLevel is the select option index");
// Memory-efficient lookup tables
struct StringToUint8 {
const char *str;
const uint8_t value;
};
static constexpr uint32_t BAUD_RATES[] PROGMEM = {9600, 19200, 38400, 57600, 115200, 230400, 256000, 460800};
struct Uint8ToString {
const uint8_t value;
const char *str;
};
constexpr StringToUint8 BAUD_RATES_BY_STR[] = {
{"9600", BAUD_RATE_9600}, {"19200", BAUD_RATE_19200}, {"38400", BAUD_RATE_38400},
{"57600", BAUD_RATE_57600}, {"115200", BAUD_RATE_115200}, {"230400", BAUD_RATE_230400},
{"256000", BAUD_RATE_256000}, {"460800", BAUD_RATE_460800},
};
constexpr StringToUint8 DISTANCE_RESOLUTIONS_BY_STR[] = {
{"0.2m", DISTANCE_RESOLUTION_0_2},
{"0.75m", DISTANCE_RESOLUTION_0_75},
};
constexpr Uint8ToString DISTANCE_RESOLUTIONS_BY_UINT[] = {
{DISTANCE_RESOLUTION_0_2, "0.2m"},
{DISTANCE_RESOLUTION_0_75, "0.75m"},
};
constexpr StringToUint8 LIGHT_FUNCTIONS_BY_STR[] = {
{"off", LIGHT_FUNCTION_OFF},
{"below", LIGHT_FUNCTION_BELOW},
{"above", LIGHT_FUNCTION_ABOVE},
};
constexpr Uint8ToString LIGHT_FUNCTIONS_BY_UINT[] = {
{LIGHT_FUNCTION_OFF, "off"},
{LIGHT_FUNCTION_BELOW, "below"},
{LIGHT_FUNCTION_ABOVE, "above"},
};
constexpr StringToUint8 OUT_PIN_LEVELS_BY_STR[] = {
{"low", OUT_PIN_LEVEL_LOW},
{"high", OUT_PIN_LEVEL_HIGH},
};
constexpr Uint8ToString OUT_PIN_LEVELS_BY_UINT[] = {
{OUT_PIN_LEVEL_LOW, "low"},
{OUT_PIN_LEVEL_HIGH, "high"},
};
constexpr uint32_t BAUD_RATES[] = {9600, 19200, 38400, 57600, 115200, 230400, 256000, 460800};
// Helper functions for lookups
template<size_t N> uint8_t find_uint8(const StringToUint8 (&arr)[N], const char *str) {
for (const auto &entry : arr) {
if (strcmp(str, entry.str) == 0)
return entry.value;
}
return 0xFF; // Not found
}
template<size_t N> const char *find_str(const Uint8ToString (&arr)[N], uint8_t value) {
for (const auto &entry : arr) {
if (value == entry.value)
return entry.str;
}
return ""; // Not found
}
// Commands
static constexpr uint8_t CMD_ENABLE_CONF = 0xFF;
@@ -336,12 +407,10 @@ void LD2410Component::handle_periodic_data_() {
}
#ifdef USE_NUMBER
std::function<void()> set_number_value(number::Number *n, float value) {
void set_number_value(number::Number *n, float value) {
if (n != nullptr && (!n->has_state() || n->state != value)) {
n->state = value;
return [n, value]() { n->publish_state(value); };
n->publish_state(value);
}
return []() {};
}
#endif
@@ -398,16 +467,11 @@ bool LD2410Component::handle_ack_data_() {
}
case CMD_QUERY_DISTANCE_RESOLUTION: {
const auto index = ld24xx::find_index(DISTANCE_RESOLUTIONS, this->buffer_data_[10]);
if (!index) {
ESP_LOGW(TAG, "Unknown distance resolution %u", this->buffer_data_[10]);
break;
}
ESP_LOGV(TAG, "Distance resolution: %s",
LOG_STR_ARG(DistanceResolutionStrings::get_log_str(*index, DistanceResolutionStrings::LAST_INDEX)));
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
if (this->distance_resolution_select_ != nullptr) {
this->distance_resolution_select_->publish_state(*index);
this->distance_resolution_select_->publish_state(distance_resolution);
}
#endif
break;
@@ -417,14 +481,16 @@ bool LD2410Component::handle_ack_data_() {
this->light_function_ = this->buffer_data_[10];
this->light_threshold_ = this->buffer_data_[11];
this->out_pin_level_ = this->buffer_data_[12];
ESP_LOGV(TAG, "Light function: %u, threshold: %u, out pin level: %u", this->light_function_,
this->light_threshold_, this->out_pin_level_);
const auto *light_function_str = find_str(LIGHT_FUNCTIONS_BY_UINT, this->light_function_);
const auto *out_pin_level_str = find_str(OUT_PIN_LEVELS_BY_UINT, this->out_pin_level_);
ESP_LOGV(TAG, "Light function: %s, threshold: %u, out pin level: %s", light_function_str, this->light_threshold_,
out_pin_level_str);
#ifdef USE_SELECT
if (this->light_function_select_ != nullptr) {
this->light_function_select_->publish_state(static_cast<size_t>(this->light_function_));
this->light_function_select_->publish_state(light_function_str);
}
if (this->out_pin_level_select_ != nullptr) {
this->out_pin_level_select_->publish_state(static_cast<size_t>(this->out_pin_level_));
this->out_pin_level_select_->publish_state(out_pin_level_str);
}
#endif
#ifdef USE_NUMBER
@@ -492,29 +558,24 @@ bool LD2410Component::handle_ack_data_() {
Moving distance range: 13th byte
Still distance range: 14th byte
*/
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]));
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]);
/*
Moving Sensitivities: 15~23th bytes
*/
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]));
for (size_t i = 0; i < TOTAL_GATES; i++) {
set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[14 + i]);
}
/*
Still Sensitivities: 24~32th bytes
*/
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]));
for (size_t i = 0; i < TOTAL_GATES; i++) {
set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[23 + i]);
}
/*
None Duration: 33~34th bytes
*/
updates.push_back(
set_number_value(this->timeout_number_, encode_uint16(this->buffer_data_[33], this->buffer_data_[32])));
for (auto &update : updates) {
update();
}
set_number_value(this->timeout_number_, encode_uint16(this->buffer_data_[33], this->buffer_data_[32]));
#endif
break;
}
@@ -570,29 +631,15 @@ void LD2410Component::set_bluetooth(bool enable) {
}
void LD2410Component::set_distance_resolution(const char *state) {
const int16_t index = progmem_string_table_find<DistanceResolutionStrings>(state, strlen(state));
if (index >= 0) {
this->set_distance_resolution(static_cast<DistanceResolution>(progmem_read_byte(&DISTANCE_RESOLUTIONS[index])));
}
}
void LD2410Component::set_distance_resolution(DistanceResolution resolution) {
this->set_config_mode_(true);
const uint8_t cmd_value[2] = {resolution, 0x00};
const uint8_t cmd_value[2] = {find_uint8(DISTANCE_RESOLUTIONS_BY_STR, state), 0x00};
this->send_command_(CMD_SET_DISTANCE_RESOLUTION, cmd_value, sizeof(cmd_value));
this->set_timeout(200, [this]() { this->restart_and_read_all_info(); });
}
void LD2410Component::set_baud_rate(const char *state) {
const int16_t index = progmem_string_table_find<BaudRateStrings>(state, strlen(state));
if (index >= 0) {
this->set_baud_rate(static_cast<BaudRate>(index + 1));
}
}
void LD2410Component::set_baud_rate(BaudRate rate) {
this->set_config_mode_(true);
const uint8_t cmd_value[2] = {rate, 0x00};
const uint8_t cmd_value[2] = {find_uint8(BAUD_RATES_BY_STR, state), 0x00};
this->send_command_(CMD_SET_BAUD_RATE, cmd_value, sizeof(cmd_value));
this->set_timeout(200, [this]() { this->restart_(); });
}
@@ -715,15 +762,11 @@ void LD2410Component::set_light_out_control() {
}
#endif
#ifdef USE_SELECT
if (this->light_function_select_ != nullptr) {
if (const auto index = this->light_function_select_->active_index()) {
this->light_function_ = *index;
}
if (this->light_function_select_ != nullptr && this->light_function_select_->has_state()) {
this->light_function_ = find_uint8(LIGHT_FUNCTIONS_BY_STR, this->light_function_select_->current_option().c_str());
}
if (this->out_pin_level_select_ != nullptr) {
if (const auto index = this->out_pin_level_select_->active_index()) {
this->out_pin_level_ = *index;
}
if (this->out_pin_level_select_ != nullptr && this->out_pin_level_select_->has_state()) {
this->out_pin_level_ = find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str());
}
#endif
this->set_config_mode_(true);
-21
View File
@@ -37,25 +37,6 @@ using namespace ld24xx;
static constexpr uint8_t MAX_LINE_LENGTH = 50;
static constexpr uint8_t TOTAL_GATES = 9; // Total number of gates supported by the LD2410
enum BaudRate : uint8_t {
BAUD_RATE_9600 = 1,
BAUD_RATE_19200 = 2,
BAUD_RATE_38400 = 3,
BAUD_RATE_57600 = 4,
BAUD_RATE_115200 = 5,
BAUD_RATE_230400 = 6,
BAUD_RATE_256000 = 7,
BAUD_RATE_460800 = 8,
};
enum DistanceResolution : uint8_t {
DISTANCE_RESOLUTION_0_2 = 0x01,
DISTANCE_RESOLUTION_0_75 = 0x00,
};
// Device values of the distance resolution select options, in option order; in flash on ESP8266
static constexpr uint8_t DISTANCE_RESOLUTIONS[] PROGMEM = {DISTANCE_RESOLUTION_0_2, DISTANCE_RESOLUTION_0_75};
class LD2410Component final : public Component, public uart::UARTDevice {
#ifdef USE_BINARY_SENSOR
SUB_BINARY_SENSOR(out_pin_presence_status)
@@ -118,9 +99,7 @@ class LD2410Component final : public Component, public uart::UARTDevice {
void restart_and_read_all_info();
void set_bluetooth(bool enable);
void set_distance_resolution(const char *state);
void set_distance_resolution(DistanceResolution resolution);
void set_baud_rate(const char *state);
void set_baud_rate(BaudRate rate);
void factory_reset();
protected:
@@ -52,7 +52,6 @@ CONFIG_SCHEMA = {
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_LD2410_ID])
selects = select.sub_selects(config, parent=hub)
# Option lists follow the order of the C++ enums and tables; the hub maps by index
await selects(
CONF_DISTANCE_RESOLUTION,
hub.set_distance_resolution_select,
@@ -4,7 +4,7 @@ namespace esphome::ld2410 {
void BaudRateSelect::control(size_t index) {
this->publish_state(index);
this->parent_->set_baud_rate(static_cast<BaudRate>(index + 1));
this->parent_->set_baud_rate(this->option_at(index));
}
} // namespace esphome::ld2410
@@ -4,8 +4,7 @@ namespace esphome::ld2410 {
void DistanceResolutionSelect::control(size_t index) {
this->publish_state(index);
this->parent_->set_distance_resolution(
static_cast<DistanceResolution>(progmem_read_byte(&DISTANCE_RESOLUTIONS[index])));
this->parent_->set_distance_resolution(this->option_at(index));
}
} // namespace esphome::ld2410
+3 -11
View File
@@ -1,7 +1,6 @@
#pragma once
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include <memory>
@@ -25,17 +24,10 @@
namespace esphome::ld24xx {
// Index of value in a constant table that may live in flash, or none
template<typename T, size_t N> optional<size_t> find_index(const T (&arr)[N], T value) {
static_assert(sizeof(T) == 1 || sizeof(T) == 4, "flash tables are read as bytes or aligned 32 bit words");
// Helper to find index of value in constexpr array
template<size_t N> optional<size_t> find_index(const uint32_t (&arr)[N], uint32_t value) {
for (size_t i = 0; i < N; i++) {
T entry;
if constexpr (sizeof(T) == 1) {
entry = progmem_read_byte(reinterpret_cast<const uint8_t *>(&arr[i]));
} else {
entry = arr[i]; // an aligned 32 bit read from flash is safe on ESP8266
}
if (entry == value)
if (arr[i] == value)
return i;
}
return {};
+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 %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
esph_log_v(TAG, "Command %02X, length %zu, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
} else {
esph_log_d(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
esph_log_d(TAG, "Command %02X, length %zu, 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,13 +39,16 @@ 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,
@@ -310,6 +313,7 @@ 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)
@@ -1281,6 +1285,23 @@ 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,6 +493,48 @@ 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,6 +647,26 @@ 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_);
+4 -2
View File
@@ -59,13 +59,15 @@ 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.18.0
aioesphomeapi==46.19.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==22.1.8 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
clang-tidy==22.1.8
clang-format==23.1.3 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
clang-tidy==23.1.3
yamllint==1.38.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
+8 -14
View File
@@ -319,21 +319,15 @@ 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.
disabled_checks.append("-clang-analyzer-core.FixedAddressDereference")
# fixed-address. clang-tidy 23 moved the checker under optin.
disabled_checks += [
"-clang-analyzer-core.FixedAddressDereference",
"-clang-analyzer-optin.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:
@@ -523,7 +517,7 @@ def main():
failed_files = []
timings = []
try:
executable = get_binary("clang-tidy", 22)
executable = get_binary("clang-tidy", 23)
task_queue = queue.Queue(args.jobs)
lock = threading.Lock()
for _ in range(args.jobs):
@@ -571,13 +565,13 @@ def main():
try:
try:
subprocess.call(
["clang-apply-replacements-22", tmpdir], close_fds=False
["clang-apply-replacements-23", tmpdir], close_fds=False
)
except FileNotFoundError:
subprocess.call(["clang-apply-replacements", tmpdir], close_fds=False)
except FileNotFoundError:
print(
"Error please install clang-apply-replacements-22 or clang-apply-replacements.\n",
"Error please install clang-apply-replacements-23 or clang-apply-replacements.\n",
file=sys.stderr,
)
except:
@@ -621,6 +621,69 @@ 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],
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,60 @@
"""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,6 +100,14 @@ 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).
@@ -0,0 +1,164 @@
#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,6 +209,27 @@ 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
+11
View File
@@ -0,0 +1,11 @@
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,3 +254,21 @@ 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
@@ -0,0 +1,66 @@
#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,6 +409,10 @@ 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
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,102 @@
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
@@ -1,68 +0,0 @@
esphome:
name: uart-mock-ld2410-selects
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 ld2410's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
# Answers the distance resolution query with 0x00, the module's code for 0.75m
uart_mock:
id: mock_uart
baud_rate: 256000
on_tx:
- then:
- lambda: |-
// The length and command word arrive as one write of their own
static const std::vector<uint8_t> RESOLUTION_QUERY = {0x02, 0x00, 0xAB, 0x00};
if (data != RESOLUTION_QUERY) {
return;
}
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>{
0xFD, 0xFC, 0xFB, 0xFA, // header
0x06, 0x00, // length 6
0xAB, 0x01, // answer to command 0x00AB
0x00, 0x00, // status: success
0x00, 0x00, // resolution code 0x00 = 0.75m
0x04, 0x03, 0x02, 0x01, // footer
});
ld2410:
id: ld2410_dev
uart_id: mock_uart
select:
- platform: ld2410
ld2410_id: ld2410_dev
distance_resolution:
name: "Distance Resolution"
baud_rate:
name: "Baud Rate"
# Lambdas reach the name taking setters directly, which the API cannot do with an unknown name
button:
- platform: template
name: "Bogus Names"
on_press:
- lambda: |-
id(ld2410_dev).set_distance_resolution("bogus");
id(ld2410_dev).set_baud_rate("bogus");
ESP_LOGI("test", "bogus names done");
- platform: template
name: "Named Values"
on_press:
- lambda: |-
id(ld2410_dev).set_distance_resolution("0.75m");
id(ld2410_dev).set_baud_rate("57600");
+12 -5
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; payloads are skipped and only message types are counted.
the device; message types are counted and each frame is kept for inspection.
"""
from __future__ import annotations
@@ -59,15 +59,17 @@ 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 (msg_type := self._try_parse()) is not None:
types.append(msg_type)
while (frame := self._try_parse()) is not None:
self.frames.append(frame)
types.append(frame[0])
return types
def _try_parse(self) -> int | None:
def _try_parse(self) -> tuple[int, bytes] | None:
buf = self._buf
if not buf:
return None
@@ -80,8 +82,9 @@ 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
return msg_type, payload
class RawApiClient:
@@ -145,5 +148,9 @@ 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
@@ -0,0 +1,129 @@
"""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
@@ -0,0 +1,55 @@
"""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
@@ -1,119 +0,0 @@
"""Integration test for the LD2410 selects with mock UART.
The module answers the distance resolution query with 0x00, so the select starts at 0.75m, and the
baud rate select starts at the bus speed. Choosing an option must send the device code of that
option: 0.2m is 0x01 and 19200 is the second baud rate. The name taking setters map a name the
same way from a lambda, and an unknown name sends nothing at all.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import ButtonInfo, SelectInfo
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
# Length and command word of each frame as the uart mock logs it; the payload is logged on its own TX line
SET_RESOLUTION = "04:00:AA:00"
SET_BAUD_RATE = "04:00:A1:00"
class _TxLines:
"""Record the TX lines and the marker lines, and hand out the payload line after an awaited command."""
def __init__(self, *markers: str) -> None:
self._markers = markers
self.lines: list[str] = []
# Command text, whether its line was seen, and the future for the line after it
self._awaited: list[tuple[str, bool, asyncio.Future[str]]] = []
def on_line(self, line: str) -> None:
is_tx = "uart_mock" in line and "TX " in line
if not is_tx and not any(marker in line for marker in self._markers):
return
self.lines.append(line)
for i, (text, seen, future) in enumerate(self._awaited):
if seen:
if not future.done():
future.set_result(line)
elif text in line:
self._awaited[i] = (text, True, future)
def next_payload_after(self, text: str) -> asyncio.Future[str]:
"""Resolve with the TX line logged right after the next line holding ``text``."""
future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
self._awaited.append((text, False, future))
return future
@pytest.mark.asyncio
async def test_uart_mock_ld2410_selects(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that the selects show the module's values and send the device code of the chosen option."""
bogus_done = "bogus names done"
events = _TxLines(bogus_done)
async with (
run_compiled(yaml_config, line_callback=events.on_line),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(lambda s: None))
await initial_state_helper.wait_for_initial_states()
resolution = require_entity(entities, "distance_resolution", SelectInfo)
baud_rate = require_entity(entities, "baud_rate", SelectInfo)
assert initial_state_helper.initial_states[resolution.key].state == "0.75m"
# The mock bus runs at 256000, which the select reports at boot
assert initial_state_helper.initial_states[baud_rate.key].state == "256000"
# An unknown name writes nothing, not even the configuration mode frames around a command.
# This runs before any select write, so no restart timer from a write can send a frame meanwhile.
before = len(events.lines)
bogus = events.next_payload_after(bogus_done)
client.button_command(require_entity(entities, "bogus_names", ButtonInfo).key)
# Each payload line follows its length and command line directly
resolution_payload = events.next_payload_after(SET_RESOLUTION)
client.select_command(resolution.key, "0.2m")
await wait_or_fail(
asyncio.gather(bogus, resolution_payload),
5.0,
"Timeout waiting for the distance resolution command",
logged=events.lines,
)
marker = next(
i for i in range(before, len(events.lines)) if bogus_done in events.lines[i]
)
assert all("TX " not in line for line in events.lines[before:marker])
assert "TX 2 bytes: 01:00" in resolution_payload.result()
baud_rate_payload = events.next_payload_after(SET_BAUD_RATE)
client.select_command(baud_rate.key, "19200")
await wait_or_fail(
baud_rate_payload,
5.0,
"Timeout waiting for the baud rate command",
logged=events.lines,
)
assert "TX 2 bytes: 02:00" in baud_rate_payload.result()
named_resolution = events.next_payload_after(SET_RESOLUTION)
named_baud_rate = events.next_payload_after(SET_BAUD_RATE)
client.button_command(require_entity(entities, "named_values", ButtonInfo).key)
await wait_or_fail(
asyncio.gather(named_resolution, named_baud_rate),
5.0,
"Timeout waiting for the named setter commands",
logged=events.lines,
)
# 0.75m is device code 0x00 and 57600 is the fourth baud rate
assert "TX 2 bytes: 00:00" in named_resolution.result()
assert "TX 2 bytes: 04:00" in named_baud_rate.result()