mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 13:56:10 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c610ab837d | ||
|
|
e1077748be | ||
|
|
cc89a011e2 | ||
|
|
406d2069a5 | ||
|
|
5dabeb1525 | ||
|
|
c76e9e1d62 | ||
|
|
7a3aac407f | ||
|
|
0d7cc4646d | ||
|
|
b0a1054e8d | ||
|
|
a8a8acb918 | ||
|
|
e2eafda5f6 | ||
|
|
39d8bf58ae | ||
|
|
afd5d324ed | ||
|
|
fa823d1a30 | ||
|
|
5088c4a956 | ||
|
|
b405cbb80a | ||
|
|
4b0bef2569 | ||
|
|
70a33c60fa | ||
|
|
c26d5502f9 | ||
|
|
13ef930573 | ||
|
|
499f716a7d | ||
|
|
0d9402cf82 | ||
|
|
a1d730704e | ||
|
|
5a5d295455 | ||
|
|
88424041b1 | ||
|
|
a0c9f3aac7 | ||
|
|
4d35fb83a4 |
@@ -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,
|
||||
|
||||
@@ -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++]
|
||||
|
||||
@@ -44,7 +44,6 @@ from esphome.platformio.library import (
|
||||
normalize_dependencies,
|
||||
parse_library_json,
|
||||
parse_library_properties,
|
||||
warn_properties_depends,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -283,7 +282,6 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
|
||||
"resolved automatically; add them with add_library() if needed",
|
||||
name,
|
||||
)
|
||||
warn_properties_depends(name, data)
|
||||
build = data.get("build")
|
||||
if isinstance(build, dict) and build.get("extraScript"):
|
||||
# Scripts only run on the converted path; building without
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -3813,6 +3813,20 @@ def _write_sdkconfig():
|
||||
clean_build(clear_pio_cache=False)
|
||||
|
||||
|
||||
def _arduino_provides(name: str) -> bool:
|
||||
"""Whether arduino-esp32 bundles the library a manifest depends on, so it is
|
||||
never fetched by name; it still has to be enabled to build."""
|
||||
if name not in ARDUINO_DISABLED_LIBRARIES:
|
||||
return False
|
||||
if name not in CORE.data[KEY_ESP32][KEY_ARDUINO_LIBRARIES]:
|
||||
_LOGGER.warning(
|
||||
"A library depends on the Arduino library %s, which is not enabled; "
|
||||
"add it under esphome: libraries:",
|
||||
name,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _write_idf_component_yml():
|
||||
yml_path = CORE.relative_build_path("src/idf_component.yml")
|
||||
dependencies: dict[str, dict] = {}
|
||||
@@ -3834,7 +3848,11 @@ def _write_idf_component_yml():
|
||||
# IDF sees the same requirement twice; converted components reach it through
|
||||
# ${ESPHOME_PROJECT_MANAGED_COMPONENTS}.
|
||||
managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {}))
|
||||
converted = generate_idf_components(libraries, managed=managed)
|
||||
converted = generate_idf_components(
|
||||
libraries,
|
||||
managed=managed,
|
||||
provides=_arduino_provides if CORE.using_arduino else None,
|
||||
)
|
||||
# IDF names a component after its directory and a later registration of the
|
||||
# same name replaces the earlier one, so a stub beside a converted library of
|
||||
# the same name (espressif/libsodium vs esphome/libsodium) would win or lose
|
||||
|
||||
@@ -407,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
|
||||
|
||||
@@ -560,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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -266,14 +266,24 @@ def _convert_pio_libs(
|
||||
in the real esp32 build; converting them too would give IDF the same requirement
|
||||
twice. Arduino below IDF 6.0 has them rule-gated off, so there they convert.
|
||||
"""
|
||||
from esphome.components.esp32 import arduino_bundles_libsodium
|
||||
from esphome.components.esp32 import (
|
||||
ARDUINO_DISABLED_LIBRARIES,
|
||||
arduino_bundles_libsodium,
|
||||
)
|
||||
from esphome.espidf.component import generate_idf_components
|
||||
|
||||
libraries = _parse_lib_deps(platformio_ini, framework)
|
||||
# Same rule as noise._use_managed_components and the manifest
|
||||
managed = set() if arduino_bundles_libsodium() else _esphome_manifest_deps()
|
||||
deps: dict[str, dict[str, str]] = {}
|
||||
for component in generate_idf_components(libraries, managed=managed):
|
||||
# As in the real esp32 build, a dependency on an Arduino-bundled library is
|
||||
# left to arduino-esp32 instead of being fetched from the registry
|
||||
provides = (
|
||||
ARDUINO_DISABLED_LIBRARIES.__contains__ if framework == "arduino" else None
|
||||
)
|
||||
for component in generate_idf_components(
|
||||
libraries, managed=managed, provides=provides
|
||||
):
|
||||
deps[component.get_sanitized_name()] = {"override_path": str(component.path)}
|
||||
return deps
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ for each resolved library, running any PlatformIO ``extraScript``, and the
|
||||
ESP-IDF platform/framework compatibility defaults.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -288,18 +289,23 @@ def _emit_idf_component(component: IDFComponent) -> None:
|
||||
|
||||
|
||||
def generate_idf_components(
|
||||
libraries: list[Library], managed: set[str] | None = None
|
||||
libraries: list[Library],
|
||||
managed: set[str] | None = None,
|
||||
provides: Callable[[str], bool] | None = None,
|
||||
) -> list[IDFComponent]:
|
||||
"""Resolve and convert a batch of PlatformIO libraries to IDF components.
|
||||
|
||||
``managed`` names registry components already in the project manifest; they
|
||||
are not converted, since IDF rejects the same requirement from two components.
|
||||
Converted components reach them through ``${ESPHOME_PROJECT_MANAGED_COMPONENTS}``.
|
||||
``provides`` names the libraries the framework bundles, so a manifest dependency on
|
||||
one is never fetched from the registry.
|
||||
"""
|
||||
backend = LibraryBackend(
|
||||
platform=ESP32_PLATFORM,
|
||||
framework=_idf_framework(),
|
||||
emit=_emit_idf_component,
|
||||
cache_key="idf",
|
||||
provides=provides,
|
||||
)
|
||||
return convert_libraries(libraries, backend, provided=managed)
|
||||
|
||||
+236
-101
@@ -609,9 +609,34 @@ def parse_library_properties(library_properties_path: PathType):
|
||||
if not value.strip():
|
||||
continue
|
||||
data[key.strip()] = value.strip()
|
||||
if depends := data.pop("depends", None):
|
||||
data["dependencies"] = parse_properties_depends(depends)
|
||||
return data
|
||||
|
||||
|
||||
def parse_properties_depends(depends: str) -> list[dict[str, str]]:
|
||||
"""The Arduino ``depends=Name, Other (>=1.0)`` line as name-only dependency entries.
|
||||
|
||||
An Arduino constraint is not PlatformIO syntax, so it is dropped; the name is
|
||||
satisfied by a listed library or the registry's current version, as the walk
|
||||
does for every version-less dependency.
|
||||
"""
|
||||
entries: list[dict[str, str]] = []
|
||||
for item in depends.split(","):
|
||||
name, _, constraint = item.strip().partition("(")
|
||||
if not (name := name.strip()):
|
||||
continue
|
||||
if constraint:
|
||||
# A plain lower bound is met by the registry's current version; an
|
||||
# exact or capped one may not be, so say so
|
||||
log = (
|
||||
_LOGGER.debug if constraint.lstrip().startswith(">=") else _LOGGER.info
|
||||
)
|
||||
log("Ignoring version constraint on depends=%s", item.strip())
|
||||
entries.append({"name": name})
|
||||
return entries
|
||||
|
||||
|
||||
def _make_registry_client() -> Any:
|
||||
"""Create a minimal PlatformIO registry client with no system filtering.
|
||||
|
||||
@@ -730,20 +755,6 @@ def join_flag_args(tokens: Iterable[str], owner: str) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def warn_properties_depends(name: str, data: object) -> None:
|
||||
"""Warn for ``depends=``-only manifests; the walk reads only the JSON
|
||||
``dependencies`` key, so they would otherwise drop silently."""
|
||||
if isinstance(data, dict) and not data.get("dependencies") and data.get("depends"):
|
||||
# INFO: common and unactionable for transitive libraries; a WARNING
|
||||
# on every build would train users to ignore the stream
|
||||
_LOGGER.info(
|
||||
"Library %s declares dependencies via library.properties "
|
||||
"depends=, which are not resolved automatically; add them with "
|
||||
"add_library() if needed",
|
||||
name,
|
||||
)
|
||||
|
||||
|
||||
def dependency_is_usable(
|
||||
dep: dict, platform: str | None, framework: str, requester: str
|
||||
) -> bool:
|
||||
@@ -762,6 +773,20 @@ def dependency_is_usable(
|
||||
return True
|
||||
|
||||
|
||||
def _split_owner(entry: dict) -> dict:
|
||||
"""``owner/Name`` written as one name becomes owner and name, as in the dict form,
|
||||
so the name matches the package the registry resolves it to."""
|
||||
name, owner = entry.get("name"), entry.get("owner")
|
||||
if isinstance(name, str) and owner is None and "://" not in name:
|
||||
head, sep, tail = name.partition("/")
|
||||
# A registry owner is a plain identifier; anything else is left to the
|
||||
# backend's own checks
|
||||
owner_ok = head.replace("-", "").replace("_", "").isalnum()
|
||||
if sep and owner_ok and tail and "/" not in tail:
|
||||
return {**entry, "owner": head, "name": tail}
|
||||
return entry
|
||||
|
||||
|
||||
def _valid_dependency_entry(entry: dict, manifest_name: str) -> bool:
|
||||
"""Whether a normalized entry carries a usable name (non-empty string),
|
||||
version (string, if present), and owner (string, if present); invalid
|
||||
@@ -790,16 +815,12 @@ def normalize_dependencies(
|
||||
so callers see a uniform list. ``manifest_name`` names the manifest in the
|
||||
warning for entries that cannot be normalized.
|
||||
|
||||
Bare-name spellings carry no version; the dependency walk later drops
|
||||
version-less entries at DEBUG, since they are usually names of bundled
|
||||
framework libraries (Wire, SPI) that need no registry install.
|
||||
Bare-name spellings carry no version; the walk settles them once the graph is known.
|
||||
"""
|
||||
if dependencies is None:
|
||||
return []
|
||||
if isinstance(dependencies, str):
|
||||
# A plain string is one or more comma-separated names; iterating it
|
||||
# as a list would shred it into one-character "libraries"
|
||||
return [{"name": n.strip()} for n in dependencies.split(",") if n.strip()]
|
||||
return [_split_owner(entry) for entry in parse_properties_depends(dependencies)]
|
||||
if isinstance(dependencies, dict):
|
||||
normalized = []
|
||||
for raw_name, spec in dependencies.items():
|
||||
@@ -826,10 +847,10 @@ def normalize_dependencies(
|
||||
for entry in dependencies:
|
||||
if isinstance(entry, dict):
|
||||
if _valid_dependency_entry(entry, manifest_name):
|
||||
normalized.append(entry)
|
||||
normalized.append(_split_owner(entry))
|
||||
elif isinstance(entry, str) and entry:
|
||||
# PIO also accepts a bare list of names ("dependencies": ["Wire"])
|
||||
normalized.append({"name": entry})
|
||||
normalized.append(_split_owner({"name": entry}))
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Ignoring unrecognized dependency entry %r of %s",
|
||||
@@ -971,54 +992,55 @@ def is_lib_ignored(name: str | None, lib_ignore: set[str]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _reconcile_versionless_skips(
|
||||
skipped_versionless: list[tuple[Any, Any, str]],
|
||||
def _satisfying_key(
|
||||
name: str,
|
||||
owner: str | None,
|
||||
requester: str,
|
||||
nodes: dict[str, _LibNode],
|
||||
components: dict[str, ConvertedLibrary],
|
||||
backend: LibraryBackend,
|
||||
) -> None:
|
||||
"""Warn for version-less deps nothing satisfied, and record the
|
||||
backend-provided ones in ``backend.provided_requests`` for its
|
||||
post-emit reconciliation; a silent drop surfaces as link errors far
|
||||
from the cause."""
|
||||
resolved_manifest_names = {c.data.get("name") for c in components.values()}
|
||||
# A treeless backend can never supply a bundled name; noise for it
|
||||
log = _LOGGER.warning if backend.provides is not None else _LOGGER.debug
|
||||
warned: set[str] = set()
|
||||
for dep_name, dep_owner, requester in skipped_versionless:
|
||||
if not isinstance(dep_name, str) or not dep_name or dep_name in warned:
|
||||
continue
|
||||
if dep_name in components:
|
||||
# A version-less dep's request key is the name itself
|
||||
continue
|
||||
if (
|
||||
not dep_owner
|
||||
and backend.provides is not None
|
||||
and backend.provides(dep_name)
|
||||
):
|
||||
# provides() only satisfies owner-less names (same guard as
|
||||
# the walk's skip); record for the post-emit reconciliation.
|
||||
# Checked before the manifest-name evidence so the overlap
|
||||
# case warns once, in the backend's own suppression loop
|
||||
backend.provided_requests.add(dep_name)
|
||||
continue
|
||||
if dep_name in resolved_manifest_names:
|
||||
# Name-only evidence: a coincidental collision must stay
|
||||
# visible where the user could pin it
|
||||
warned.add(dep_name)
|
||||
log(
|
||||
"Version-less dependency %s of %s assumed satisfied by a "
|
||||
"resolved library's manifest name only",
|
||||
dep_name,
|
||||
requester,
|
||||
)
|
||||
continue
|
||||
warned.add(dep_name)
|
||||
log(
|
||||
"Dependency %s of %s has no version to resolve and nothing "
|
||||
"provides it; skipping",
|
||||
dep_name,
|
||||
requester,
|
||||
)
|
||||
) -> tuple[str, bool] | None:
|
||||
"""The key of a resolved library a version-less dependency names, and whether
|
||||
only its manifest name matched: the registry package name (with the owner,
|
||||
when the dependency gives one) is checked first; never the requester itself."""
|
||||
wanted = name.lower()
|
||||
|
||||
def owner_of(key: str, component: ConvertedLibrary) -> str:
|
||||
# The owner as requested, else the one the registry (or a git path) gave
|
||||
head, sep, _ = component.name.partition("/")
|
||||
return nodes[key].owner or (head if sep else "")
|
||||
|
||||
candidates = [
|
||||
(key, component)
|
||||
for key, component in components.items()
|
||||
if key != requester
|
||||
and (not owner or owner_of(key, component).lower() == owner.lower())
|
||||
]
|
||||
for key, _ in candidates:
|
||||
if (nodes[key].pkgname or key).lower() == wanted:
|
||||
return key, False
|
||||
if owner:
|
||||
return None
|
||||
for key, component in candidates:
|
||||
manifest_name = component.data.get("name")
|
||||
if isinstance(manifest_name, str) and manifest_name.lower() == wanted:
|
||||
return key, True
|
||||
return None
|
||||
|
||||
|
||||
def _own_names(
|
||||
key: str, nodes: dict[str, _LibNode], components: dict[str, ConvertedLibrary]
|
||||
) -> set[str]:
|
||||
"""The lower-case key, requested and resolved package names and the manifest
|
||||
name a resolved library goes by."""
|
||||
names = {
|
||||
key.lower(),
|
||||
(nodes[key].pkgname or key).lower(),
|
||||
components[key].name.lower(),
|
||||
}
|
||||
manifest_name = components[key].data.get("name")
|
||||
if isinstance(manifest_name, str):
|
||||
names.add(manifest_name.lower())
|
||||
return names
|
||||
|
||||
|
||||
def _fetch_source(
|
||||
@@ -1147,7 +1169,12 @@ def convert_libraries(
|
||||
managed components from ``add_idf_component``); unlike ``backend.provides`` they
|
||||
carry an owner. They are excluded like ``lib_ignore`` so nothing is both
|
||||
converted and managed, which ESP-IDF refuses to build.
|
||||
|
||||
A dependency named without a version is settled once the graph is known: wired
|
||||
to a library already in it, left to ``backend.provides``, or fetched by name.
|
||||
"""
|
||||
from platformio.package.exception import UnknownPackageError
|
||||
|
||||
nodes: dict[str, _LibNode] = {}
|
||||
|
||||
# Folded into one set so every is_lib_ignored() call site honors both.
|
||||
@@ -1165,10 +1192,16 @@ def convert_libraries(
|
||||
else ""
|
||||
)
|
||||
|
||||
resolved_requirements: dict[str, frozenset[str]] = {}
|
||||
|
||||
def add_spec(name: str | None, version: str | None, repository: str | None) -> str:
|
||||
key, kind, locator = _node_key(name, version, repository)
|
||||
node = nodes.get(key) or _LibNode(key=key, is_git=kind == "git")
|
||||
nodes[key] = node
|
||||
if kind != "registry" and not (node.is_git or node.is_local):
|
||||
# A registry node (perhaps one the registry did not know) becomes a
|
||||
# git or local source: resolve it again from that source
|
||||
resolved_requirements.pop(key, None)
|
||||
# The same key requested from two different kinds of source (or two
|
||||
# different local paths) is a config mistake: one silently wins. Warn so
|
||||
# it isn't a surprise. (git-vs-registry is reported separately below.)
|
||||
@@ -1220,10 +1253,69 @@ def convert_libraries(
|
||||
# requirement set has grown since the last time, so every requirement in the
|
||||
# graph is accounted for before conversion.
|
||||
components: dict[str, ConvertedLibrary] = {}
|
||||
resolved_requirements: dict[str, frozenset[str]] = {}
|
||||
top_level_keys = set(top_level)
|
||||
# (name, owner, requester) reconciled against the final resolution set
|
||||
skipped_versionless: list[tuple[Any, Any, str]] = []
|
||||
|
||||
def fetched_by_name(key: str) -> bool:
|
||||
"""A registry node only version-less manifest dependencies asked for: a
|
||||
miss skips it, and an explicit library of the same name replaces it."""
|
||||
node = nodes[key]
|
||||
return (
|
||||
key not in top_level_keys
|
||||
and not (node.is_git or node.is_local)
|
||||
and not node.requirements
|
||||
)
|
||||
|
||||
def backend_provides(name: str, owner: str | None) -> bool:
|
||||
"""Whether the framework bundles an owner-less name; asked once per name,
|
||||
since the backend may warn, and recorded in ``provided_requests``."""
|
||||
if owner or backend.provides is None:
|
||||
return False
|
||||
if name in backend.provided_requests or backend.provides(name):
|
||||
backend.provided_requests.add(name)
|
||||
return True
|
||||
return False
|
||||
|
||||
# Version-less dependencies per requester key, kept for the whole walk and
|
||||
# wired once the graph is final; a re-walked requester replaces its entries
|
||||
pending_versionless: dict[str, list[dict]] = {}
|
||||
# Names fetched by name so far, whatever their case: each is asked for once
|
||||
fetched: dict[tuple[str, str], str] = {}
|
||||
|
||||
def settle_versionless() -> list[str]:
|
||||
"""Fetch by name the deferred dependencies nothing in the graph or the
|
||||
framework satisfies yet; return the new keys to walk. Only a dependency
|
||||
on the requester itself or with a bad name needs no wiring later."""
|
||||
new_keys: list[str] = []
|
||||
for requester, dependencies in pending_versionless.items():
|
||||
kept: list[dict] = []
|
||||
for dependency in dependencies:
|
||||
name, owner = dependency["name"], dependency.get("owner")
|
||||
if _owner_pkgname_to_name(owner, name).lower() in _own_names(
|
||||
requester, nodes, components
|
||||
):
|
||||
_LOGGER.debug("Dependency %s of %s names itself", name, requester)
|
||||
continue
|
||||
# A library in the graph beats a bundled copy, as on the Arduino backend
|
||||
if _satisfying_key(
|
||||
name, owner, requester, nodes, components
|
||||
) is None and not backend_provides(name, owner):
|
||||
spelling = (name.lower(), (owner or "").lower())
|
||||
if spelling not in fetched:
|
||||
try:
|
||||
dep_key = add_spec(
|
||||
_owner_pkgname_to_name(owner, name), None, None
|
||||
)
|
||||
except RuntimeError as e:
|
||||
_LOGGER.warning(
|
||||
"Skipping dependency %s of %s: %s", name, requester, e
|
||||
)
|
||||
continue
|
||||
fetched[spelling] = dep_key
|
||||
new_keys.append(dep_key)
|
||||
kept.append(dependency)
|
||||
dependencies[:] = kept
|
||||
return new_keys
|
||||
|
||||
worklist = deque(dict.fromkeys(top_level))
|
||||
while worklist:
|
||||
# Drain the frontier sequentially (spec resolution mutates shared
|
||||
@@ -1245,9 +1337,17 @@ def convert_libraries(
|
||||
elif node.is_local:
|
||||
component = ConvertedLibrary(key, "*", LocalSource(node.local_path))
|
||||
else:
|
||||
owner, name, version, url, size = _resolve_registry_version(
|
||||
node.owner, node.pkgname, node.requirements
|
||||
)
|
||||
try:
|
||||
owner, name, version, url, size = _resolve_registry_version(
|
||||
node.owner, node.pkgname, node.requirements
|
||||
)
|
||||
except UnknownPackageError as e:
|
||||
# A requirement conflict is a RuntimeError and always raises
|
||||
if not fetched_by_name(key):
|
||||
raise
|
||||
# Reported per requester once the graph is final
|
||||
_LOGGER.debug("Library %s is not in the registry: %s", key, e)
|
||||
continue
|
||||
component = ConvertedLibrary(
|
||||
_owner_pkgname_to_name(owner, name), version, URLSource(url, size)
|
||||
)
|
||||
@@ -1255,9 +1355,9 @@ def convert_libraries(
|
||||
_prefetch_wave(wave, salt, backend.cache_key)
|
||||
for key, component in wave:
|
||||
node = nodes[key]
|
||||
if frozenset(node.requirements) != resolved_requirements[key]:
|
||||
# Requirements grew mid-wave: skip parsing a manifest the
|
||||
# next wave will re-resolve and replace
|
||||
if frozenset(node.requirements) != resolved_requirements.get(key):
|
||||
# Requirements grew (or the source changed) mid-wave: skip parsing
|
||||
# a manifest the next wave will re-resolve and replace
|
||||
worklist.append(key)
|
||||
continue
|
||||
component.download(salt=salt, namespace=backend.cache_key)
|
||||
@@ -1313,8 +1413,6 @@ def convert_libraries(
|
||||
raise EsphomeError(f"Library {key} has a malformed manifest")
|
||||
_LOGGER.warning("Skipping dependency %s: malformed manifest", key)
|
||||
continue
|
||||
warn_properties_depends(component.name, component.data)
|
||||
|
||||
try:
|
||||
check_library_data(component.data, backend.platform, backend.framework)
|
||||
except InvalidLibrary as e:
|
||||
@@ -1337,27 +1435,19 @@ def convert_libraries(
|
||||
# Requirements changed (we got past the short-circuit above), so
|
||||
# (re)walk this component's dependencies.
|
||||
node.edges = set()
|
||||
pending = pending_versionless[key] = []
|
||||
for dependency in normalize_dependencies(
|
||||
component.data.get("dependencies"), component.name
|
||||
):
|
||||
if "version" not in dependency:
|
||||
# Cannot resolve from the registry; the post-emit
|
||||
# reconciliation owns the drop warning
|
||||
dep_name = dependency.get("name")
|
||||
_LOGGER.debug(
|
||||
"Skip version-less dependency %r of %s",
|
||||
dep_name,
|
||||
component.name,
|
||||
)
|
||||
if not dependency.get("version"):
|
||||
# An empty version is as good as none; it settles the same way
|
||||
if not is_lib_ignored(
|
||||
dep_name, lib_ignore
|
||||
dependency.get("name"), lib_ignore
|
||||
) and dependency_is_usable(
|
||||
dependency, backend.platform, backend.framework, component.name
|
||||
):
|
||||
# Filtered or ignored deps are deliberately absent
|
||||
skipped_versionless.append(
|
||||
(dep_name, dependency.get("owner"), component.name)
|
||||
)
|
||||
pending.append(dependency)
|
||||
continue
|
||||
if not dependency_is_usable(
|
||||
dependency, backend.platform, backend.framework, component.name
|
||||
@@ -1375,11 +1465,7 @@ def convert_libraries(
|
||||
dep_url = _url_or_none(dep_version)
|
||||
if dep_url is not None:
|
||||
dep_version = None
|
||||
elif (
|
||||
backend.provides is not None
|
||||
and not dependency.get("owner")
|
||||
and backend.provides(dep_name)
|
||||
):
|
||||
elif backend_provides(dep_name, dependency.get("owner")):
|
||||
# The backend adds it from its own tree; resolving here
|
||||
# would fetch a same-named registry package
|
||||
if dep_version and dep_version != "*":
|
||||
@@ -1392,11 +1478,12 @@ def convert_libraries(
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug("Skip backend-provided dependency %s", dep_name)
|
||||
backend.provided_requests.add(dep_name)
|
||||
continue
|
||||
dep_key = add_spec(dep_name, dep_version, dep_url)
|
||||
node.edges.add(dep_key)
|
||||
worklist.append(dep_key)
|
||||
if not worklist:
|
||||
worklist.extend(settle_versionless())
|
||||
|
||||
# A git or local source wins over the same component requested from the
|
||||
# registry. That's intentional, but warn so the dropped registry spec isn't
|
||||
@@ -1420,6 +1507,56 @@ def convert_libraries(
|
||||
source,
|
||||
)
|
||||
|
||||
# Every version-less request resolves to the explicit library that goes by
|
||||
# its name if the final graph has one, else to the node fetched by name for
|
||||
# that package, else to the framework's bundled copy, else to nothing
|
||||
explicit = {k: c for k, c in components.items() if not fetched_by_name(k)}
|
||||
by_name = {k: c for k, c in components.items() if fetched_by_name(k)}
|
||||
unresolved: dict[tuple[str, str], None] = {}
|
||||
for requester, dependencies in pending_versionless.items():
|
||||
for dependency in dependencies:
|
||||
name, owner = dependency["name"], dependency.get("owner")
|
||||
found = _satisfying_key(
|
||||
name, owner, requester, nodes, explicit
|
||||
) or _satisfying_key(name, owner, requester, nodes, by_name)
|
||||
if found is None:
|
||||
if not backend_provides(name, owner):
|
||||
unresolved[(requester, name)] = None
|
||||
continue
|
||||
key, by_manifest_name = found
|
||||
if by_manifest_name:
|
||||
# Visible, so a chance name collision can be pinned to the right package
|
||||
_LOGGER.info(
|
||||
"Dependency %s of %s satisfied by %s through its manifest name",
|
||||
name,
|
||||
requester,
|
||||
key,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Dependency %s of %s satisfied by %s", name, requester, key
|
||||
)
|
||||
nodes[requester].edges.add(key)
|
||||
|
||||
# What no listed library needs any more is not built: a node fetched by name
|
||||
# that an explicit library replaced, and whatever only it pulled in
|
||||
reachable: set[str] = set()
|
||||
stack = [key for key in top_level if key in components]
|
||||
while stack:
|
||||
if (key := stack.pop()) in reachable:
|
||||
continue
|
||||
reachable.add(key)
|
||||
stack.extend(edge for edge in nodes[key].edges if edge in components)
|
||||
for key in [key for key in components if key not in reachable]:
|
||||
_LOGGER.debug("Library %s is not needed; dropping it", key)
|
||||
del components[key]
|
||||
|
||||
for requester, name in unresolved:
|
||||
if requester in components:
|
||||
_LOGGER.warning(
|
||||
"Dependency %s of %s was not resolved; skipping", name, requester
|
||||
)
|
||||
|
||||
# Two graph nodes that resolve to the same component name (e.g. a package
|
||||
# referenced both bare and as ``owner/name``) are not deduplicated and can
|
||||
# produce conflicting component definitions. Warn so it's not silent.
|
||||
@@ -1447,6 +1584,4 @@ def convert_libraries(
|
||||
for component in components.values():
|
||||
backend.emit(component)
|
||||
|
||||
_reconcile_versionless_skips(skipped_versionless, components, backend)
|
||||
|
||||
return [components[key] for key in top_level if key in components]
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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
|
||||
@@ -62,8 +62,9 @@ def test_write_idf_component_yml_passes_managed_components(
|
||||
converted.get_sanitized_name.return_value = "esphome/other-lib"
|
||||
converted.path = tmp_path / "pio_components" / "other-lib"
|
||||
|
||||
def fake_generate_idf_components(libraries, managed=None):
|
||||
def fake_generate_idf_components(libraries, managed=None, provides=None):
|
||||
captured["managed"] = managed
|
||||
captured["provides"] = provides
|
||||
return [converted]
|
||||
|
||||
monkeypatch.setattr(esp32, "generate_idf_components", fake_generate_idf_components)
|
||||
@@ -71,6 +72,8 @@ def test_write_idf_component_yml_passes_managed_components(
|
||||
esp32._write_idf_component_yml()
|
||||
|
||||
assert captured["managed"] == {"esphome/noise-c"}
|
||||
# ESP-IDF bundles no Arduino libraries, so every name may be fetched
|
||||
assert captured["provides"] is None
|
||||
# The managed component itself is still written into the manifest deps
|
||||
# directly (from KEY_COMPONENTS), just not converted a second time.
|
||||
yml_path = tmp_path / "src" / "idf_component.yml"
|
||||
@@ -93,7 +96,7 @@ def test_write_idf_component_yml_empty_managed_when_no_components(
|
||||
|
||||
captured: dict[str, set[str] | None] = {}
|
||||
|
||||
def fake_generate_idf_components(libraries, managed=None):
|
||||
def fake_generate_idf_components(libraries, managed=None, provides=None):
|
||||
captured["managed"] = managed
|
||||
return []
|
||||
|
||||
@@ -104,6 +107,50 @@ def test_write_idf_component_yml_empty_managed_when_no_components(
|
||||
assert captured["managed"] == set()
|
||||
|
||||
|
||||
def test_write_idf_component_yml_marks_arduino_bundled_libraries_provided(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Under Arduino the converter is told which names the core bundles, so a
|
||||
manifest dependency on Wire or SPI is never fetched from the registry."""
|
||||
_setup_core(tmp_path)
|
||||
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = str(Framework.ARDUINO)
|
||||
CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(3, 3, 12)
|
||||
CORE.data[esp32.KEY_ESP32] = {
|
||||
esp32.KEY_COMPONENTS: {},
|
||||
esp32.KEY_IDF_VERSION: cv.Version(5, 5, 4),
|
||||
esp32.KEY_ARDUINO_LIBRARIES: {"Wire", "SPI"},
|
||||
}
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_generate_idf_components(libraries, managed=None, provides=None):
|
||||
captured["provides"] = provides
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(esp32, "generate_idf_components", fake_generate_idf_components)
|
||||
|
||||
esp32._write_idf_component_yml()
|
||||
|
||||
provides = captured["provides"]
|
||||
assert provides("Wire") and provides("SPI")
|
||||
assert not provides("Adafruit BusIO")
|
||||
|
||||
|
||||
def test_arduino_provides_warns_for_a_bundled_library_not_enabled(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A manifest dependency on a bundled Arduino library is never fetched, but
|
||||
the library still has to be enabled, so an unenabled one is called out."""
|
||||
CORE.reset()
|
||||
CORE.data[esp32.KEY_ESP32] = {esp32.KEY_ARDUINO_LIBRARIES: {"Wire"}}
|
||||
assert esp32._arduino_provides("Wire")
|
||||
assert "Wire" not in caplog.text
|
||||
assert esp32._arduino_provides("SPI")
|
||||
assert "Arduino library SPI, which is not enabled" in caplog.text
|
||||
assert not esp32._arduino_provides("Adafruit BusIO")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("version", "libsodium_stubbed"),
|
||||
[
|
||||
@@ -200,7 +247,9 @@ def test_write_idf_component_yml_stub_yields_to_converted_library_of_same_name(
|
||||
converted.get_sanitized_name.return_value = "esphome/libsodium"
|
||||
converted.path = tmp_path / "pio_components" / "esphome" / "libsodium"
|
||||
monkeypatch.setattr(
|
||||
esp32, "generate_idf_components", lambda libraries, managed=None: [converted]
|
||||
esp32,
|
||||
"generate_idf_components",
|
||||
lambda libraries, managed=None, provides=None: [converted],
|
||||
)
|
||||
|
||||
esp32._write_idf_component_yml()
|
||||
|
||||
@@ -846,15 +846,14 @@ def test_library_info_dot_a_linkage_malformed_raises(tmp_path: Path) -> None:
|
||||
def test_bundled_library_properties_depends_warns(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The library.properties depends= spelling reaches the visibility
|
||||
warning too; the shared parser returns it raw."""
|
||||
"""The library.properties depends= spelling is a dependency list too, so a
|
||||
bundled manifest using it gets the same visibility warning."""
|
||||
framework = _make_framework(tmp_path)
|
||||
wire = framework / "libraries" / "Wire"
|
||||
(wire / "library.properties").write_text("name=Wire\nversion=1.0\ndepends=SPI\n")
|
||||
_add_library("Wire", None)
|
||||
caplog.set_level("INFO")
|
||||
_resolve(framework)
|
||||
assert "Library Wire declares dependencies via library.properties" in caplog.text
|
||||
assert "Bundled library Wire declares dependencies" in caplog.text
|
||||
|
||||
|
||||
def test_bundled_library_extra_script_raises(tmp_path: Path) -> None:
|
||||
|
||||
@@ -215,8 +215,9 @@ def test_convert_pio_libs_arduino_framework_passes_empty_managed(
|
||||
path=tmp_path / "other-lib",
|
||||
)
|
||||
|
||||
def fake_generate_idf_components(libraries, managed=None):
|
||||
def fake_generate_idf_components(libraries, managed=None, provides=None):
|
||||
captured["managed"] = managed
|
||||
captured["provides"] = provides
|
||||
return [converted]
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -226,6 +227,8 @@ def test_convert_pio_libs_arduino_framework_passes_empty_managed(
|
||||
result = _convert_pio_libs(tmp_path / "platformio.ini", "arduino")
|
||||
|
||||
assert captured["managed"] == set()
|
||||
# Bundled Arduino names are left to arduino-esp32, as in the real build
|
||||
assert captured["provides"]("Wire") and not captured["provides"]("Adafruit BusIO")
|
||||
assert result == {
|
||||
"esphome/other-lib": {"override_path": str(tmp_path / "other-lib")}
|
||||
}
|
||||
@@ -242,7 +245,7 @@ def test_convert_pio_libs_arduino_idf_6_passes_manifest_deps(
|
||||
|
||||
captured: dict[str, set[str] | None] = {}
|
||||
|
||||
def fake_generate_idf_components(libraries, managed=None):
|
||||
def fake_generate_idf_components(libraries, managed=None, provides=None):
|
||||
captured["managed"] = managed
|
||||
return []
|
||||
|
||||
@@ -265,8 +268,9 @@ def test_convert_pio_libs_espidf_framework_passes_manifest_deps(
|
||||
|
||||
captured: dict[str, set[str] | None] = {}
|
||||
|
||||
def fake_generate_idf_components(libraries, managed=None):
|
||||
def fake_generate_idf_components(libraries, managed=None, provides=None):
|
||||
captured["managed"] = managed
|
||||
captured["provides"] = provides
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -276,6 +280,8 @@ def test_convert_pio_libs_espidf_framework_passes_manifest_deps(
|
||||
result = _convert_pio_libs(tmp_path / "platformio.ini", "espidf")
|
||||
|
||||
assert captured["managed"] == _esphome_manifest_deps()
|
||||
# ESP-IDF bundles no Arduino libraries
|
||||
assert captured["provides"] is None
|
||||
assert "esphome/noise-c" in captured["managed"]
|
||||
assert result == {}
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ Covers the shared download/parse/resolve/dependency-walk paths in
|
||||
``esphome.platformio.library`` directly (the ESP-IDF and Zephyr backends are
|
||||
exercised in their own test modules)."""
|
||||
|
||||
from collections.abc import Collection
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
from platformio.package.exception import UnknownPackageError
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE, EsphomeError, Library
|
||||
@@ -261,23 +263,37 @@ def test_make_registry_client_creates_http_cache_dir(
|
||||
lib._make_registry_client()
|
||||
|
||||
|
||||
def _patch_registry_resolve(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Stub the registry lookup so tests never touch the network."""
|
||||
monkeypatch.setattr(
|
||||
lib,
|
||||
"_resolve_registry_version",
|
||||
lambda owner, pkgname, requirements: (
|
||||
owner,
|
||||
pkgname,
|
||||
"1.0.0",
|
||||
f"http://x/{pkgname}.tar.gz",
|
||||
None,
|
||||
),
|
||||
)
|
||||
def _patch_registry_resolve(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
unknown: Collection[str] = (),
|
||||
owner: str | None = None,
|
||||
) -> list[tuple[str | None, str]]:
|
||||
"""Stub the registry lookup so tests never touch the network; returns the
|
||||
(owner, name) pairs looked up. ``unknown`` names miss; ``owner`` is what the
|
||||
registry answers for an owner-less name."""
|
||||
asked: list[tuple[str | None, str]] = []
|
||||
|
||||
def resolve(requested_owner, pkgname, requirements):
|
||||
asked.append((requested_owner, pkgname))
|
||||
if pkgname in unknown:
|
||||
raise UnknownPackageError(pkgname)
|
||||
return _resolution(requested_owner or owner, pkgname)
|
||||
|
||||
monkeypatch.setattr(lib, "_resolve_registry_version", resolve)
|
||||
return asked
|
||||
|
||||
|
||||
def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()):
|
||||
"""Fake ConvertedLibrary.download to materialize canned manifests on disk."""
|
||||
def _resolution(owner, pkgname):
|
||||
"""What the stubbed registry answers for a package."""
|
||||
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz", None
|
||||
|
||||
|
||||
def _patch_download_with_manifests(
|
||||
monkeypatch, tmp_path, manifests, *, properties=(), unknown=(), owner=None
|
||||
):
|
||||
"""Fake ConvertedLibrary.download to materialize canned manifests on disk;
|
||||
returns the registry lookups made (see _patch_registry_resolve)."""
|
||||
|
||||
def fake_download(self, force=False, salt="", namespace=""):
|
||||
self.path = tmp_path / self.get_require_name()
|
||||
@@ -288,7 +304,7 @@ def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properti
|
||||
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
|
||||
|
||||
monkeypatch.setattr(ConvertedLibrary, "download", fake_download)
|
||||
_patch_registry_resolve(monkeypatch)
|
||||
return _patch_registry_resolve(monkeypatch, unknown=unknown, owner=owner)
|
||||
|
||||
|
||||
def test_wave_requirement_growth_defers_the_superseded_download(tmp_path, monkeypatch):
|
||||
@@ -343,18 +359,22 @@ def test_convert_libraries_parses_library_properties(tmp_path, monkeypatch):
|
||||
assert emitted[0].data["version"] == "1.0"
|
||||
|
||||
|
||||
def test_convert_libraries_skips_dependency_without_version(tmp_path, monkeypatch):
|
||||
# A dependency entry lacking a version is malformed and silently skipped.
|
||||
_patch_download_with_manifests(
|
||||
def test_convert_libraries_fetches_dependency_without_version(tmp_path, monkeypatch):
|
||||
# A dependency entry lacking a version resolves by name alone, as PlatformIO did.
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{"esphome/A": {"name": "A", "dependencies": [{"name": "C"}]}},
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "C"}]},
|
||||
"C": {"name": "C"},
|
||||
},
|
||||
)
|
||||
|
||||
# No version on the top-level spec exercises the "no requirement" path too.
|
||||
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
|
||||
assert top[0].dependencies == []
|
||||
assert [d.name for d in top[0].dependencies] == ["C"]
|
||||
assert asked == [("esphome", "A"), (None, "C")]
|
||||
|
||||
|
||||
def test_convert_libraries_handles_unparsable_dependency_version(tmp_path, monkeypatch):
|
||||
@@ -1013,20 +1033,24 @@ def test_convert_libraries_malformed_transitive_dep_skips(
|
||||
assert "Skipping dependency esphome/B: malformed manifest" in caplog.text
|
||||
|
||||
|
||||
def test_walk_warns_for_properties_only_depends(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A manifest declaring dependencies only as library.properties depends=
|
||||
warns in the shared walk, so every backend reports the drop."""
|
||||
_patch_download_with_manifests(
|
||||
def test_walk_resolves_properties_only_depends(tmp_path, monkeypatch) -> None:
|
||||
"""A library.properties depends= line is a dependency list: a bundled name
|
||||
goes to the backend, the rest resolve by name."""
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{"esphome/A": "name=A\nversion=1.0\ndepends=Wire, SPI\n"},
|
||||
{
|
||||
"esphome/A": "name=A\nversion=1.0\ndepends=Wire, SPI\n",
|
||||
"SPI": {"name": "SPI"},
|
||||
},
|
||||
properties=("esphome/A",),
|
||||
)
|
||||
caplog.set_level("INFO")
|
||||
convert_libraries([Library("esphome/A", "1.0.0", None)], _backend())
|
||||
assert "declares dependencies via library.properties" in caplog.text
|
||||
backend = _backend(provides=lambda name: name == "Wire")
|
||||
top = convert_libraries([Library("esphome/A", "1.0.0", None)], backend)
|
||||
assert [d.name for d in top[0].dependencies] == ["SPI"]
|
||||
assert "depends" not in top[0].data
|
||||
assert backend.provided_requests == {"Wire"}
|
||||
assert (None, "SPI") in asked
|
||||
|
||||
|
||||
def test_walk_warns_for_nonplatform_invalid_library(
|
||||
@@ -1093,12 +1117,12 @@ def test_source_kind_map_shape() -> None:
|
||||
assert SOURCE_KIND_FOR_SUFFIX[".C++"] == "cxx"
|
||||
|
||||
|
||||
def test_versionless_platform_filtered_dependency_stays_quiet(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
def test_versionless_platform_filtered_dependency_is_not_fetched(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""A version-less dependency the platform filter excludes is
|
||||
deliberately absent, not a drop to warn about."""
|
||||
_patch_download_with_manifests(
|
||||
"""A version-less dependency the platform filter excludes is deliberately
|
||||
absent, not fetched by name."""
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
@@ -1108,59 +1132,52 @@ def test_versionless_platform_filtered_dependency_stays_quiet(
|
||||
}
|
||||
},
|
||||
)
|
||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert top[0].dependencies == []
|
||||
assert (None, "Hash") not in asked
|
||||
|
||||
|
||||
def test_versionless_ignored_dependency_stays_quiet(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
def test_versionless_ignored_dependency_is_not_fetched(tmp_path, monkeypatch) -> None:
|
||||
"""A lib_ignore'd version-less dependency is deliberately excluded, not
|
||||
a drop; no reconciliation warning."""
|
||||
_patch_download_with_manifests(
|
||||
fetched by name."""
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{"esphome/A": {"name": "A", "dependencies": [{"name": "Hash"}]}},
|
||||
)
|
||||
CORE.platformio_options = {"lib_ignore": ["Hash"]}
|
||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert top[0].dependencies == []
|
||||
assert (None, "Hash") not in asked
|
||||
|
||||
|
||||
def test_versionless_dependency_without_provider_warns(
|
||||
def test_versionless_dependency_registry_miss_is_skipped(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A backend whose tree could supply the name warns on the drop; one
|
||||
without provides() can never act on it, so it stays at debug."""
|
||||
"""A name-only dependency the registry does not know is skipped with one
|
||||
warning naming the requester, and the requester still converts; a listed
|
||||
library the registry does not know still fails the build."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {
|
||||
"name": "A",
|
||||
# The duplicate entry warns once (reconciliation dedup)
|
||||
# The duplicate entry is one node, so it warns once
|
||||
"dependencies": [{"name": "Hash"}, {"name": "Hash"}],
|
||||
}
|
||||
},
|
||||
unknown={"Hash"},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None)], _backend(provides=lambda name: False)
|
||||
)
|
||||
|
||||
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert top[0].dependencies == []
|
||||
assert (
|
||||
caplog.text.count(
|
||||
"Hash of esphome/A has no version to resolve and nothing provides it"
|
||||
)
|
||||
caplog.text.count("Dependency Hash of esphome/A was not resolved; skipping")
|
||||
== 1
|
||||
)
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
records = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if "has no version to resolve and nothing provides it" in r.message
|
||||
]
|
||||
assert records and all(r.levelno == logging.DEBUG for r in records)
|
||||
with pytest.raises(UnknownPackageError):
|
||||
convert_libraries([Library("Hash", None, None)], _backend())
|
||||
|
||||
|
||||
def test_url_version_dependency_is_not_substituted_by_provides(
|
||||
@@ -1191,102 +1208,525 @@ def test_url_version_dependency_is_not_substituted_by_provides(
|
||||
assert any("o/hash" in n.lower() for n in emitted)
|
||||
|
||||
|
||||
def test_versionless_owner_qualified_dependency_warns_despite_provides(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
def test_versionless_owner_qualified_dependency_is_fetched_despite_provides(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""An owner-qualified version-less dependency is not satisfied by
|
||||
provides(); it must still warn."""
|
||||
_patch_download_with_manifests(
|
||||
"""An owner-qualified version-less dependency names a registry package,
|
||||
so provides() does not stand in for it."""
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {
|
||||
"name": "A",
|
||||
"dependencies": [{"name": "Wire", "owner": "Foo"}],
|
||||
}
|
||||
},
|
||||
"Foo/Wire": {"name": "Wire"},
|
||||
},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None)],
|
||||
_backend(provides=lambda name: name == "Wire"),
|
||||
)
|
||||
assert "Wire of esphome/A has no version to resolve" in caplog.text
|
||||
|
||||
|
||||
def test_versionless_provided_dependency_stays_quiet(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An owner-less version-less dependency the backend provides is added
|
||||
by the backend after emit; no reconciliation warning."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{"esphome/A": {"name": "A", "dependencies": [{"name": "Wire"}]}},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None)],
|
||||
_backend(provides=lambda name: name == "Wire"),
|
||||
)
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
|
||||
|
||||
def test_versionless_dependency_requested_top_level_stays_quiet(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A version-less dependency the config also requests top-level is in
|
||||
the build; no drop warning even without a provides backend."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "Hash"}]},
|
||||
"Hash": {"name": "Hash"},
|
||||
},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None), Library("Hash", None, None)],
|
||||
_backend(),
|
||||
)
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
backend = _backend(provides=lambda name: name == "Wire")
|
||||
top = convert_libraries([Library("esphome/A", None, None)], backend)
|
||||
assert [d.name for d in top[0].dependencies] == ["Foo/Wire"]
|
||||
assert ("Foo", "Wire") in asked
|
||||
assert not backend.provided_requests
|
||||
|
||||
|
||||
def test_versionless_url_ish_dependency_name_warns_cleanly(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A malformed URL-ish dependency name falls to the drop warning, never
|
||||
"""A malformed URL-ish dependency name is skipped with a warning, never
|
||||
a RuntimeError out of the key parser."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{"esphome/A": {"name": "A", "dependencies": [{"name": "file://"}]}},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None)], _backend(provides=lambda name: False)
|
||||
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert top[0].dependencies == []
|
||||
assert "Skipping dependency file:// of esphome/A" in caplog.text
|
||||
|
||||
|
||||
def test_versionless_dependency_matching_resolved_manifest_name_is_wired(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A bare name no package name matches is satisfied by a listed component's
|
||||
manifest name (a fork published under another package name)."""
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
||||
"esphome/B-fork": {"name": "B"},
|
||||
},
|
||||
)
|
||||
with caplog.at_level(logging.INFO):
|
||||
top = convert_libraries(
|
||||
[Library("esphome/A", None, None), Library("esphome/B-fork", None, None)],
|
||||
_backend(),
|
||||
)
|
||||
assert [d.name for d in top[0].dependencies] == ["esphome/B-fork"]
|
||||
assert asked == [("esphome", "A"), ("esphome", "B-fork")]
|
||||
assert (
|
||||
"file:// of esphome/A has no version to resolve and nothing provides it"
|
||||
"Dependency B of esphome/A satisfied by esphome/B-fork through its manifest name"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
|
||||
def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet(
|
||||
def test_dependency_naming_the_requester_itself_is_ignored(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""A manifest that lists its own name as a dependency neither fetches a
|
||||
second copy of itself nor gains a self edge."""
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": "name=A\ndepends=a, esphome/A\n",
|
||||
# No manifest name: the requested and resolved package names still
|
||||
# identify it, with or without the owner
|
||||
"esphome/C": {
|
||||
"dependencies": [{"name": "C"}, {"name": "C", "owner": "esphome"}]
|
||||
},
|
||||
},
|
||||
properties=("esphome/A",),
|
||||
)
|
||||
top = convert_libraries(
|
||||
[Library("esphome/A", "1.0.0", None), Library("esphome/C", "1.0.0", None)],
|
||||
_backend(),
|
||||
)
|
||||
assert [c.dependencies for c in top] == [[], []]
|
||||
assert asked == [("esphome", "A"), ("esphome", "C")]
|
||||
|
||||
|
||||
def test_versionless_dependency_settles_after_the_graph(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A bare name satisfied by an owner-qualified component's manifest
|
||||
name is not a drop."""
|
||||
"""The issue 21308 shape: a library.properties depends= naming a library the
|
||||
config lists owner-qualified is wired to that one package, case-insensitively
|
||||
and whatever constraint it carries, without a second node for the bare name
|
||||
and ahead of a same-named bundled copy, as on the Arduino backend."""
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"adafruit/Adafruit GFX Library": (
|
||||
"name=Adafruit GFX Library\ndepends=adafruit busio (>=1.0)\n"
|
||||
),
|
||||
"adafruit/Adafruit BusIO": {"name": "Adafruit BusIO"},
|
||||
},
|
||||
properties=("adafruit/Adafruit GFX Library",),
|
||||
)
|
||||
backend = _backend(provides=lambda name: name == "adafruit busio")
|
||||
top = convert_libraries(
|
||||
[
|
||||
Library("adafruit/Adafruit GFX Library", "^1.13.0", None),
|
||||
Library("adafruit/Adafruit BusIO", "^1.17.4", None),
|
||||
],
|
||||
backend,
|
||||
)
|
||||
assert [d.name for d in top[0].dependencies] == ["adafruit/Adafruit BusIO"]
|
||||
assert len(asked) == 2
|
||||
assert not backend.provided_requests
|
||||
assert "multiple names" not in caplog.text
|
||||
|
||||
|
||||
def test_by_name_node_folds_into_a_later_pin_of_the_same_package(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A library fetched by name alone may pull in a manifest that pins the same
|
||||
package owner-qualified; the by-name node folds into that one instead of
|
||||
converting the package twice."""
|
||||
# Manifests are keyed by the resolved name, so both B nodes read the same one:
|
||||
# B pins C, and C pins owner/B, which arrives after B was fetched by name
|
||||
manifests = {
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
||||
"esphome/B": {"name": "B", "dependencies": {"esphome/C": "1.0.0"}},
|
||||
"esphome/C": {"name": "C", "dependencies": {"esphome/B": "1.0.0"}},
|
||||
}
|
||||
_patch_download_with_manifests(monkeypatch, tmp_path, manifests, owner="esphome")
|
||||
|
||||
emitted: list[str] = []
|
||||
top = convert_libraries(
|
||||
[Library("esphome/A", None, None)],
|
||||
_backend(emit=lambda c: emitted.append(c.name)),
|
||||
)
|
||||
assert [d.name for d in top[0].dependencies] == ["esphome/B"]
|
||||
assert emitted.count("esphome/B") == 1
|
||||
assert "multiple names" not in caplog.text
|
||||
|
||||
|
||||
def test_two_spellings_of_a_name_in_one_round_share_a_node(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Requesters spelling one unlisted name differently get a single by-name
|
||||
fetch and the same node."""
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "Foo"}]},
|
||||
"esphome/B": {"name": "B", "dependencies": [{"name": "foo"}]},
|
||||
"Foo": {"name": "Foo"},
|
||||
},
|
||||
)
|
||||
top = convert_libraries(
|
||||
[Library("esphome/A", None, None), Library("esphome/B", None, None)],
|
||||
_backend(),
|
||||
)
|
||||
assert [d.name for d in top[0].dependencies] == ["Foo"]
|
||||
assert [d.name for d in top[1].dependencies] == ["Foo"]
|
||||
assert asked.count((None, "Foo")) + asked.count((None, "foo")) == 1
|
||||
assert "multiple names" not in caplog.text
|
||||
|
||||
|
||||
def test_parse_properties_depends_reads_names_and_drops_constraints(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Arduino constraints are not PlatformIO syntax, so only the names survive;
|
||||
an unclosed parenthesis loses nothing more than the constraint."""
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
entries = lib.parse_properties_depends(
|
||||
"Adafruit BusIO, Foo (>=1.2), ,Bar( <2 ), Baz (=1.0, Qux ()"
|
||||
)
|
||||
levels = {
|
||||
r.message.removeprefix("Ignoring version constraint on depends="): r.levelno
|
||||
for r in caplog.records
|
||||
if "Ignoring version constraint" in r.message
|
||||
}
|
||||
# A lower bound is met by the current version; a cap or pin may not be
|
||||
assert levels == {
|
||||
"Foo (>=1.2)": logging.DEBUG,
|
||||
"Bar( <2 )": logging.INFO,
|
||||
"Baz (=1.0": logging.INFO,
|
||||
"Qux ()": logging.INFO,
|
||||
}
|
||||
assert entries == [
|
||||
{"name": "Adafruit BusIO"},
|
||||
{"name": "Foo"},
|
||||
{"name": "Bar"},
|
||||
{"name": "Baz"},
|
||||
{"name": "Qux"},
|
||||
]
|
||||
assert "Ignoring version constraint on depends=Foo (>=1.2)" in caplog.text
|
||||
|
||||
|
||||
def test_by_name_node_pinned_later_reports_a_requirement_conflict(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""A node fetched by name that a later manifest pins under the same spelling
|
||||
is resolved again against the pin; a conflict is an error, not a miss."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
||||
"esphome/B": {"name": "B"},
|
||||
"B": {"name": "B", "dependencies": {"C": "1.0.0"}},
|
||||
"C": {"name": "C", "dependencies": {"B": "^2.0"}},
|
||||
},
|
||||
)
|
||||
|
||||
def resolve(owner, pkgname, requirements):
|
||||
if pkgname == "B" and requirements:
|
||||
raise RuntimeError(f"No version of {pkgname} satisfies {requirements}")
|
||||
return _resolution(owner, pkgname)
|
||||
|
||||
monkeypatch.setattr(lib, "_resolve_registry_version", resolve)
|
||||
with pytest.raises(RuntimeError, match="No version of B satisfies"):
|
||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
|
||||
|
||||
def test_by_name_fetch_yields_to_a_fork_pulled_in_later(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A name fetched from the registry yields to a library another round
|
||||
pins explicitly under the same manifest name, so only the fork is built."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
||||
"esphome/X": {"name": "X", "dependencies": [{"name": "C"}]},
|
||||
# The upstream B pulls in D, which nothing else needs; D asks for a
|
||||
# name the registry does not know
|
||||
"B": {"name": "B", "dependencies": {"esphome/D": "1.0.0"}},
|
||||
"esphome/D": {"name": "D", "dependencies": [{"name": "Zed"}]},
|
||||
"C": {"name": "C", "dependencies": {"custom/B-fork": "1.0.0"}},
|
||||
"custom/B-fork": {"name": "B"},
|
||||
},
|
||||
unknown={"Zed"},
|
||||
)
|
||||
|
||||
emitted: list[str] = []
|
||||
top = convert_libraries(
|
||||
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
|
||||
_backend(emit=lambda c: emitted.append(c.name)),
|
||||
)
|
||||
assert [d.name for d in top[0].dependencies] == ["custom/B-fork"]
|
||||
assert sorted(emitted) == ["C", "custom/B-fork", "esphome/A", "esphome/X"]
|
||||
# The miss belongs to a library that is not built, so it is not reported
|
||||
assert "Zed" not in caplog.text
|
||||
|
||||
|
||||
def test_git_source_replacing_a_registry_key_in_the_same_wave(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""A manifest in one wave naming a key of the same wave as a git source
|
||||
defers that key to the next wave instead of tripping over its stale record."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"o/A": {"name": "A", "dependencies": {"B": "https://github.com/o/B.git"}},
|
||||
"o/B": {"name": "B"},
|
||||
},
|
||||
)
|
||||
emitted: dict[str, ConvertedLibrary] = {}
|
||||
top = convert_libraries(
|
||||
[Library("o/A", "1.0.0", None), Library("o/B", "1.0.0", None)],
|
||||
_backend(emit=lambda c: emitted.__setitem__(c.name, c)),
|
||||
)
|
||||
assert [c.name for c in top] == ["o/A", "o/B"]
|
||||
assert isinstance(emitted["o/B"].source, GitSource)
|
||||
assert [d.name for d in top[0].dependencies] == ["o/B"]
|
||||
|
||||
|
||||
def test_owner_qualified_name_matches_the_resolved_owner(tmp_path, monkeypatch) -> None:
|
||||
"""A version-less ``o/B`` is satisfied by a bare ``B`` pin the registry
|
||||
resolved to owner ``o``, so the package is not fetched a second time."""
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {
|
||||
"name": "A",
|
||||
"dependencies": [
|
||||
{"name": "B", "version": "1.0.0"},
|
||||
{"name": "B", "owner": "o"},
|
||||
],
|
||||
},
|
||||
"o/B": {"name": "B"},
|
||||
},
|
||||
owner="o",
|
||||
)
|
||||
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert [d.name for d in top[0].dependencies] == ["o/B"]
|
||||
assert asked.count((None, "B")) == 1
|
||||
assert ("o", "B") not in asked
|
||||
|
||||
|
||||
def test_two_spellings_fetched_by_name_fold_into_one_node(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Version-less ``B`` and ``o/B`` from two requesters resolve to one
|
||||
package; only one copy is built and both requesters depend on it."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
||||
"esphome/X": {"name": "X", "dependencies": [{"name": "B", "owner": "o"}]},
|
||||
"o/B": {"name": "B"},
|
||||
},
|
||||
owner="o",
|
||||
)
|
||||
emitted: list[str] = []
|
||||
top = convert_libraries(
|
||||
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
|
||||
_backend(emit=lambda c: emitted.append(c.name)),
|
||||
)
|
||||
assert emitted.count("o/B") == 1
|
||||
kept = top[0].dependencies[0]
|
||||
assert top[1].dependencies == [kept]
|
||||
assert "multiple names" not in caplog.text
|
||||
|
||||
|
||||
def test_registry_miss_resolves_again_as_a_git_source(tmp_path, monkeypatch) -> None:
|
||||
"""A key the registry missed is resolved again when a later manifest names
|
||||
the same key as a git source."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "B", "owner": "o"}]},
|
||||
"esphome/X": {"name": "X", "dependencies": [{"name": "C"}]},
|
||||
"C": {"name": "C", "dependencies": {"B": "https://github.com/o/B.git"}},
|
||||
"o/B": {"name": "B"},
|
||||
},
|
||||
unknown={"B"},
|
||||
)
|
||||
|
||||
emitted: dict[str, ConvertedLibrary] = {}
|
||||
top = convert_libraries(
|
||||
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
|
||||
_backend(emit=lambda c: emitted.__setitem__(c.name, c)),
|
||||
)
|
||||
assert [d.name for d in top[0].dependencies] == ["o/B"]
|
||||
assert isinstance(emitted["o/B"].source, GitSource)
|
||||
assert [d.name for d in emitted["C"].dependencies] == ["o/B"]
|
||||
|
||||
|
||||
def test_normalize_dependencies_splits_an_embedded_owner() -> None:
|
||||
"""``owner/Name`` as one string, in a list or a dict entry, normalizes like
|
||||
the dict form; a URL or a deeper path is left alone."""
|
||||
assert lib.normalize_dependencies(
|
||||
["o/B", {"name": "o/C", "version": "1.0"}, "x://y", "a/b/c", "../escape"],
|
||||
"libx",
|
||||
) == [
|
||||
{"name": "B", "owner": "o"},
|
||||
{"name": "C", "owner": "o", "version": "1.0"},
|
||||
{"name": "x://y"},
|
||||
{"name": "a/b/c"},
|
||||
{"name": "../escape"},
|
||||
]
|
||||
assert lib.normalize_dependencies("o/B, D", "libx") == [
|
||||
{"name": "B", "owner": "o"},
|
||||
{"name": "D"},
|
||||
]
|
||||
|
||||
|
||||
def test_owner_qualified_string_dependency_is_fetched_and_wired(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""``"dependencies": ["o/B"]`` fetches ``o/B`` and wires the requester to it."""
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": ["o/B"]},
|
||||
"o/B": {"name": "B"},
|
||||
},
|
||||
)
|
||||
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert [d.name for d in top[0].dependencies] == ["o/B"]
|
||||
assert asked == [("esphome", "A"), ("o", "B")]
|
||||
|
||||
|
||||
def test_empty_version_is_settled_like_a_missing_one(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""``{"Foo": ""}`` carries no version either, so it is matched, fetched by
|
||||
name or reported like a version-less entry rather than failing or vanishing."""
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": {"Foo": "", "Bar": ""}},
|
||||
"Bar": {"name": "Bar"},
|
||||
},
|
||||
unknown={"Foo"},
|
||||
)
|
||||
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert [d.name for d in top[0].dependencies] == ["Bar"]
|
||||
assert (None, "Foo") in asked
|
||||
assert "Dependency Foo of esphome/A was not resolved; skipping" in caplog.text
|
||||
|
||||
|
||||
def test_bundled_name_is_satisfied_by_a_replacement_pulled_in_later(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""A name the framework bundles is still wired to an explicit replacement
|
||||
of the same manifest name that a later round pulls in, as it would be had
|
||||
the replacement been listed up front."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "Ethernet"}]},
|
||||
"esphome/X": {"name": "X", "dependencies": [{"name": "C"}]},
|
||||
"C": {"name": "C", "dependencies": {"vendor/Ethernet": "1.0.0"}},
|
||||
"vendor/Ethernet": {"name": "Ethernet"},
|
||||
},
|
||||
)
|
||||
backend = _backend(provides=lambda name: name == "Ethernet")
|
||||
top = convert_libraries(
|
||||
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
|
||||
backend,
|
||||
)
|
||||
assert [d.name for d in top[0].dependencies] == ["vendor/Ethernet"]
|
||||
|
||||
|
||||
def test_missed_name_is_satisfied_by_a_library_pulled_in_later(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A bare name the registry does not know is wired to a library a later
|
||||
round pulls in under another package name but the same manifest name."""
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
||||
"esphome/X": {"name": "X", "dependencies": [{"name": "C"}]},
|
||||
# Fetched by name in the same round as the miss; pins the fork
|
||||
"C": {"name": "C", "dependencies": {"owner/B-fork": "1.0.0"}},
|
||||
"owner/B-fork": {"name": "B"},
|
||||
},
|
||||
unknown={"B"},
|
||||
)
|
||||
with caplog.at_level(logging.INFO):
|
||||
top = convert_libraries(
|
||||
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
|
||||
_backend(),
|
||||
)
|
||||
assert [d.name for d in top[0].dependencies] == ["owner/B-fork"]
|
||||
assert asked.count((None, "B")) == 1
|
||||
assert (
|
||||
"Dependency B of esphome/A satisfied by owner/B-fork through its manifest name"
|
||||
in caplog.text
|
||||
)
|
||||
assert "skipping" not in caplog.text
|
||||
|
||||
|
||||
def test_by_name_request_does_not_make_an_existing_node_optional(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A version-less request for a node the walk already created (here one
|
||||
skipped for its platform) neither re-fetches it nor turns a later miss
|
||||
into a warning."""
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {
|
||||
"name": "A",
|
||||
"dependencies": [{"name": "X", "version": "1.0"}, {"name": "X"}],
|
||||
},
|
||||
"X": {"name": "X", "platforms": "espressif8266"},
|
||||
},
|
||||
)
|
||||
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert top[0].dependencies == []
|
||||
assert asked == [("esphome", "A"), (None, "X")]
|
||||
assert "not in the registry" not in caplog.text
|
||||
|
||||
|
||||
def test_provided_dependency_asks_the_backend_once_per_name(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""A bundled name is recorded for the backend and never fetched; two
|
||||
requesters cost one provides() call, so a backend warning there is not
|
||||
repeated. A pinned request on the same name shares the memo."""
|
||||
asked = _patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "Wire"}]},
|
||||
"esphome/B": {"name": "B", "dependencies": {"Wire": "*"}},
|
||||
},
|
||||
)
|
||||
calls: list[str] = []
|
||||
|
||||
def provides(name: str) -> bool:
|
||||
calls.append(name)
|
||||
return name == "Wire"
|
||||
|
||||
backend = _backend(provides=provides)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None), Library("esphome/B", None, None)],
|
||||
_backend(),
|
||||
backend,
|
||||
)
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
assert calls == ["Wire"]
|
||||
assert backend.provided_requests == {"Wire"}
|
||||
assert (None, "Wire") not in asked
|
||||
|
||||
|
||||
def test_convert_libraries_symlink_url_resolves_as_local(setup_core: Path) -> None:
|
||||
|
||||
Reference in New Issue
Block a user