Compare commits

...
Author SHA1 Message Date
J. Nick Koston c610ab837d Merge branch 'dev' into library-versionless-dependencies 2026-10-21 18:24:19 -10:00
e1077748be [api][cover] Expose toggle support in native API (#16506)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-21 18:11:09 -10:00
cc89a011e2 Bump clang-tidy + clang-format from 22.1.8 to 23.1.3 (#21296)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-21 17:45:36 -10:00
J. Nick Koston 406d2069a5 Flatten the owner check ruff flagged 2026-10-21 17:22:47 -10:00
J. Nick Koston 5dabeb1525 Split an owner written into a dependency name
"owner/Name" given as one string, in a list entry, a dict entry or a
depends= line, normalizes into owner and name like the dict form does,
so the request matches the package the registry resolves it to and is
wired instead of being pruned.
2026-10-21 17:10:31 -10:00
Craig Andrews c76e9e1d62 [epaper_spi] Support Good Display GDEY029Z95 2.9" 3-color e-paper (128x296, SSD1680) (#21218) 2026-10-21 17:09:44 -10:00
esphome[bot] 7a3aac407f Bump aioesphomeapi from 46.18.0 to 46.19.0 (#21345) 2026-10-21 17:03:34 -10:00
J. Nick Koston 0d7cc4646d Merge branch 'dev' into library-versionless-dependencies 2026-10-21 17:00:38 -10:00
J. Nick Koston b0a1054e8d [ld2410] Publish queried number values directly (#21346) 2026-10-21 16:56:51 -10:00
a8a8acb918 [sensor] Add Relative Humidity Correction Filter (#18287)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-10-21 16:33:11 -10:00
J. Nick Koston e2eafda5f6 Settle an empty version like a missing one
A dependency pinned to an empty string carries no version either, so it
takes the deferred path and a registry miss is reported for its
requester instead of being skipped quietly. A leftover docstring line
in normalize_dependencies is removed.
2026-10-21 16:32:35 -10:00
J. Nick Koston 39d8bf58ae Wire a bundled name to a replacement pulled in later
A request the framework provides is kept for the final wiring too, so an
explicit library of that name a later round pulls in still beats the
bundled copy, as it does when it is listed up front.
2026-10-21 16:22:52 -10:00
J. Nick Koston afd5d324ed Wire version-less requests in one pass once the graph is final
The settle step now only decides what to fetch by name; every request is
kept for the whole walk and wired at the end to the explicit library of
that name if there is one, else to the node fetched by name, else
reported for the libraries that are built. Nodes nothing reaches are
dropped. That replaces the separate yield, fold, re-match and late
report passes and the miss bookkeeping. The depends= parser also serves
the string form of a library.json dependencies entry.
2026-10-21 16:20:14 -10:00
J. Nick Koston fa823d1a30 Ignore an owner-qualified dependency on the requester itself too
The self check compares the requested owner and name, and a library's
resolved package name, against the requester's own names.
2026-10-21 16:04:58 -10:00
J. Nick Koston 5088c4a956 Defer a key whose source changed mid-wave, fold aliases by resolved owner
A git source replacing a registry key within the same wave no longer
trips the stale-record guard; the key waits for the next wave. The
matcher compares a requested owner with the owner the registry resolved,
and two names fetched for one package fold into one node. Registry
misses are reported after the prune, only for libraries that are built,
and a dropped exact or capped depends= constraint is logged at info.
2026-10-21 15:52:10 -10:00
J. Nick Koston b405cbb80a Report a registry miss per requester once the graph is final
The final pass runs before the prune, so a library only a yielded one
pulled in can still satisfy a missed name, and it reports every library
whose name stayed unmatched, including requesters from later rounds.
2026-10-21 15:39:05 -10:00
J. Nick Koston 4b0bef2569 Drop what only a yielded library needed
After a library fetched by name yields to an explicit one, the components
no top-level library reaches any more are not converted either.
2026-10-21 15:27:31 -10:00
J. Nick Koston 70a33c60fa Resolve a missed key again as a git source and let a by-name fetch yield
A key the registry did not know is resolved again when a later manifest
names it as a git or local source. Once the graph is final, a library
fetched by name yields to one asked for explicitly under the same
package or manifest name, whichever round brought it in; that replaces
the fold limited to the same resolved package.
2026-10-21 15:14:31 -10:00
J. Nick Koston c26d5502f9 Re-match only the names the registry missed, and say when one is satisfied
The final pass considers only the by-name nodes whose registry miss was
skipped, not nodes dropped by the platform filter, and logs at info when
a name warned about as missing is satisfied by a library pulled in later.
2026-10-21 15:10:00 -10:00
J. Nick Koston 13ef930573 Match a missed name against libraries pulled in later
A bare name the registry did not know is fetched in the same round as the
libraries that may later satisfy it, so once the graph is finished the
edges to such missed nodes are matched once more, without another fetch.
2026-10-21 15:01:10 -10:00
J. Nick Koston 499f716a7d Leave Arduino-bundled names to arduino-esp32 in the clang-tidy conversion too
The clang-tidy project converts the PlatformIO libraries through the same
converter as the esp32 build, so it passes the bundled library names for
an Arduino environment as well.
2026-10-21 13:56:52 -10:00
J. Nick Koston 0d9402cf82 Log a manifest-name match and ignore a dependency on the requester itself
A match found only through a library's manifest name is logged at info,
so a chance name collision can be pinned to the right package. A
manifest listing its own name as a dependency no longer fetches a
second copy of itself.
2026-10-21 12:52:08 -10:00
J. Nick Koston a1d730704e Compute "fetched by name" from the node and share the bundled-name check
A node fetched by name is a registry node no listed or versioned request
asked for, so nothing has to set or clear a flag. One closure answers
whether the framework bundles a name for both the versioned and the
deferred path, once per name. Requesters that spell one unlisted name
differently share a node within a settle round.
2026-10-21 12:41:42 -10:00
J. Nick Koston 5a5d295455 Defer every depends= entry and keep a pinned node fatal on a miss
An Arduino version constraint is not PlatformIO syntax, so the parser
keeps only the name and every depends= entry takes the deferred path,
where a listed library satisfies it and a registry miss is skipped. A
node is only miss-tolerant while it was created by name and never given
a version; a versioned request clears that, and a requirement conflict
always raises. The matcher skips the requester and checks package names
before manifest names; the backend is asked once per bundled name.
2026-10-21 12:24:29 -10:00
J. Nick Koston 88424041b1 Cover the matcher's miss and the by-name fold
The fold test keyed the by-name node's manifest under a name the fake
download never read, so the fold never ran. The matcher's trailing
return is replaced by a next() so coverage attributes the miss.
2026-10-21 12:08:10 -10:00
J. Nick Koston a0c9f3aac7 Match a listed library before the bundled copy and fold by-name duplicates
A library in the graph wins over a same-named bundled one, as on the
Arduino backend. A node fetched by name alone that resolves to a package
another manifest later pins folds into that node. Pending entries are
kept per requester so a re-walk replaces them. ESP32 Arduino warns when
a manifest depends on a bundled library that is not enabled.
2026-10-21 12:00:40 -10:00
J. Nick Koston 4d35fb83a4 [core] Resolve version-less library dependencies like PlatformIO
A library.properties depends= line and a library.json dependency without
a version were dropped, so the converted ESP-IDF component never required
the library it includes from. Parse depends= as dependencies, wire a
version-less dependency to the library that satisfies it once the graph
is known, and otherwise fetch it from the registry by name. Framework
bundled names and lib_ignore are still skipped; a registry miss for such
a dependency warns instead of failing the build.
2026-10-21 11:52:51 -10:00
46 changed files with 1882 additions and 306 deletions
+8
View File
@@ -12,6 +12,7 @@ Checks: >-
-bugprone-multi-level-implicit-pointer-conversion,
-bugprone-narrowing-conversions,
-bugprone-tagged-union-member-count,
-bugprone-signed-bitwise,
-bugprone-signed-char-misuse,
-bugprone-switch-missing-default-case,
-cert-dcl50-cpp,
@@ -39,6 +40,7 @@ Checks: >-
-cppcoreguidelines-avoid-const-or-ref-data-members,
-cppcoreguidelines-avoid-do-while,
-cppcoreguidelines-avoid-magic-numbers,
-cppcoreguidelines-explicit-constructor,
-cppcoreguidelines-init-variables,
-cppcoreguidelines-macro-to-enum,
-cppcoreguidelines-macro-usage,
@@ -84,6 +86,7 @@ Checks: >-
-llvm-use-ranges,
-llvmlibc-*,
-misc-const-correctness,
-misc-explicit-constructor,
-misc-include-cleaner,
-misc-multiple-inheritance,
-misc-no-recursion,
@@ -113,6 +116,7 @@ Checks: >-
-objc-*,
-performance-enum-size,
-portability-avoid-pragma-once,
-portability-no-assembler,
-portability-template-virtual-member-function,
-readability-ambiguous-smartptr-reset-call,
-readability-avoid-nested-conditional-operator,
@@ -129,9 +133,13 @@ Checks: >-
-readability-named-parameter,
-readability-redundant-casting,
-readability-redundant-inline-specifier,
-readability-redundant-lambda-parameter-list,
-readability-redundant-member-init,
-readability-redundant-nested-if,
-readability-redundant-parentheses,
-readability-redundant-typename,
-readability-trailing-comma,
-readability-trivial-switch,
-readability-uppercase-literal-suffix,
-readability-use-anyofallof,
-readability-use-std-min-max,
+1 -1
View File
@@ -38,7 +38,7 @@ repos:
- id: yamllint
exclude: ^(\.clang-format|\.clang-tidy)$
- repo: https://github.com/pre-commit/mirrors-clang-format
rev: v22.1.8
rev: v23.1.3
hooks:
- id: clang-format
types_or: [c, c++]
-2
View File
@@ -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
+2
View File
@@ -608,6 +608,7 @@ message ListEntitiesCoverResponse {
bool supports_stop = 12;
uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"];
bool supports_stop_tilt = 14;
bool supports_toggle = 15;
}
// Deprecated in API version 1.1
@@ -670,6 +671,7 @@ message CoverCommandRequest {
bool stop = 8;
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
bool stop_tilt = 10;
bool toggle = 11;
}
// ==================== FAN ====================
@@ -555,6 +555,7 @@ uint16_t APIConnection::try_send_cover_info(EntityBase *entity, APIConnection *c
msg.supports_tilt = traits.get_supports_tilt();
msg.supports_stop = traits.get_supports_stop();
msg.supports_stop_tilt = traits.get_supports_stop_tilt();
msg.supports_toggle = traits.get_supports_toggle();
return fill_and_encode_entity_info_with_device_class(cover, msg, msg.device_class, conn, remaining_size);
}
void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) {
@@ -567,6 +568,8 @@ void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) {
call.set_command_stop();
if (msg.stop_tilt)
call.set_command_stop_tilt();
if (msg.toggle)
call.set_command_toggle();
call.perform();
}
#endif
+5
View File
@@ -484,6 +484,7 @@ uint8_t *ListEntitiesCoverResponse::encode_msg(const void *self, uint8_t *__rest
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_id);
#endif
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.supports_stop_tilt);
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 15, msg.supports_toggle);
return pos;
}
uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) {
@@ -506,6 +507,7 @@ uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) {
size += ProtoSize::calc_uint32(1, msg.device_id);
#endif
size += ProtoSize::calc_bool(1, msg.supports_stop_tilt);
size += ProtoSize::calc_bool(1, msg.supports_toggle);
return size;
}
uint8_t *CoverStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
@@ -565,6 +567,9 @@ void CoverCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *
case proto_tag(10, WIRE_TYPE_VARINT):
msg.stop_tilt = value.as_bool();
break;
case proto_tag(11, WIRE_TYPE_VARINT):
msg.toggle = value.as_bool();
break;
default:
break;
}
+4 -2
View File
@@ -911,7 +911,7 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage {
class ListEntitiesCoverResponse final : public InfoResponseProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 13;
static constexpr uint8_t ESTIMATED_SIZE = 59;
static constexpr uint8_t ESTIMATED_SIZE = 61;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_cover_response"); }
#endif
@@ -921,6 +921,7 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage {
ProgmemStringRef device_class{};
bool supports_stop{false};
bool supports_stop_tilt{false};
bool supports_toggle{false};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
@@ -958,7 +959,7 @@ class CoverStateResponse final : public StateResponseProtoMessage {
class CoverCommandRequest final : public CommandProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 30;
static constexpr uint8_t ESTIMATED_SIZE = 27;
static constexpr uint8_t ESTIMATED_SIZE = 29;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("cover_command_request"); }
#endif
@@ -968,6 +969,7 @@ class CoverCommandRequest final : public CommandProtoMessage {
float tilt{0.0f};
bool stop{false};
bool stop_tilt{false};
bool toggle{false};
void decode(const uint8_t *buffer, size_t length) {
ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);
}
+2
View File
@@ -1246,6 +1246,7 @@ const char *ListEntitiesCoverResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("supports_stop_tilt"), this->supports_stop_tilt);
dump_field(out, ESPHOME_PSTR("supports_toggle"), this->supports_toggle);
return out.c_str();
}
const char *CoverStateResponse::dump_to(DumpBuffer &out) const {
@@ -1271,6 +1272,7 @@ const char *CoverCommandRequest::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("stop_tilt"), this->stop_tilt);
dump_field(out, ESPHOME_PSTR("toggle"), this->toggle);
return out.c_str();
}
#endif
+1 -1
View File
@@ -301,7 +301,7 @@ constexpr uint32_t VARINT_MAX_1_BYTE = 1 << 7; // 128
constexpr uint32_t VARINT_MAX_2_BYTE = 1 << 14; // 16384
/// Generated encode body: writes the fields at pos, returns the cursor past them.
using ProtoEncodeFn = uint8_t *(*) (const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM);
using ProtoEncodeFn = uint8_t *(*)(const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM);
/// Static encode helpers for the generated encode bodies. Each takes the write cursor by value and
/// returns it advanced, so outlined calls at -Os chain through the return register instead of a
+4 -5
View File
@@ -111,10 +111,9 @@ static const uint8_t BL0906_USR_WRPROT = 0x9E;
// Reset Register
static const uint8_t BL0906_SOFT_RESET = 0x9F;
const uint8_t BL0906_INIT[2][6] = {
// Reset to default
{BL0906_WRITE_COMMAND, BL0906_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x52},
// Enable User Operation Write
{BL0906_WRITE_COMMAND, BL0906_USR_WRPROT, 0x55, 0x55, 0x00, 0xB7}};
const uint8_t BL0906_INIT[2][6] = {// Reset to default
{BL0906_WRITE_COMMAND, BL0906_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x52},
// Enable User Operation Write
{BL0906_WRITE_COMMAND, BL0906_USR_WRPROT, 0x55, 0x55, 0x00, 0xB7}};
} // namespace esphome::bl0906
@@ -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",
)
+19 -1
View File
@@ -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
+9 -16
View File
@@ -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;
}
+2 -2
View File
@@ -244,9 +244,9 @@ class MipiSpi : public display::Display,
char hex_buf[format_hex_pretty_size(MIPI_SPI_MAX_CMD_LOG_BYTES)];
// Don't spam the log after setup
if (this->init_sequence_len_ == 0) {
esph_log_v(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
esph_log_v(TAG, "Command %02X, length %zu, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
} else {
esph_log_d(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
esph_log_d(TAG, "Command %02X, length %zu, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
}
if constexpr (BUS_TYPE == BUS_TYPE_QUAD) {
this->enable();
+21
View File
@@ -39,13 +39,16 @@ from esphome.const import (
CONF_REFERENCE_TEMPERATURE,
CONF_SEND_EVERY,
CONF_SEND_FIRST_AT,
CONF_SENSOR_ID,
CONF_STATE_CLASS,
CONF_TEMPERATURE,
CONF_TEMPERATURE_OFFSET,
CONF_TIMEOUT,
CONF_TO,
CONF_TRIGGER_ID,
CONF_TYPE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USE_FAHRENHEIT,
CONF_VALUE,
CONF_WEB_SERVER,
CONF_WINDOW_SIZE,
@@ -310,6 +313,7 @@ OrFilter = sensor_ns.class_("OrFilter", Filter)
CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter)
ToNTCResistanceFilter = sensor_ns.class_("ToNTCResistanceFilter", Filter)
ToNTCTemperatureFilter = sensor_ns.class_("ToNTCTemperatureFilter", Filter)
RHCorrectionFilter = sensor_ns.class_("RHCorrectionFilter", Filter)
CalibratePolynomialFilter = sensor_ns.class_("CalibratePolynomialFilter", Filter)
SensorInRangeCondition = sensor_ns.class_("SensorInRangeCondition", Filter)
ClampFilter = sensor_ns.class_("ClampFilter", Filter)
@@ -1281,6 +1285,23 @@ async def calibrate_ntc_temperature_filter_to_code(config, filter_id):
)
@FILTER_REGISTRY.register(
"rh_correction",
RHCorrectionFilter,
cv.Schema(
{
cv.Required(CONF_TEMPERATURE_OFFSET): cv.templatable(cv.float_),
cv.Required(CONF_SENSOR_ID): cv.use_id(Sensor),
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
}
),
)
async def rh_correction_filter_to_code(config: ConfigType, filter_id: ID) -> MockObj:
sensor = await cg.get_variable(config[CONF_SENSOR_ID])
offset = await cg.templatable(config[CONF_TEMPERATURE_OFFSET], [], cg.float_)
return cg.new_Pvariable(filter_id, offset, sensor, config[CONF_USE_FAHRENHEIT])
def _mean(xs):
return sum(xs) / len(xs)
+42
View File
@@ -493,6 +493,48 @@ optional<float> ToNTCTemperatureFilter::new_value(float value) {
return temp;
}
// RHCorrectionFilter
static constexpr float MAGNUS_B = 17.67f, MAGNUS_C = 243.5f;
static bool rh_correction_temp_in_band(float t) { return t >= -40.0f && t <= 50.0f; }
optional<float> RHCorrectionFilter::new_value(float value) {
if (!this->temperature_sensor_->has_state()) {
if (!this->warned_) {
ESP_LOGW(TAG, "RHCorrectionFilter(%p): no temperature yet, passing humidity through uncorrected", this);
this->warned_ = true;
}
return value;
}
float offset = this->temperature_offset_.value();
float tc = this->temperature_sensor_->get_state();
if (this->use_fahrenheit_) {
tc = fahrenheit_to_celsius(tc);
offset /= 1.8f;
}
const float tnc = tc - offset;
// A NaN temperature or offset is a faulted input: keep the last corrected value
if (std::isnan(tnc)) {
if (!this->warned_) {
ESP_LOGW(TAG, "RHCorrectionFilter(%p): temperature or offset is NaN, dropping humidity updates", this);
this->warned_ = true;
}
return {};
}
if (!rh_correction_temp_in_band(tc) || !rh_correction_temp_in_band(tnc)) {
if (!this->warned_) {
ESP_LOGW(TAG, "RHCorrectionFilter(%p): Temperature out of range -40..50 °C. Tc=%f Tnc=%f", this, tc, tnc);
this->warned_ = true;
}
return value;
}
this->warned_ = false;
const float rh =
clamp(value * std::exp(MAGNUS_B * MAGNUS_C * (tnc - tc) / ((tnc + MAGNUS_C) * (tc + MAGNUS_C))), 0.0f, 100.0f);
ESP_LOGVV(TAG, "RHCorrectionFilter(%p)::new_value(%f) -> %f", this, value, rh);
return rh;
}
// StreamingFilter (base class)
StreamingFilter::StreamingFilter(uint16_t window_size, uint16_t send_first_at)
: window_size_(window_size), send_first_at_(send_first_at) {}
+20
View File
@@ -647,6 +647,26 @@ class ToNTCTemperatureFilter : public Filter {
double c_;
};
/** RH Temperature correction filter
*
* Allows for Relative Humidity Correction when a temperature offset is applied to the temperature sensor.
* Uses the Magnus formula, rh = rh_nc * exp(b*(t_nc/(c+t_nc) - t/(c+t))) with b = 17.67, c = 243.5 °C;
* see https://en.wikipedia.org/wiki/Dew_point
*/
class RHCorrectionFilter : public Filter {
public:
RHCorrectionFilter(TemplatableFn<float> offset, const Sensor *temperature_sensor, bool use_fahrenheit)
: temperature_offset_(offset), temperature_sensor_(temperature_sensor), use_fahrenheit_(use_fahrenheit) {}
optional<float> new_value(float value) override;
protected:
TemplatableFn<float> temperature_offset_;
const Sensor *temperature_sensor_;
bool use_fahrenheit_;
bool warned_{false};
};
/** Base class for streaming filters (batch windows where window_size == send_every).
*
* When window_size equals send_every, we don't need a sliding window.
+13 -13
View File
@@ -1043,7 +1043,7 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa
if (is_on) {
parse_cstr_param_(
request, ESPHOME_F("effect"), call,
static_cast<light::LightCall &(light::LightCall::*) (const char *, size_t)>(&decltype(call)::set_effect));
static_cast<light::LightCall &(light::LightCall::*)(const char *, size_t)>(&decltype(call)::set_effect));
}
DEFER_ACTION(call, call.perform());
@@ -1404,7 +1404,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat
auto call = obj->make_call();
parse_cstr_param_(
request, ESPHOME_F("value"), call,
static_cast<text::TextCall &(text::TextCall::*) (const char *, size_t)>(&decltype(call)::set_value));
static_cast<text::TextCall &(text::TextCall::*)(const char *, size_t)>(&decltype(call)::set_value));
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1464,7 +1464,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM
auto call = obj->make_call();
parse_cstr_param_(
request, ESPHOME_F("option"), call,
static_cast<select::SelectCall &(select::SelectCall::*) (const char *, size_t)>(&decltype(call)::set_option));
static_cast<select::SelectCall &(select::SelectCall::*)(const char *, size_t)>(&decltype(call)::set_option));
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1522,26 +1522,26 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url
// Parse string mode parameters
parse_cstr_param_(
request, ESPHOME_F("mode"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(&decltype(call)::set_mode));
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(&decltype(call)::set_mode));
parse_cstr_param_(request, ESPHOME_F("fan_mode"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(
&decltype(call)::set_fan_mode));
parse_cstr_param_(request, ESPHOME_F("swing_mode"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(
&decltype(call)::set_swing_mode));
parse_cstr_param_(request, ESPHOME_F("preset"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(
&decltype(call)::set_preset));
// Parse temperature parameters
// static_cast needed to disambiguate overloaded setters (float vs optional<float>)
using ClimateCall = decltype(call);
parse_num_param_(request, ESPHOME_F("target_temperature_high"), call,
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature_high));
static_cast<ClimateCall &(ClimateCall::*)(float)>(&ClimateCall::set_target_temperature_high));
parse_num_param_(request, ESPHOME_F("target_temperature_low"), call,
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature_low));
static_cast<ClimateCall &(ClimateCall::*)(float)>(&ClimateCall::set_target_temperature_low));
parse_num_param_(request, ESPHOME_F("target_temperature"), call,
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature));
static_cast<ClimateCall &(ClimateCall::*)(float)>(&ClimateCall::set_target_temperature));
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1818,8 +1818,8 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques
auto call = obj->make_call();
parse_cstr_param_(
request, ESPHOME_F("code"), call,
static_cast<alarm_control_panel::AlarmControlPanelCall &(
alarm_control_panel::AlarmControlPanelCall::*) (const char *, size_t)>(&decltype(call)::set_code));
static_cast<alarm_control_panel::AlarmControlPanelCall &(alarm_control_panel::AlarmControlPanelCall::*)(
const char *, size_t)>(&decltype(call)::set_code));
static constexpr ActionEntry<alarm_control_panel::AlarmControlPanelCall> ACTIONS[] PROGMEM = {
{"disarm", &alarm_control_panel::AlarmControlPanelCall::disarm},
@@ -1893,7 +1893,7 @@ void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, cons
// Parse mode parameter
parse_cstr_param_(
request, ESPHOME_F("mode"), base_call,
static_cast<water_heater::WaterHeaterCall &(water_heater::WaterHeaterCall::*) (const char *, size_t)>(
static_cast<water_heater::WaterHeaterCall &(water_heater::WaterHeaterCall::*)(const char *, size_t)>(
&water_heater::WaterHeaterCall::set_mode));
// Parse temperature parameters
@@ -5,7 +5,7 @@ namespace esphome::xiaomi_hhccpot002 {
ESPHOME_LOG_TAG(TAG, "xiaomi_hhccpot002");
void XiaomiHHCCPOT002 ::dump_config() {
void XiaomiHHCCPOT002::dump_config() {
ESP_LOGCONFIG(TAG, "Xiaomi HHCCPOT002");
LOG_SENSOR(" ", "Moisture", this->moisture_);
LOG_SENSOR(" ", "Conductivity", this->conductivity_);
+4 -2
View File
@@ -59,13 +59,15 @@ template<typename T> class PlainAtomic {
constexpr PlainAtomic(T value) : value_(value) {}
T load(std::memory_order order = std::memory_order_seq_cst) const {
T value = value_;
if (order != std::memory_order_relaxed)
if (order != std::memory_order_relaxed) {
ESPHOME_LFQ_COMPILER_BARRIER(); // acquire: later reads may not hoist above this load
}
return value;
}
void store(T value, std::memory_order order = std::memory_order_seq_cst) {
if (order != std::memory_order_relaxed)
if (order != std::memory_order_relaxed) {
ESPHOME_LFQ_COMPILER_BARRIER(); // release: earlier writes may not sink below this store
}
value_ = value;
}
T fetch_add(T amount, std::memory_order /*order*/ = std::memory_order_seq_cst) {
+12 -2
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.2.0
esptool==5.6.0
click==8.5.0
aioesphomeapi==46.18.0
aioesphomeapi==46.19.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5
puremagic==2.2.0
+2 -2
View File
@@ -1,4 +1,4 @@
# Useful stuff when working in a development environment
clang-format==22.1.8 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
clang-tidy==22.1.8
clang-format==23.1.3 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
clang-tidy==23.1.3
yamllint==1.38.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
+8 -14
View File
@@ -319,21 +319,15 @@ def run_tidy(
disabled_checks = []
if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx")):
# MMIO peripheral access on these bare-metal platforms is all
# fixed-address.
disabled_checks.append("-clang-analyzer-core.FixedAddressDereference")
# fixed-address. clang-tidy 23 moved the checker under optin.
disabled_checks += [
"-clang-analyzer-core.FixedAddressDereference",
"-clang-analyzer-optin.core.FixedAddressDereference",
]
if args.environment.startswith("host"):
# glibc's assert() expands to a call the check mistakes for a
# constant condition.
disabled_checks += ["-misc-static-assert", "-cert-dcl03-c"]
if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx", "host")):
# bugprone-pointer-arithmetic-on-polymorphic-object (and its
# cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher
# recursion on lvgl_esphome.h under the RP2 defines and on the
# all-include file under the host defines.
disabled_checks += [
"-bugprone-pointer-arithmetic-on-polymorphic-object",
"-cert-ctr56-cpp",
]
if os.path.normpath(path) in ANALYZER_SKIPPED_FILES:
disabled_checks.append("-clang-analyzer-*")
if disabled_checks:
@@ -523,7 +517,7 @@ def main():
failed_files = []
timings = []
try:
executable = get_binary("clang-tidy", 22)
executable = get_binary("clang-tidy", 23)
task_queue = queue.Queue(args.jobs)
lock = threading.Lock()
for _ in range(args.jobs):
@@ -571,13 +565,13 @@ def main():
try:
try:
subprocess.call(
["clang-apply-replacements-22", tmpdir], close_fds=False
["clang-apply-replacements-23", tmpdir], close_fds=False
)
except FileNotFoundError:
subprocess.call(["clang-apply-replacements", tmpdir], close_fds=False)
except FileNotFoundError:
print(
"Error please install clang-apply-replacements-22 or clang-apply-replacements.\n",
"Error please install clang-apply-replacements-23 or clang-apply-replacements.\n",
file=sys.stderr,
)
except:
@@ -621,6 +621,69 @@ def test_uc8279_width_must_be_a_multiple_of_8(
)
GDEY029Z95 = "goodisplay-gdey029z95-2.9"
def _gdey029z95_config(**overrides: Any) -> ConfigType:
config: ConfigType = {
"id": "test_display",
"model": GDEY029Z95,
CONF_DC_PIN: 21,
CONF_RESET_PIN: 22,
CONF_BUSY_PIN: 23,
}
config.update(overrides)
return config
def test_gdey029z95_uses_the_good_display_driver() -> None:
"""The GDEY029Z95 uses EPaperGoodDisplay3C with the WeAct init sequence."""
model = MODELS[GDEY029Z95.upper()]
assert model.class_name == "EPaperGoodDisplay3C"
assert model.get_init_sequence({}) == (
(0x01, 0x27, 0x01, 0x00),
(0x11, 0x03),
(0x3C, 0x05),
(0x18, 0x80),
(0x21, 0x00, 0x80),
)
@pytest.mark.parametrize("pin", [CONF_RESET_PIN, CONF_BUSY_PIN])
def test_gdey029z95_requires_reset_and_busy_pins(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
pin: str,
) -> None:
"""Only a hardware reset wakes the panel from deep sleep, and refreshes are timed by busy."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = _gdey029z95_config()
del config[pin]
with pytest.raises(cv.Invalid, match=pin):
run_schema_validation(config)
def test_gdey029z95_has_no_partial_update(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
run_schema_validation(_gdey029z95_config())
with pytest.raises(cv.Invalid, match="does not support partial update"):
run_schema_validation(_gdey029z95_config(full_update_every=2))
def test_uc8279_x4pro_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -0,0 +1,24 @@
esphome:
name: test
esp32:
board: esp32dev
sensor:
- platform: template
id: temperature
filters:
- offset: -7.3
- platform: template
id: constant_offset
filters:
- rh_correction:
sensor_id: temperature
temperature_offset: -7.3
- platform: template
id: templated_offset
filters:
- rh_correction:
sensor_id: temperature
temperature_offset: !lambda return -7.3;
use_fahrenheit: true
@@ -0,0 +1,60 @@
"""The rh_correction filter: schema and the constructor call it generates."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.components.sensor import validate_filters
import esphome.config_validation as cv
@pytest.mark.parametrize(
"filters",
[
pytest.param([{"rh_correction": {"sensor_id": "t"}}], id="no_offset"),
pytest.param([{"rh_correction": {"temperature_offset": -7.3}}], id="no_sensor"),
pytest.param(
[
{
"rh_correction": {
"sensor_id": "t",
"temperature_offset": -7.3,
"use_fahrenheit": "maybe",
}
}
],
id="bad_fahrenheit",
),
],
)
def test_rh_correction_rejects_incomplete_config(filters: list[dict]) -> None:
with pytest.raises(cv.Invalid):
validate_filters(filters)
def test_rh_correction_defaults_to_celsius() -> None:
config = validate_filters(
[{"rh_correction": {"sensor_id": "t", "temperature_offset": -7.3}}]
)
assert config[0]["rh_correction"]["use_fahrenheit"] is False
def test_rh_correction_codegen(
generate_main: Callable[[str | Path], str],
) -> None:
main_cpp = generate_main("tests/component_tests/sensor/rh_correction.yaml")
# A constant offset is wrapped in a stateless lambda so the filter stores a function pointer
assert re.search(
r"sensor::RHCorrectionFilter\(\[\]\(\) -> float \{\s*return -7\.3f;\s*\}, "
r"temperature, false\);\s*constant_offset->add_filter\(",
main_cpp,
)
# A templated offset keeps the user's lambda body and passes use_fahrenheit through
assert re.search(
r"sensor::RHCorrectionFilter\(\[\]\(\) -> float \{[^}]*return -7\.3;[^}]*\}, "
r"temperature, true\);\s*templated_offset->add_filter\(",
main_cpp,
)
@@ -0,0 +1,164 @@
#include <gtest/gtest.h>
#include <utility>
#include <vector>
#include "../common.h"
#include "esphome/components/epaper_spi/epaper_goodisplay_3c.h"
namespace esphome::epaper_spi::testing {
namespace {
class TestableGoodDisplay3C : public EPaperGoodDisplay3C {
public:
TestableGoodDisplay3C(uint16_t width, uint16_t height) : EPaperGoodDisplay3C("test", width, height, nullptr, 0) {}
void install(spi::SPIDelegate *delegate) {
this->delegate_ = delegate;
this->set_dc_pin(&this->dc);
ASSERT_TRUE(this->init_buffer_(this->buffer_length_));
}
void draw(int x, int y, Color color) { this->draw_pixel_at(x, y, color); }
/// Both planes of one push; returns how many calls it took.
int run_push() {
int calls = 1;
while (!this->transfer_data())
calls++;
return calls;
}
/// A whole update with the frame already drawn, run through loop() as the main loop would.
void run_update() {
this->set_auto_clear(false);
this->state_ = EPaperState::UPDATE;
const uint32_t deadline = millis() + 2000;
while (this->state_ != EPaperState::IDLE) {
ASSERT_LT(millis(), deadline) << "update did not finish";
this->loop();
}
}
using EPaperGoodDisplay3C::REFRESH_START_MS;
RecordingPin dc;
};
/// Also notes when each command byte was sent.
class TimedCommandDelegate : public RecordingDelegate {
public:
using RecordingDelegate::RecordingDelegate;
void write_array(const uint8_t *ptr, size_t length) override {
if (!this->dc_->level) {
for (size_t i = 0; i != length; i++)
this->sent.emplace_back(ptr[i], millis());
}
RecordingDelegate::write_array(ptr, length);
}
/// When the n-th (from 0) copy of a command was sent.
uint32_t time_of(uint8_t command, int n = 0) const {
for (const auto &[byte, time] : this->sent) {
if (byte == command && n-- == 0)
return time;
}
ADD_FAILURE() << "command " << int(command) << " not sent";
return 0;
}
std::vector<std::pair<uint8_t, uint32_t>> sent;
};
using Bytes = std::vector<uint8_t>;
const Color RED(255, 0, 0);
/// One row of 16 pixels: white, black, red, then white. The red plane goes first as 1=red, then the black/white
/// plane as 1=white with red pixels sent as 0.
TEST(EPaperGoodDisplay3CTransfer, PixelsReachThePanel) {
TestableGoodDisplay3C display(16, 1);
RecordingDelegate bus(&display.dc);
display.install(&bus);
display.fill(Color::WHITE);
display.draw(1, 0, Color::BLACK);
display.draw(2, 0, RED);
EXPECT_EQ(display.run_push(), 1);
EXPECT_EQ(bus.commands, (Bytes{0x44, 0x45, 0x4E, 0x4F, 0x26, 0x24}));
EXPECT_EQ(bus.data[0x44], (Bytes{0x00, 0x01}));
EXPECT_EQ(bus.data[0x45], (Bytes{0x00, 0x00, 0x00, 0x00}));
EXPECT_EQ(bus.data[0x24], (Bytes{0x9F, 0xFF}));
EXPECT_EQ(bus.data[0x26], (Bytes{0x20, 0x00}));
}
/// A push that yields to the main loop after every chunk sends the same commands and bytes as one that runs to
/// completion in one call, and a second push starts again from the window setup.
TEST(EPaperGoodDisplay3CTransfer, ResumedPushMatches) {
// 128x20 pixels is 320 bytes per plane, so each plane takes three chunks of up to MAX_TRANSFER_SIZE.
constexpr int width = 128;
constexpr int height = 20;
const Color colors[] = {Color::WHITE, Color::BLACK, RED};
TestableGoodDisplay3C reference(width, height);
RecordingDelegate reference_bus(&reference.dc);
reference.install(&reference_bus);
TestableGoodDisplay3C display(width, height);
// Every data write takes longer than MAX_TRANSFER_TIME, so the push yields after each chunk.
RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1);
display.install(&bus);
for (int y = 0; y != height; y++) {
for (int x = 0; x != width; x++) {
reference.draw(x, y, colors[(x * 7 + y * 3) % 3]);
display.draw(x, y, colors[(x * 7 + y * 3) % 3]);
}
}
EXPECT_EQ(reference.run_push(), 1);
EXPECT_GT(display.run_push(), 2);
EXPECT_EQ(bus.commands, reference_bus.commands);
EXPECT_EQ(bus.data[0x24].size(), 320u);
EXPECT_EQ(bus.data[0x24], reference_bus.data[0x24]);
EXPECT_EQ(bus.data[0x26], reference_bus.data[0x26]);
bus.clear();
display.run_push();
EXPECT_EQ(bus.commands, reference_bus.commands);
}
/// A whole update sends no power-on (0x22 0xF8) before the refresh (0x22 0xF7): with it, this panel does not
/// refresh. Power off (0x83) and deep sleep follow.
TEST(EPaperGoodDisplay3CTransfer, UpdateHasNoSeparatePowerOn) {
TestableGoodDisplay3C display(16, 1);
RecordingDelegate bus(&display.dc);
display.install(&bus);
display.fill(RED);
display.run_update();
EXPECT_EQ(bus.commands, (Bytes{0x44, 0x45, 0x4E, 0x4F, 0x26, 0x24, 0x4E, 0x4F, 0x22, 0x20, 0x22, 0x20, 0x10}));
EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0xFF}));
EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0x00}));
EXPECT_EQ(bus.data[0x22], (Bytes{0xF7, 0x83}));
EXPECT_EQ(bus.data[0x10], (Bytes{0x01}));
}
/// Busy only goes high a little after master activation (0x20), so power off is not sent until REFRESH_START_MS
/// after the refresh starts.
TEST(EPaperGoodDisplay3CTransfer, WaitsForBusyAfterTheRefreshStarts) {
TestableGoodDisplay3C display(16, 1);
TimedCommandDelegate bus(&display.dc);
display.install(&bus);
display.fill(RED);
display.run_update();
// The first 0x20 starts the refresh; the second 0x22 selects power off.
EXPECT_GE(bus.time_of(0x22, 1) - bus.time_of(0x20, 0), TestableGoodDisplay3C::REFRESH_START_MS);
}
} // namespace
} // namespace esphome::epaper_spi::testing
@@ -209,6 +209,27 @@ display:
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# Good Display GDEY029Z95 2.9" 3-color e-paper (128x296, SSD1680)
- platform: epaper_spi
spi_id: spi_bus
model: goodisplay-gdey029z95-2.9
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# WeAct 4.2" 3-color e-paper (400x300, SSD1683)
- platform: epaper_spi
spi_id: spi_bus
+11
View File
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config: ConfigType) -> None:
# filter.cpp only compiles with the define the real to_code emits for configured filters
cg.add_define("USE_SENSOR_FILTER")
manifest.to_code = to_code_testing
+18
View File
@@ -254,3 +254,21 @@ sensor:
lambda: return 42.0;
update_interval: 1s
state_class: "measurement_angle"
# Testing rh_correction filter
- platform: copy
source_id: source_sensor
name: "RH Correction Filter Fahrenheit"
filters:
- rh_correction:
sensor_id: source_sensor
temperature_offset: -10.0
use_fahrenheit: true
- platform: copy
source_id: source_sensor
name: "RH Correction Filter Template"
filters:
- rh_correction:
sensor_id: source_sensor
temperature_offset: !lambda return -10.0;
+66
View File
@@ -0,0 +1,66 @@
#include <gtest/gtest.h>
#include <cmath>
#include "esphome/components/sensor/filter.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::sensor::testing {
// 40 % read 7.3 °C warm at a true 25 °C, in single precision Magnus arithmetic
static constexpr float CORRECTED = 61.13f;
struct RhFixture {
Sensor temperature;
RHCorrectionFilter filter;
RhFixture(float (*offset)(), bool use_fahrenheit = false) : filter(offset, &this->temperature, use_fahrenheit) {}
};
TEST(RHCorrectionFilter, CorrectsForTheOffset) {
RhFixture f([]() -> float { return -7.3f; });
f.temperature.publish_state(25.0f);
EXPECT_NEAR(f.filter.new_value(40.0f).value_or(NAN), CORRECTED, 0.05f);
}
TEST(RHCorrectionFilter, PositiveOffsetLowersTheHumidity) {
RhFixture f([]() -> float { return 7.3f; });
f.temperature.publish_state(25.0f);
EXPECT_LT(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
}
TEST(RHCorrectionFilter, FahrenheitReadingsGiveTheSameResult) {
// 77 °F is 25 °C and -13.14 °F is -7.3 °C of offset
RhFixture f([]() -> float { return -13.14f; }, true);
f.temperature.publish_state(77.0f);
EXPECT_NEAR(f.filter.new_value(40.0f).value_or(NAN), CORRECTED, 0.05f);
}
TEST(RHCorrectionFilter, ClampsToOneHundredPercent) {
RhFixture f([]() -> float { return -7.3f; });
f.temperature.publish_state(25.0f);
EXPECT_FLOAT_EQ(f.filter.new_value(90.0f).value_or(NAN), 100.0f);
}
TEST(RHCorrectionFilter, PassesThroughWithoutAUsableTemperature) {
RhFixture f([]() -> float { return -7.3f; });
// No temperature reading yet
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
// Out of the -40..50 °C band
f.temperature.publish_state(60.0f);
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
// The band also covers the reconstructed enclosure temperature
f.temperature.publish_state(45.0f);
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
}
TEST(RHCorrectionFilter, DropsTheUpdateOnNaN) {
RhFixture f([]() -> float { return -7.3f; });
f.temperature.publish_state(NAN);
EXPECT_FALSE(f.filter.new_value(40.0f).has_value());
RhFixture nan_offset([]() -> float { return NAN; });
nan_offset.temperature.publish_state(25.0f);
EXPECT_FALSE(nan_offset.filter.new_value(40.0f).has_value());
}
} // namespace esphome::sensor::testing
@@ -409,6 +409,10 @@ cover:
id: template_cover_actions
position: 0.0
tilt: 0.0
toggle_action:
- cover.template.publish:
id: template_cover_actions
current_operation: IDLE
stop_action:
- cover.template.publish:
id: template_cover_actions
@@ -0,0 +1,26 @@
esphome:
name: api-cover-toggle-test
host:
api:
logger:
level: DEBUG
cover:
- platform: template
name: "Toggle Cover"
id: toggle_cover
toggle_action:
- cover.template.publish:
id: toggle_cover
position: 0.5
stop_action:
- cover.template.publish:
id: toggle_cover
position: 0.25
- platform: template
name: "Plain Cover"
id: plain_cover
stop_action:
- cover.template.publish:
id: plain_cover
position: 0.25
@@ -0,0 +1,102 @@
esphome:
name: test-rh-correction
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
sensor:
- platform: template
name: "Temperature C"
id: temperature_c
accuracy_decimals: 1
- platform: template
name: "Temperature F"
id: temperature_f
accuracy_decimals: 1
- platform: template
name: "Humidity Source"
id: humidity_source
accuracy_decimals: 1
- platform: copy
source_id: humidity_source
name: "RH Celsius"
id: rh_celsius
accuracy_decimals: 2
filters:
- rh_correction:
sensor_id: temperature_c
temperature_offset: -7.3
- platform: copy
source_id: humidity_source
name: "RH Fahrenheit"
id: rh_fahrenheit
accuracy_decimals: 2
filters:
- rh_correction:
sensor_id: temperature_f
temperature_offset: -13.14
use_fahrenheit: true
script:
- id: test_rh_correction
then:
# Both temperature sensors read 25 °C after a -7.3 °C (-13.14 °F) offset
- sensor.template.publish:
id: temperature_c
state: 25.0
- sensor.template.publish:
id: temperature_f
state: 77.0
- sensor.template.publish:
id: humidity_source
state: 40.0
- delay: 20ms
# Clamped to 100 %
- sensor.template.publish:
id: humidity_source
state: 90.0
- delay: 20ms
# Out of band: passes through unchanged
- sensor.template.publish:
id: temperature_c
state: 60.0
- sensor.template.publish:
id: temperature_f
state: 140.0
- sensor.template.publish:
id: humidity_source
state: 40.0
- delay: 20ms
# NaN temperature: the update is dropped
- sensor.template.publish:
id: temperature_c
state: !lambda "return NAN;"
- sensor.template.publish:
id: temperature_f
state: !lambda "return NAN;"
- sensor.template.publish:
id: humidity_source
state: 50.0
- delay: 20ms
- sensor.template.publish:
id: temperature_c
state: 25.0
- sensor.template.publish:
id: temperature_f
state: 77.0
- sensor.template.publish:
id: humidity_source
state: 40.0
button:
- platform: template
name: "Test RH Correction"
on_press:
- script.execute: test_rh_correction
+12 -5
View File
@@ -1,7 +1,7 @@
"""Minimal plaintext native-api client over a raw socket.
Reads only when told to, so tests control when the TCP pipe backs up toward
the device; payloads are skipped and only message types are counted.
the device; message types are counted and each frame is kept for inspection.
"""
from __future__ import annotations
@@ -59,15 +59,17 @@ class FrameParser:
def __init__(self) -> None:
self._buf = bytearray()
self.frames: list[tuple[int, bytes]] = []
def feed(self, data: bytes) -> list[int]:
self._buf.extend(data)
types: list[int] = []
while (msg_type := self._try_parse()) is not None:
types.append(msg_type)
while (frame := self._try_parse()) is not None:
self.frames.append(frame)
types.append(frame[0])
return types
def _try_parse(self) -> int | None:
def _try_parse(self) -> tuple[int, bytes] | None:
buf = self._buf
if not buf:
return None
@@ -80,8 +82,9 @@ class FrameParser:
msg_type, pos = type_decoded
if len(buf) - pos < size:
return None
payload = bytes(buf[pos : pos + size])
del buf[: pos + size]
return msg_type
return msg_type, payload
class RawApiClient:
@@ -145,5 +148,9 @@ class RawApiClient:
await asyncio.wait_for(_read_loop(), timeout)
def frames(self, msg_type: int) -> list[bytes]:
"""Payloads of every frame of msg_type received so far."""
return [payload for kind, payload in self._parser.frames if kind == msg_type]
def close(self) -> None:
self._sock.close()
+129
View File
@@ -0,0 +1,129 @@
"""The native API advertises cover toggle support and runs the toggle action on request."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
import struct
from aioesphomeapi import CoverInfo, CoverState, EntityState, api_pb2
import pytest
from .log_utils import LineWaiter
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient, decode_varint, encode_varint
from .state_utils import InitialStateHelper, StateWaiter, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
COVER_COMMAND = MESSAGE_TYPE_OF[api_pb2.CoverCommandRequest]
LIST_COVER = MESSAGE_TYPE_OF[api_pb2.ListEntitiesCoverResponse]
LIST_DONE = MESSAGE_TYPE_OF[api_pb2.ListEntitiesDoneResponse]
WIRE_VARINT, WIRE_LENGTH, WIRE_FIXED32 = 0, 2, 5
# ListEntitiesCoverResponse fields; the installed client library does not know supports_toggle yet
FIELD_ENTITY_KEY, FIELD_SUPPORTS_TOGGLE = 2, 15
# CoverCommandRequest fields
FIELD_KEY, FIELD_STOP, FIELD_TOGGLE = 1, 8, 11
def tag(field: int, wire_type: int) -> bytes:
return encode_varint((field << 3) | wire_type)
def decode_fields(payload: bytes) -> dict[int, list[int | bytes]]:
"""Split a protobuf payload into field number -> raw values."""
fields: dict[int, list[int | bytes]] = {}
buf = bytearray(payload)
pos = 0
while pos < len(buf):
key, pos = decode_varint(buf, pos)
field, wire_type = key >> 3, key & 0x07
if wire_type == WIRE_VARINT:
value, pos = decode_varint(buf, pos)
elif wire_type == WIRE_LENGTH:
length, pos = decode_varint(buf, pos)
value = bytes(buf[pos : pos + length])
pos += length
elif wire_type == WIRE_FIXED32:
value = struct.unpack_from("<I", buf, pos)[0]
pos += 4
else:
raise AssertionError(f"unexpected wire type {wire_type} for field {field}")
fields.setdefault(field, []).append(value)
return fields
@pytest.mark.asyncio
async def test_api_cover_toggle(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
unused_tcp_port: int,
) -> None:
lines = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=lines.callback),
api_client_connected() as client,
RawApiClient(unused_tcp_port) as raw,
):
entities, _ = await client.list_entities_services()
toggle_cover = require_entity(entities, "toggle_cover", CoverInfo, exact=True)
plain_cover = require_entity(entities, "plain_cover", CoverInfo, exact=True)
positions: dict[int, list[float]] = {toggle_cover.key: [], plain_cover.key: []}
waiter = StateWaiter()
def on_state(state: EntityState) -> None:
if isinstance(state, CoverState) and state.key in positions:
positions[state.key].append(state.position)
waiter.on_state(state)
def position_is(key: int, value: float) -> Callable[[EntityState], bool]:
return lambda s: (
isinstance(s, CoverState)
and s.key == key
and s.position == pytest.approx(value, abs=0.01)
)
initial = InitialStateHelper(entities)
client.subscribe_states(initial.on_state_wrapper(on_state))
await initial.wait_for_initial_states()
# Capability discovery: only the cover with a toggle_action advertises toggle
await raw.connect()
await raw.send_message(api_pb2.ListEntitiesRequest())
await raw.read_until_frame(LIST_DONE)
supports_toggle = {}
for payload in raw.frames(LIST_COVER):
fields = decode_fields(payload)
key = fields[FIELD_ENTITY_KEY][0]
supports_toggle[key] = bool(fields.get(FIELD_SUPPORTS_TOGGLE, [0])[0])
assert supports_toggle == {toggle_cover.key: True, plain_cover.key: False}
def key_field(key: int) -> bytes:
return tag(FIELD_KEY, WIRE_FIXED32) + struct.pack("<I", key)
toggle = tag(FIELD_TOGGLE, WIRE_VARINT) + b"\x01"
stop = tag(FIELD_STOP, WIRE_VARINT) + b"\x01"
# A toggle request runs the toggle action, which publishes 0.5
toggled = waiter.expect(
position_is(toggle_cover.key, 0.5), label="toggle action"
)
await raw.send_raw(COVER_COMMAND, key_field(toggle_cover.key) + toggle)
await toggled
# A cover without toggle_action drops the request with a warning; its state does not change
await raw.send_raw(COVER_COMMAND, key_field(plain_cover.key) + toggle)
await lines.wait_for("Plain Cover", "toggle unsupported", timeout=2.0)
await asyncio.sleep(0.5)
assert all(p == pytest.approx(1.0) for p in positions[plain_cover.key])
# Stop and toggle in one request: stop wins and the toggle action does not run
before = len(positions[toggle_cover.key])
stopped = waiter.expect(
position_is(toggle_cover.key, 0.25), label="stop action"
)
await raw.send_raw(COVER_COMMAND, key_field(toggle_cover.key) + stop + toggle)
await stopped
await asyncio.sleep(0.5)
after = positions[toggle_cover.key][before:]
assert all(p == pytest.approx(0.25, abs=0.01) for p in after), after
@@ -0,0 +1,55 @@
"""Test the sensor rh_correction filter numerically."""
from __future__ import annotations
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
# 40 % at 25 °C, measured 7.3 °C warmer, in single precision Magnus arithmetic
CORRECTED = pytest.approx(61.13, abs=0.05)
# Corrected, clamped, passed through out of band, corrected again; the NaN step is dropped
EXPECTED = [CORRECTED, 100.0, 40.0, CORRECTED]
@pytest.mark.asyncio
async def test_sensor_filters_rh_correction(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
collector = SensorStateCollector(sensor_names=["rh_celsius", "rh_fahrenheit"])
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(
initial_state_helper.on_state_wrapper(collector.on_state)
)
await initial_state_helper.wait_for_initial_states()
done = collector.add_waiter(
lambda: all(
len(values) == len(EXPECTED)
for values in collector.sensor_states.values()
)
)
button = require_entity(entities, "test_rh_correction", ButtonInfo, exact=True)
client.button_command(button.key)
await wait_or_fail(
done,
2.0,
"Timed out waiting for the corrected humidity values",
values=collector.sensor_states,
)
assert collector.sensor_states["rh_celsius"] == EXPECTED
# The same readings in °F give the same corrected humidity
assert collector.sensor_states["rh_fahrenheit"] == EXPECTED
@@ -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()
+3 -4
View File
@@ -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:
+9 -3
View File
@@ -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 == {}
+562 -122
View File
@@ -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: