Compare commits

..
Author SHA1 Message Date
JoHoenk 400407b135 [runtime_image] Allocate the JPEG decoder state from PSRAM (#21629) 2026-10-26 08:28:49 +11:00
Jonathan SwobodaandJ. Nick Koston 22b32b194a [core][scheduler] Count a cancelled item on a failed component as removed (#21626)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-25 15:36:17 -04:00
J. Nick Koston 6dcd989a7f [switch][number][text] Follow the source with plain binary sensors, sensors and text sensors (#21612) 2026-10-25 18:05:42 +00:00
J. Nick Koston 2016884210 [copy] Follow the source with plain sensors and text sensors (#21610) 2026-10-25 07:52:01 -10:00
J. Nick Koston d3f4f44c7b [core] Add follow helpers for plain sensors and text sensors (#21609) 2026-10-25 07:50:58 -10:00
Jonathan Swoboda 6df15be714 [tests] Cover remote, modbus and bus action options (#21616) 2026-10-25 07:50:12 -10:00
Jonathan Swoboda a03fe3c38e [tests] Cover network and scripting action options (#21615) 2026-10-25 07:49:52 -10:00
Jonathan Swoboda afc8c51f93 [core] Keep components that failed before setup out of the loop (#21622) 2026-10-25 07:49:32 -10:00
Jonathan Swoboda ef7ed69f9d [lightwaverf] Stop requiring the unused name on send_raw (#21620) 2026-10-25 07:48:57 -10:00
Jonathan Swoboda ec32802662 [core] Avoid division by zero in Color::random_color() (#21623) 2026-10-25 07:48:34 -10:00
Jonathan Swoboda 433d8046b1 [espota2] Stop waiting when the device closes mid-reply (#21624) 2026-10-25 07:48:07 -10:00
Jonathan Swoboda 8ecb981656 [tests] Cover device and lvgl action options (#21617) 2026-10-25 07:47:36 -10:00
Jonathan Swoboda 8c990573d7 [core] Generate valid C++ for infinite float values (#21625) 2026-10-25 07:47:24 -10:00
J. Nick Koston bdec0c1485 [copy] Add an integration test for the sensor, binary sensor and text sensor platforms (#21608) 2026-10-25 10:18:37 -04:00
Jonathan Swobodaandclydebarrow 70e85d939d [lvgl] Make canvas gradient and dashed line options compile (#21580)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
2026-10-25 08:59:32 -04:00
Clyde Stubbs 25b3c2820a [core][display] Let users take over a display's enable pins (#21605) 2026-10-25 20:47:50 +11:00
Jonathan Swoboda ef53e675ff [lvgl] Apply opa on indicator.update (#21579) 2026-10-24 21:43:38 -10:00
148 changed files with 3187 additions and 2156 deletions

No files matched your search

-1
View File
@@ -196,7 +196,6 @@ esphome/components/ens160_base/* @latonita @vincentscode
esphome/components/ens160_i2c/* @latonita
esphome/components/ens160_spi/* @latonita
esphome/components/ens210/* @itn3rd77
esphome/components/entity_transport/* @clydebarrow
esphome/components/epaper_spi/* @esphome/core
esphome/components/es7210/* @kahrendt
esphome/components/es7243/* @Chorty
+5 -16
View File
@@ -13,19 +13,10 @@ from esphome.const import (
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
from .. import copy_ns
CopySensor = copy_ns.class_("CopySensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
sensor.sensor_schema(CopySensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
}
)
FINAL_VALIDATE_SCHEMA = cv.All(
@@ -40,6 +31,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "copy_sensor.h"
#include "esphome/core/log.h"
namespace esphome::copy {
ESPHOME_LOG_TAG(TAG, "copy.sensor");
void CopySensor::setup() {
source_->add_on_state_callback([this](float value) { this->publish_state(value); });
if (source_->has_state())
this->publish_state(source_->state);
}
void CopySensor::dump_config() { LOG_SENSOR("", "Copy Sensor", this); }
} // namespace esphome::copy
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::copy {
class CopySensor final : public sensor::Sensor, public Component {
public:
void set_source(sensor::Sensor *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
sensor::Sensor *source_;
};
} // namespace esphome::copy
@@ -5,19 +5,10 @@ from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
from .. import copy_ns
CopyTextSensor = copy_ns.class_("CopyTextSensor", text_sensor.TextSensor, cg.Component)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema(CopyTextSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
}
)
FINAL_VALIDATE_SCHEMA = cv.All(
@@ -28,6 +19,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = await text_sensor.new_text_sensor(config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "copy_text_sensor.h"
#include "esphome/core/log.h"
namespace esphome::copy {
ESPHOME_LOG_TAG(TAG, "copy.text_sensor");
void CopyTextSensor::setup() {
source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
if (source_->has_state())
this->publish_state(source_->state);
}
void CopyTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Copy Sensor", this); }
} // namespace esphome::copy
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome::copy {
class CopyTextSensor final : public text_sensor::TextSensor, public Component {
public:
void set_source(text_sensor::TextSensor *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
text_sensor::TextSensor *source_;
};
} // namespace esphome::copy
+30 -1
View File
@@ -1,6 +1,7 @@
from dataclasses import dataclass
from typing import Any, Protocol
from esphome import automation, core
from esphome import automation, core, pins
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components.const import (
@@ -13,6 +14,7 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_DIMENSIONS,
CONF_ENABLE_PIN,
CONF_FROM,
CONF_HEIGHT,
CONF_ID,
@@ -54,6 +56,33 @@ DISPLAY_ROTATIONS = {
}
class DisplayModel(Protocol):
"""The parts of a display model used by enable_pin_schema()."""
def get_default(self, key: str, fallback: Any = ...) -> Any: ...
def enable_pin_schema(
model: DisplayModel, config: ConfigType
) -> dict[cv.Optional, Any]:
"""
Get the schema entry for a display's enable pins.
Pins given in the user config are used as they are. Otherwise the model's default enable
pins are used, except any that is also used elsewhere in the config, so a user can take
control of that pin, e.g. with a switch.
:param config: The display config as given by the user
"""
schema = cv.ensure_list(pins.gpio_output_pin_schema)
default = model.get_default(CONF_ENABLE_PIN, None)
if default is None or CONF_ENABLE_PIN in config:
return {cv.Optional(CONF_ENABLE_PIN): schema}
return {
cv.Optional(
CONF_ENABLE_PIN, default=default
): pins.PIN_SCHEMA_REGISTRY.optional(schema)
}
def validate_rotation(value):
value = cv.string(value)
value = value.removesuffix("°")
@@ -1,169 +0,0 @@
"""Send sensor and binary sensor states to other devices over a packet interface."""
from collections.abc import Callable
from typing import Any
import esphome.codegen as cg
from esphome.components import packet_interface
from esphome.components.binary_sensor import BinarySensor
from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID
from esphome.components.packet_interface import CONF_PACKET_INTERFACE, interface_layers
from esphome.components.packet_interface.layers import CONF_SESSION
from esphome.components.sensor import Sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BINARY_SENSORS,
CONF_ID,
CONF_SENSORS,
CONF_TIMEOUT,
CONF_UPDATE_INTERVAL,
)
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.types import ConfigType
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["packet_interface"]
DOMAIN = "entity_transport"
MULTI_CONF = True
CONF_BROADCAST_ID = "broadcast_id"
CONF_ENTITY_TRANSPORT_ID = "entity_transport_id"
entity_transport_ns = cg.esphome_ns.namespace("entity_transport")
EntityTransport = entity_transport_ns.class_("EntityTransport", cg.Component)
_request_remote_sensor_slot = cg.slot_counter("ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT")
_request_remote_binary_sensor_slot = cg.slot_counter(
"ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT"
)
def _advertised_id(conf: ConfigType) -> str:
return conf.get(CONF_BROADCAST_ID, conf[CONF_ID].id)
def _unique_advertised_ids(entities: list[ConfigType]) -> list[ConfigType]:
"""A receiver tells entities of one type apart by their advertised ids, so each must be different."""
seen: set[str] = set()
for index, conf in enumerate(entities):
if (advertised := _advertised_id(conf)) in seen:
raise cv.Invalid(
f"The id '{advertised}' is sent for two entities", path=[index]
)
seen.add(advertised)
return entities
def _local_entity(cls: MockObjClass) -> Callable[[Any], Any]:
return cv.maybe_simple_value(
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(cls),
cv.Optional(CONF_BROADCAST_ID): cv.All(
cv.validate_id_name, cv.Length(max=255)
),
}
),
key=CONF_ID,
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(EntityTransport),
cv.Required(CONF_PACKET_INTERFACE): cv.use_id(packet_interface.PacketInterface),
# Defaults to a periodic resend unless the interface has session and reliability layers
cv.Optional(CONF_UPDATE_INTERVAL): cv.All(
cv.update_interval, cv.Range(min=cv.TimePeriod(seconds=1))
),
# Remote entities lose their state when none arrives for this long
cv.Optional(CONF_TIMEOUT): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(seconds=1)),
),
# Entities are numbered with one byte
cv.Optional(CONF_SENSORS): cv.All(
cv.ensure_list(_local_entity(Sensor)),
cv.Length(max=255),
_unique_advertised_ids,
),
cv.Optional(CONF_BINARY_SENSORS): cv.All(
cv.ensure_list(_local_entity(BinarySensor)),
cv.Length(max=255),
_unique_advertised_ids,
),
}
).extend(cv.COMPONENT_SCHEMA)
def _final_validate(config: ConfigType) -> ConfigType:
"""With a session layer, which clears the states when the peer is lost, nothing would refresh an unchanged state."""
layers = interface_layers(config[CONF_PACKET_INTERFACE])
if CONF_TIMEOUT in config and layers is not None and CONF_SESSION in layers:
raise cv.Invalid(
f"Not allowed with a '{CONF_SESSION}' layer, which already clears the states when the other device "
"cannot be reached; unchanged states may never be sent again, so they would time out on a working link",
path=[CONF_TIMEOUT],
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
def _validate_remote_id(config: ConfigType) -> ConfigType:
if CONF_REMOTE_ID not in config and not config[CONF_ID].is_manual:
raise cv.Invalid(f"'{CONF_REMOTE_ID}' is required unless '{CONF_ID}' is given")
return config
def remote_entity_schema(base_schema: cv.Schema) -> cv.Schema:
"""Schema for an entity whose state comes from another device."""
return cv.All(
base_schema.extend(
{
cv.GenerateID(CONF_ENTITY_TRANSPORT_ID): cv.use_id(EntityTransport),
cv.Required(CONF_PROVIDER): cv.valid_name,
cv.Optional(CONF_REMOTE_ID): cv.All(
cv.string_strict, cv.Length(min=1, max=255)
),
}
),
_validate_remote_id,
)
def _remote_id(config: ConfigType) -> str:
return config.get(CONF_REMOTE_ID) or config[CONF_ID].id
async def register_remote_sensor(config: ConfigType, var: MockObj) -> None:
hub = await cg.get_variable(config[CONF_ENTITY_TRANSPORT_ID])
_request_remote_sensor_slot(str(hub))
cg.add(hub.add_remote_sensor(config[CONF_PROVIDER], _remote_id(config), var))
async def register_remote_binary_sensor(config: ConfigType, var: MockObj) -> None:
hub = await cg.get_variable(config[CONF_ENTITY_TRANSPORT_ID])
_request_remote_binary_sensor_slot(str(hub))
cg.add(hub.add_remote_binary_sensor(config[CONF_PROVIDER], _remote_id(config), var))
async def to_code(config: ConfigType) -> None:
interface = await cg.get_variable(config[CONF_PACKET_INTERFACE])
var = cg.new_Pvariable(config[CONF_ID], interface)
await cg.register_component(var, config)
if (interval := config.get(CONF_UPDATE_INTERVAL)) is not None:
cg.add(var.set_update_interval(interval))
if (timeout := config.get(CONF_TIMEOUT)) is not None:
cg.add(var.set_remote_timeout(timeout))
sensors = config.get(CONF_SENSORS, [])
binary_sensors = config.get(CONF_BINARY_SENSORS, [])
if count := len(sensors) + len(binary_sensors):
cg.add(var.set_entity_count(count))
for conf in sensors:
sensor = await cg.get_variable(conf[CONF_ID])
cg.add(var.add_sensor(_advertised_id(conf), sensor))
for conf in binary_sensors:
sensor = await cg.get_variable(conf[CONF_ID])
cg.add(var.add_binary_sensor(_advertised_id(conf), sensor))
@@ -1,11 +0,0 @@
from esphome.components import binary_sensor
from esphome.types import ConfigType
from . import register_remote_binary_sensor, remote_entity_schema
CONFIG_SCHEMA = remote_entity_schema(binary_sensor.binary_sensor_schema())
async def to_code(config: ConfigType) -> None:
var = await binary_sensor.new_binary_sensor(config)
await register_remote_binary_sensor(config, var)
@@ -1,683 +0,0 @@
#include "entity_transport.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::entity_transport {
using packet_interface::PacketBuffer;
using packet_interface::PacketMetaData;
ESPHOME_LOG_TAG(TAG, "entity_transport");
// The packet type byte holds the format version in its high nibble
static constexpr uint8_t FORMAT_VERSION = 1;
static constexpr uint8_t PACKET_DICTIONARY = 0x11;
static constexpr uint8_t PACKET_DATA = 0x12;
static constexpr uint8_t PACKET_REQUEST = 0x13;
// Entity types, in dictionary sections and data records; 0 is not used
static constexpr uint8_t TYPE_SENSOR = 1;
static constexpr uint8_t TYPE_BINARY_SENSOR = 2;
static constexpr uint8_t MAX_TYPE = 31;
// Packet type and dictionary id
static constexpr size_t HEADER_SIZE = 5;
// A dictionary packet's part byte follows the header: the part's index in bits 0-6, and on the last part, bit 7
static constexpr uint8_t PART_INDEX_MASK = 0x7F;
static constexpr uint8_t PART_LAST = 0x80;
static constexpr uint8_t PART_NONE = 0xFF; // no dictionary is being received in order
// A data record starts with a 16-bit tag: the entity's number in bits 0-7, its type in bits 8-12, the payload form in
// bits 13-14, and with PAYLOAD_NONE, the state in bit 15. Any record can be skipped without knowing its type.
static constexpr size_t TAG_SIZE = 2;
static constexpr uint8_t TAG_TYPE_SHIFT = 8;
static constexpr uint8_t TAG_PAYLOAD_SHIFT = 13;
static constexpr uint16_t TAG_VALUE_BIT = 0x8000;
static constexpr uint8_t PAYLOAD_NONE = 0; // the state is in the tag's value bit
static constexpr uint8_t PAYLOAD_WORD = 1; // 4 bytes follow
static constexpr uint8_t PAYLOAD_LENGTH = 2; // a length byte follows, then that many bytes
static constexpr uint8_t PAYLOAD_NO_STATE = 3; // the entity has no state
static constexpr size_t WORD_SIZE = 4;
static constexpr size_t MAX_RECORD_SIZE = TAG_SIZE + WORD_SIZE;
// A dictionary section starts with the entity type, first number and count
static constexpr size_t SECTION_HEADER_SIZE = 3;
static constexpr size_t MAX_STRING_LENGTH = 255;
static constexpr uint32_t DEFAULT_UPDATE_INTERVAL_MS = 15000;
static constexpr uint32_t REQUEST_INTERVAL_MS = 5000;
static constexpr uint32_t RETRY_DELAY_MS = 1000;
static constexpr uint32_t RETRY_TIMER_ID = 0;
static constexpr uint32_t TIMEOUT_CHECK_INTERVAL_MS = 1000;
static constexpr uint32_t WARNING_INTERVAL_MS = 60000;
// How long the interface may refuse every packet before it is reported
static constexpr uint32_t REFUSED_WARNING_MS = 60000;
static void put_uint32(uint8_t *buf, uint32_t value) {
for (size_t i = 0; i != 4; i++)
buf[i] = value >> (i * 8);
}
// True if the NUL terminated str is the same as the len bytes at data
static bool matches(const char *str, const char *data, size_t len) {
return strlen(str) == len && memcmp(str, data, len) == 0;
}
struct Record {
uint8_t number;
uint8_t type;
uint8_t payload;
bool value;
const uint8_t *data; // what follows the tag
};
static Record parse_record(std::span<const uint8_t> packet, size_t pos) {
uint16_t tag = encode_uint16(packet[pos + 1], packet[pos]);
return {static_cast<uint8_t>(tag), static_cast<uint8_t>((tag >> TAG_TYPE_SHIFT) & MAX_TYPE),
static_cast<uint8_t>((tag >> TAG_PAYLOAD_SHIFT) & 3), (tag & TAG_VALUE_BIT) != 0,
packet.data() + pos + TAG_SIZE};
}
// The size of the data record at pos, or 0 if it does not fit in the packet
static size_t record_size(std::span<const uint8_t> packet, size_t pos) {
if (pos + TAG_SIZE > packet.size())
return 0;
size_t size = TAG_SIZE;
uint8_t payload = parse_record(packet, pos).payload;
if (payload == PAYLOAD_WORD) {
size += WORD_SIZE;
} else if (payload == PAYLOAD_LENGTH) {
if (pos + TAG_SIZE == packet.size())
return 0;
size += 1 + packet[pos + TAG_SIZE];
}
return pos + size <= packet.size() ? size : 0;
}
// Checks every record of a data packet, so that a damaged or forged packet changes no state at all
static bool data_well_formed(std::span<const uint8_t> packet) {
for (size_t pos = HEADER_SIZE; pos != packet.size();) {
size_t size = record_size(packet, pos);
if (size == 0)
return false;
Record record = parse_record(packet, pos);
if (record.type == 0 || (record.value && record.payload != PAYLOAD_NONE))
return false;
// Types this device knows must use their own payload form; others are skipped
if (record.type == TYPE_SENSOR && record.payload != PAYLOAD_WORD && record.payload != PAYLOAD_NO_STATE)
return false;
if (record.type == TYPE_BINARY_SENSOR && record.payload != PAYLOAD_NONE && record.payload != PAYLOAD_NO_STATE)
return false;
pos += size;
}
return true;
}
// Checks the device name and every section of a dictionary packet before anything is learnt from it
static bool dictionary_well_formed(std::span<const uint8_t> packet) {
size_t pos = HEADER_SIZE + 1;
if (pos >= packet.size())
return false;
pos += 1 + packet[pos];
if (pos > packet.size())
return false;
while (pos != packet.size()) {
if (pos + SECTION_HEADER_SIZE > packet.size())
return false;
uint8_t type = packet[pos];
size_t first = packet[pos + 1];
size_t count = packet[pos + 2];
pos += SECTION_HEADER_SIZE;
// Numbers are one byte, and a data record can name types up to MAX_TYPE only
if (type == 0 || type > MAX_TYPE || count == 0 || first + count > 256)
return false;
for (size_t i = 0; i != count; i++) {
if (pos == packet.size())
return false;
pos += 1 + packet[pos];
if (pos > packet.size())
return false;
}
}
return true;
}
void EntityTransport::setup() {
this->interface_ = this->interface_->top();
this->max_packet_size_ = this->interface_->get_max_packet_size();
const auto &name = App.get_name();
size_t longest_id = 0;
for (const auto &entity : this->entities_)
longest_id = std::max(longest_id, strlen(entity.id));
// A dictionary packet must hold the device name and any one id, which also leaves room for a data record
if (name.size() > MAX_STRING_LENGTH || longest_id > MAX_STRING_LENGTH ||
HEADER_SIZE + 2 + name.size() + SECTION_HEADER_SIZE + 1 + longest_id > this->max_packet_size_) {
this->mark_failed(LOG_STR("max_packet_size too small for the device name and entity ids"));
return;
}
RAMAllocator<uint8_t> allocator;
this->buffer_ = allocator.allocate(this->max_packet_size_);
if (this->buffer_ == nullptr) {
this->mark_failed(LOG_STR("Not enough memory for the packet buffer"));
return;
}
// The dictionary id changes when the device name or any entity changes
uint32_t hash = fnv1a_hash(name.c_str());
for (const auto &entity : this->entities_) {
hash = fnv1a_hash_extend(hash, entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR);
hash = fnv1a_hash_extend(hash, entity.id);
}
this->dictionary_id_ = hash;
// [&entity] is safe: FixedVector never reallocates, and the parent pointer keeps the capture to one pointer
for (auto &entity : this->entities_) {
#ifdef USE_SENSOR
if (entity.is_sensor) {
static_cast<sensor::Sensor *>(entity.entity)->add_on_state_callback([&entity](float) {
entity.parent->mark_updated_(entity);
});
}
#endif
#ifdef USE_BINARY_SENSOR
if (!entity.is_sensor) {
// The full state callback also fires for the first state
static_cast<binary_sensor::BinarySensor *>(entity.entity)
->add_full_state_callback(
[&entity](optional<bool>, optional<bool>) { entity.parent->mark_updated_(entity); });
}
#endif
}
this->interface_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &) { this->receive_(data); });
bool session = this->interface_->has_capability(packet_interface::CAPABILITY_SESSION);
if (session)
this->interface_->add_on_session_state_callback([this](bool connected) { this->on_session_(connected); });
// Without a session and reliable delivery, a lost dictionary or state is only replaced by a periodic resend
if (this->update_interval_ms_ == 0) {
this->update_interval_ms_ = session && this->interface_->has_capability(packet_interface::CAPABILITY_RELIABILITY)
? SCHEDULER_DONT_RUN
: DEFAULT_UPDATE_INTERVAL_MS;
}
if (this->update_interval_ms_ != SCHEDULER_DONT_RUN)
this->set_interval(this->update_interval_ms_, [this] { this->send_all_(); });
if (this->remote_timeout_ms_ != 0)
this->set_interval(TIMEOUT_CHECK_INTERVAL_MS, [this] { this->clear_remote_states_(true); });
this->send_all_();
}
void EntityTransport::dump_config() {
ESP_LOGCONFIG(TAG,
"Entity Transport:\n"
" Packet Interface: %s\n"
" Max Packet Size: %zu\n"
" Encryption: %s\n"
" Reliability: %s\n"
" Session: %s\n"
" Dictionary ID: %08" PRIX32,
this->interface_->get_name(), this->max_packet_size_,
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_ENCRYPTION)),
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_RELIABILITY)),
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_SESSION)), this->dictionary_id_);
if (this->update_interval_ms_ == SCHEDULER_DONT_RUN) {
ESP_LOGCONFIG(TAG, " Update Interval: never");
} else {
ESP_LOGCONFIG(TAG, " Update Interval: %" PRIu32 "ms", this->update_interval_ms_);
}
if (this->remote_timeout_ms_ != 0) {
ESP_LOGCONFIG(TAG, " Remote Timeout: %" PRIu32 "ms", this->remote_timeout_ms_);
}
for (const auto &entity : this->entities_) {
ESP_LOGCONFIG(TAG, " %s %u: %s", entity.is_sensor ? LOG_STR_LITERAL("Sensor") : LOG_STR_LITERAL("Binary Sensor"),
entity.number, entity.id);
}
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (const auto &remote : this->remote_sensors_) {
ESP_LOGCONFIG(TAG, " Remote Sensor: %s from %s", remote.remote_id, remote.provider);
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (const auto &remote : this->remote_binary_sensors_) {
ESP_LOGCONFIG(TAG, " Remote Binary Sensor: %s from %s", remote.remote_id, remote.provider);
}
#endif
}
void EntityTransport::send_all_() {
this->dictionary_pending_ = true;
this->dictionary_next_ = 0;
this->dictionary_part_ = 0;
for (auto &entity : this->entities_)
entity.updated = true;
this->enable_loop();
}
void EntityTransport::mark_updated_(LocalEntity &entity) {
entity.updated = true;
this->enable_loop();
}
void EntityTransport::loop() {
this->disable_loop();
// States are only sent once the dictionary that names them has been accepted
if ((this->dictionary_pending_ && !this->send_dictionary_()) || !this->send_updated_()) {
this->sending_refused_();
this->set_timeout(RETRY_TIMER_ID, RETRY_DELAY_MS, [this] { this->enable_loop(); });
return;
}
this->refusing_ = false;
if (this->status_has_warning()) {
ESP_LOGI(TAG, "Packets are accepted again");
this->status_clear_warning();
}
}
// A link that is down or full refuses packets for a while; one that refuses them for long sends nothing at all
void EntityTransport::sending_refused_() {
uint32_t now = App.get_loop_component_start_time();
if (!this->refusing_) {
this->refusing_ = true;
this->refused_since_ms_ = now;
return;
}
if (!this->status_has_warning() && now - this->refused_since_ms_ >= REFUSED_WARNING_MS) {
ESP_LOGW(TAG, "The packet interface has accepted no packets for %" PRIu32 "s, so no states are sent",
(now - this->refused_since_ms_) / 1000);
this->status_set_warning();
}
}
size_t EntityTransport::write_header_(uint8_t type) const {
this->buffer_[0] = type;
put_uint32(this->buffer_ + 1, this->dictionary_id_);
return HEADER_SIZE;
}
bool EntityTransport::send_dictionary_() {
uint8_t *buf = this->buffer_;
const auto &name = App.get_name();
size_t header = this->write_header_(PACKET_DICTIONARY);
size_t part_offset = header++;
buf[header++] = name.size();
memcpy(buf + header, name.c_str(), name.size());
header += name.size();
size_t length = header;
size_t count_offset = 0;
uint8_t section_type = 0; // none
// A dictionary in several packets resumes after the last packet accepted, so a refused one does not resend the rest
for (size_t i = this->dictionary_next_; i != this->entities_.size(); i++) {
const auto &entity = this->entities_[i];
uint8_t type = entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR;
size_t id_length = strlen(entity.id);
bool new_section = type != section_type;
if (length + (new_section ? SECTION_HEADER_SIZE : 0) + 1 + id_length > this->max_packet_size_) {
buf[part_offset] = this->dictionary_part_ & PART_INDEX_MASK;
if (!this->interface_->send_to_interface(PacketBuffer(buf, length), {})) {
ESP_LOGV(TAG, "Dictionary packet of %zu bytes not accepted, will retry", length);
return false;
}
this->dictionary_next_ = i;
this->dictionary_part_++;
length = header;
new_section = true;
}
if (new_section) {
buf[length++] = type;
buf[length++] = entity.number;
count_offset = length;
buf[length++] = 0;
section_type = type;
}
buf[count_offset]++;
buf[length++] = id_length;
memcpy(buf + length, entity.id, id_length);
length += id_length;
}
if (length != header) {
buf[part_offset] = (this->dictionary_part_ & PART_INDEX_MASK) | PART_LAST;
if (!this->interface_->send_to_interface(PacketBuffer(buf, length), {})) {
ESP_LOGV(TAG, "Dictionary packet of %zu bytes not accepted, will retry", length);
return false;
}
}
this->dictionary_pending_ = false;
this->dictionary_next_ = 0;
this->dictionary_part_ = 0;
return true;
}
// Writes the entity's data record, and returns its size
size_t EntityTransport::write_record_(const LocalEntity &entity, uint8_t *record) const {
uint8_t type = entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR;
uint8_t payload = PAYLOAD_NO_STATE;
bool value = false;
size_t size = TAG_SIZE;
#ifdef USE_SENSOR
if (entity.is_sensor) {
float state = static_cast<sensor::Sensor *>(entity.entity)->get_state();
if (!std::isnan(state)) {
payload = PAYLOAD_WORD;
uint32_t bits;
memcpy(&bits, &state, sizeof(bits));
put_uint32(record + TAG_SIZE, bits);
size += WORD_SIZE;
}
}
#endif
#ifdef USE_BINARY_SENSOR
if (!entity.is_sensor) {
auto *sensor = static_cast<binary_sensor::BinarySensor *>(entity.entity);
if (sensor->has_state()) {
payload = PAYLOAD_NONE;
value = sensor->state;
}
}
#endif
uint16_t tag =
entity.number | (type << TAG_TYPE_SHIFT) | (payload << TAG_PAYLOAD_SHIFT) | (value ? TAG_VALUE_BIT : 0);
record[0] = tag;
record[1] = tag >> 8;
return size;
}
// Sends every updated entity, in as few packets as fit; returns false if a packet was refused
bool EntityTransport::send_updated_() {
size_t length = this->write_header_(PACKET_DATA);
size_t first = 0;
for (size_t i = 0; i != this->entities_.size(); i++) {
auto &entity = this->entities_[i];
if (!entity.updated)
continue;
uint8_t record[MAX_RECORD_SIZE];
size_t size = this->write_record_(entity, record);
if (length + size > this->max_packet_size_) {
if (!this->flush_(length, first, i))
return false;
length = HEADER_SIZE;
first = i;
}
memcpy(this->buffer_ + length, record, size);
length += size;
}
return this->flush_(length, first, this->entities_.size());
}
// Sends the packet holding the updated entities from first up to last, and clears their flags if it was accepted
bool EntityTransport::flush_(size_t length, size_t first, size_t last) {
if (length == HEADER_SIZE)
return true;
if (!this->interface_->send_to_interface(PacketBuffer(this->buffer_, length), {})) {
ESP_LOGV(TAG, "Packet of %zu bytes not accepted, will retry", length);
return false;
}
for (size_t i = first; i != last; i++)
this->entities_[i].updated = false;
return true;
}
void EntityTransport::receive_(const PacketBuffer &data) {
// Packet interface layers deliver packets in one piece
if (!data.is_contiguous()) {
ESP_LOGW(TAG, "Packet split across buffers is not supported");
return;
}
auto packet = data.as_span();
if (packet.size() < HEADER_SIZE) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Packet of %zu bytes is too short", packet.size());
}
return;
}
if (packet[0] >> 4 != FORMAT_VERSION) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Packet format version %u not supported; the sender may run another ESPHome version",
packet[0] >> 4);
}
return;
}
uint32_t dictionary_id = encode_uint32(packet[4], packet[3], packet[2], packet[1]);
if (dictionary_id == this->dictionary_id_) {
// Either a request for our dictionary, or our own packet heard on a broadcast channel
if (packet[0] == PACKET_REQUEST && packet.size() == HEADER_SIZE) {
ESP_LOGD(TAG, "Dictionary requested");
this->send_all_();
}
return;
}
if (packet[0] == PACKET_DICTIONARY) {
this->receive_dictionary_(packet, dictionary_id);
} else if (packet[0] == PACKET_DATA) {
this->receive_data_(packet, dictionary_id);
} else if (packet[0] != PACKET_REQUEST) {
ESP_LOGV(TAG, "Unknown packet type 0x%02X", packet[0]);
}
}
void EntityTransport::receive_dictionary_(std::span<const uint8_t> packet, uint32_t dictionary_id) {
if (!dictionary_well_formed(packet)) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Dictionary packet of %zu bytes is malformed", packet.size());
}
return;
}
size_t pos = HEADER_SIZE;
uint8_t part = packet[pos++];
uint8_t index = part & PART_INDEX_MASK;
// The dictionary is complete, and need not be asked for again, once its last part follows all the others in order
bool in_order = index == 0 || (this->rx_dictionary_id_ == dictionary_id && index == this->rx_next_part_);
this->rx_dictionary_id_ = dictionary_id;
this->rx_next_part_ = in_order ? (index + 1) & PART_INDEX_MASK : PART_NONE;
bool completed = in_order && (part & PART_LAST) != 0;
bool first_time = completed && !this->dictionary_seen_(dictionary_id);
if (first_time) {
this->seen_dictionaries_[this->seen_next_] = dictionary_id;
this->seen_next_ = (this->seen_next_ + 1) % this->seen_dictionaries_.size();
}
size_t provider_length = packet[pos++];
const auto *provider = reinterpret_cast<const char *>(packet.data() + pos);
pos += provider_length;
while (pos != packet.size()) {
uint8_t type = packet[pos];
uint8_t number = packet[pos + 1];
uint8_t count = packet[pos + 2];
pos += SECTION_HEADER_SIZE;
for (uint8_t i = 0; i != count; i++) {
size_t id_length = packet[pos++];
this->learn_(provider, provider_length, type, number + i, reinterpret_cast<const char *>(packet.data() + pos),
id_length, dictionary_id);
pos += id_length;
}
}
// A provider may go back to a dictionary already seen, so every complete one is checked
if (completed)
this->forget_unnamed_(provider, provider_length, dictionary_id, first_time);
}
void EntityTransport::forget_unnamed_(const char *provider, size_t provider_length, uint32_t dictionary_id,
bool first_time) {
// A remote entity that its provider's whole dictionary does not name would otherwise keep no or a stale state
// silently
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (auto &remote : this->remote_sensors_) {
if (!matches(remote.provider, provider, provider_length) || (remote.known && remote.dictionary_id == dictionary_id))
continue;
if (remote.known || first_time) {
ESP_LOGW(TAG, "'%s' does not send '%s'", remote.provider, remote.remote_id);
}
remote.known = false;
if (remote.has_state) {
remote.has_state = false;
remote.entity->publish_state(NAN);
}
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (auto &remote : this->remote_binary_sensors_) {
if (!matches(remote.provider, provider, provider_length) || (remote.known && remote.dictionary_id == dictionary_id))
continue;
if (remote.known || first_time) {
ESP_LOGW(TAG, "'%s' does not send '%s'", remote.provider, remote.remote_id);
}
remote.known = false;
if (remote.has_state) {
remote.has_state = false;
remote.entity->invalidate_state();
}
}
#endif
}
void EntityTransport::learn_(const char *provider, size_t provider_length, uint8_t type, uint8_t number, const char *id,
size_t id_length, uint32_t dictionary_id) {
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
if (type == TYPE_SENSOR) {
for (auto &remote : this->remote_sensors_) {
if (matches(remote.provider, provider, provider_length) && matches(remote.remote_id, id, id_length)) {
remote.dictionary_id = dictionary_id;
remote.number = number;
remote.known = true;
}
}
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
if (type == TYPE_BINARY_SENSOR) {
for (auto &remote : this->remote_binary_sensors_) {
if (matches(remote.provider, provider, provider_length) && matches(remote.remote_id, id, id_length)) {
remote.dictionary_id = dictionary_id;
remote.number = number;
remote.known = true;
}
}
}
#endif
}
bool EntityTransport::dictionary_seen_(uint32_t dictionary_id) const {
return std::find(this->seen_dictionaries_.begin(), this->seen_dictionaries_.end(), dictionary_id) !=
this->seen_dictionaries_.end();
}
void EntityTransport::receive_data_(std::span<const uint8_t> packet, uint32_t dictionary_id) {
bool known = false;
bool has_remotes = false;
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (const auto &remote : this->remote_sensors_) {
known |= remote.known && remote.dictionary_id == dictionary_id;
has_remotes = true;
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (const auto &remote : this->remote_binary_sensors_) {
known |= remote.known && remote.dictionary_id == dictionary_id;
has_remotes = true;
}
#endif
if (!has_remotes)
return;
// Checked first, so a damaged packet does not make the provider resend everything
if (!data_well_formed(packet)) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Data packet of %zu bytes is malformed", packet.size());
}
return;
}
// A dictionary not yet received in full may name our entities, or be a provider's new one; one received in full
// named all of them it could, so asking again would only make that provider resend everything
if (!this->dictionary_seen_(dictionary_id))
this->request_dictionary_(dictionary_id);
if (!known)
return;
[[maybe_unused]] uint32_t now = App.get_loop_component_start_time();
for (size_t pos = HEADER_SIZE; pos != packet.size(); pos += record_size(packet, pos)) {
[[maybe_unused]] Record record = parse_record(packet, pos);
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
if (record.type == TYPE_SENSOR) {
float value = NAN;
if (record.payload == PAYLOAD_WORD) {
uint32_t bits = encode_uint32(record.data[3], record.data[2], record.data[1], record.data[0]);
memcpy(&value, &bits, sizeof(value));
}
for (auto &remote : this->remote_sensors_) {
if (remote.known && remote.dictionary_id == dictionary_id && remote.number == record.number) {
remote.last_state_ms = now;
remote.has_state = true;
remote.entity->publish_state(value);
}
}
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
if (record.type == TYPE_BINARY_SENSOR) {
for (auto &remote : this->remote_binary_sensors_) {
if (!remote.known || remote.dictionary_id != dictionary_id || remote.number != record.number)
continue;
remote.last_state_ms = now;
remote.has_state = true;
if (record.payload == PAYLOAD_NO_STATE) {
remote.entity->invalidate_state();
} else {
remote.entity->publish_state(record.value);
}
}
}
#endif
}
}
void EntityTransport::request_dictionary_(uint32_t dictionary_id) {
uint32_t now = App.get_loop_component_start_time();
if (this->request_sent_ && now - this->last_request_ms_ < REQUEST_INTERVAL_MS)
return;
uint8_t packet[HEADER_SIZE] = {PACKET_REQUEST};
put_uint32(packet + 1, dictionary_id);
// A refused request is tried again with the next data packet under that dictionary id
if (!this->interface_->send_to_interface(PacketBuffer(packet, sizeof(packet)), {})) {
ESP_LOGV(TAG, "Request for dictionary %08" PRIX32 " not accepted", dictionary_id);
return;
}
this->request_sent_ = true;
this->last_request_ms_ = now;
ESP_LOGD(TAG, "Requested dictionary %08" PRIX32, dictionary_id);
}
// Dropped packets point to a device with other firmware or a fault, and may arrive steadily, so warn only now and then
bool EntityTransport::warning_due_() {
uint32_t now = App.get_loop_component_start_time();
if (this->warned_ && now - this->last_warning_ms_ < WARNING_INTERVAL_MS)
return false;
this->warned_ = true;
this->last_warning_ms_ = now;
return true;
}
// A newly connected or restarted peer needs the dictionary and every state; while it is lost, its entities have no
// state
void EntityTransport::on_session_(bool connected) {
if (connected) {
this->send_all_();
return;
}
this->clear_remote_states_(false);
}
// Clears the state of every remote entity, or with timed_out_only, of those whose state is older than the timeout
void EntityTransport::clear_remote_states_(bool timed_out_only) {
[[maybe_unused]] uint32_t now = App.get_loop_component_start_time();
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (auto &remote : this->remote_sensors_) {
if (timed_out_only && (!remote.has_state || now - remote.last_state_ms <= this->remote_timeout_ms_))
continue;
remote.has_state = false;
remote.entity->publish_state(NAN);
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (auto &remote : this->remote_binary_sensors_) {
if (timed_out_only && (!remote.has_state || now - remote.last_state_ms <= this->remote_timeout_ms_))
continue;
remote.has_state = false;
remote.entity->invalidate_state();
}
#endif
}
} // namespace esphome::entity_transport
@@ -1,171 +0,0 @@
#pragma once
#include <array>
#include <span>
#include "esphome/components/packet_interface/packet_interface.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/helpers.h"
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::entity_transport {
/**
* Sends the states of local sensors and binary sensors over a packet interface, and publishes the states received
* from other devices to remote entities.
*
* Each device numbers its sensors and its binary sensors from 0, and sends a dictionary that gives the id for each
* number. Data packets then refer to an entity by its number. Every packet carries the sender's dictionary id, a hash
* of its device name and dictionary, so that a receiver knows which device sent it and which dictionary applies.
* All values are little endian.
*
* Dictionary packet:
* - type: 1 byte, PACKET_DICTIONARY;
* - dictionary id: 4 bytes;
* - part: 1 byte, the packet's index in the dictionary in bits 0-6, counting from 0 and wrapping, and bit 7 set on
* the last packet;
* - sender's device name: length (1 byte), then the name;
* - one or more sections, each: entity type (1 byte), first number (1 byte), count (1 byte), then for each entity
* its id: length (1 byte), then the id.
*
* Data packet:
* - type: 1 byte, PACKET_DATA;
* - dictionary id: 4 bytes;
* - records, to the end, each a 16-bit tag, then its payload. The tag holds the entity's number in bits 0-7, its type
* in bits 8-12, and the payload form in bits 13-14: none, with the state in bit 15 (binary sensors); 4 bytes (a
* sensor's float); a length byte, then that many bytes; or no state, with no payload. The form gives the record's
* size, so a receiver skips records of types it does not know.
*
* Every packet is checked in full before anything in it is used, so a damaged or forged packet changes no state.
*
* Dictionary request packet, sent by a device that receives data with a dictionary id it has not received in full,
* with every part in order:
* - type: 1 byte, PACKET_REQUEST;
* - the dictionary id wanted: 4 bytes.
*
* Changed states are sent from loop(). The dictionary and all states are sent at boot, when a session layer reports
* the peer connected, when the dictionary is requested, and every update interval. Unless the interface has both a
* session and a reliability layer, the update interval defaults to DEFAULT_UPDATE_INTERVAL_MS. When the interface
* refuses a packet, sending is tried again after a delay, and a warning is set once it has refused every packet for a
* minute. With a remote timeout, a remote entity loses its state when
* none arrives for it within the timeout.
*/
class EntityTransport : public Component {
public:
explicit EntityTransport(packet_interface::PacketInterface *interface) : interface_(interface) {}
void setup() override;
void loop() override;
void dump_config() override;
/// 0 chooses the default for the interface's layers; SCHEDULER_DONT_RUN never resends.
void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; }
/// Remote entities lose their state when none arrives for this long; 0 keeps the last state.
void set_remote_timeout(uint32_t timeout_ms) { this->remote_timeout_ms_ = timeout_ms; }
void set_entity_count(size_t count) { this->entities_.init(count); }
// Sensors must be added before binary sensors, so that data packets hold sensor records first
#ifdef USE_SENSOR
void add_sensor(const char *id, sensor::Sensor *sensor) {
this->entities_.push_back({sensor, id, this, this->sensor_count_++, true, true});
}
#endif
#ifdef USE_BINARY_SENSOR
void add_binary_sensor(const char *id, binary_sensor::BinarySensor *sensor) {
this->entities_.push_back({sensor, id, this, this->binary_sensor_count_++, false, true});
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
void add_remote_sensor(const char *provider, const char *remote_id, sensor::Sensor *sensor) {
this->remote_sensors_.push_back({provider, remote_id, sensor});
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
void add_remote_binary_sensor(const char *provider, const char *remote_id, binary_sensor::BinarySensor *sensor) {
this->remote_binary_sensors_.push_back({provider, remote_id, sensor});
}
#endif
protected:
struct LocalEntity {
EntityBase *entity; // a sensor::Sensor or a binary_sensor::BinarySensor, as is_sensor says
const char *id;
EntityTransport *parent;
uint8_t number;
bool is_sensor;
bool updated;
};
template<typename T> struct RemoteEntity {
const char *provider;
const char *remote_id;
T *entity;
uint32_t dictionary_id{0}; // learnt from the provider's dictionary
uint32_t last_state_ms{0};
uint8_t number{0};
bool known{false};
bool has_state{false}; // a state arrived, and has not yet timed out
};
void send_all_();
void mark_updated_(LocalEntity &entity);
bool send_dictionary_();
bool send_updated_();
size_t write_record_(const LocalEntity &entity, uint8_t *record) const;
bool flush_(size_t length, size_t first, size_t last);
size_t write_header_(uint8_t type) const;
void receive_(const packet_interface::PacketBuffer &data);
void receive_dictionary_(std::span<const uint8_t> packet, uint32_t dictionary_id);
void receive_data_(std::span<const uint8_t> packet, uint32_t dictionary_id);
void learn_(const char *provider, size_t provider_length, uint8_t type, uint8_t number, const char *id,
size_t id_length, uint32_t dictionary_id);
void request_dictionary_(uint32_t dictionary_id);
bool dictionary_seen_(uint32_t dictionary_id) const;
/// Clears each remote entity of \p provider that its complete dictionary does not name, warning if the entity was
/// known or the dictionary is \p first_time seen, so a resent dictionary does not repeat the warning.
void forget_unnamed_(const char *provider, size_t provider_length, uint32_t dictionary_id, bool first_time);
bool warning_due_();
void sending_refused_();
void on_session_(bool connected);
void clear_remote_states_(bool timed_out_only);
packet_interface::PacketInterface *interface_;
FixedVector<LocalEntity> entities_;
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
StaticVector<RemoteEntity<sensor::Sensor>, ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT> remote_sensors_;
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
StaticVector<RemoteEntity<binary_sensor::BinarySensor>, ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT>
remote_binary_sensors_;
#endif
// Packets are built here, allocated in setup()
uint8_t *buffer_{nullptr};
size_t max_packet_size_{0};
uint32_t update_interval_ms_{0};
uint32_t remote_timeout_ms_{0};
uint32_t dictionary_id_{0};
uint32_t last_request_ms_{0};
uint32_t last_warning_ms_{0};
uint32_t refused_since_ms_{0}; // when the interface began refusing every packet
uint32_t rx_dictionary_id_{0}; // the dictionary whose parts are arriving
// The last dictionary ids received in full, so a provider whose dictionary names none of our entities is not asked
// again
std::array<uint32_t, 8> seen_dictionaries_{};
uint8_t seen_next_{0};
uint8_t rx_next_part_{0}; // the index of its next part, or PART_NONE if one was missed
uint8_t dictionary_part_{0}; // the index of the next dictionary packet to send
uint8_t sensor_count_{0};
uint8_t binary_sensor_count_{0};
size_t dictionary_next_{0}; // the first entity of the next dictionary packet to send
bool dictionary_pending_{true};
bool request_sent_{false};
bool warned_{false};
bool refusing_{false};
};
} // namespace esphome::entity_transport
@@ -1,11 +0,0 @@
from esphome.components import sensor
from esphome.types import ConfigType
from . import register_remote_sensor, remote_entity_schema
CONFIG_SCHEMA = remote_entity_schema(sensor.sensor_schema())
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await register_remote_sensor(config, var)
+1 -1
View File
@@ -146,7 +146,7 @@ def model_schema(config):
cv.GenerateID(): cv.declare_id(class_name),
cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8),
cv_dimensions(CONF_DIMENSIONS): dimension_schema(model),
model.option(CONF_ENABLE_PIN): cv.ensure_list(pins.gpio_output_pin_schema),
**display.enable_pin_schema(model, config),
model.option(CONF_INIT_SEQUENCE, cv.UNDEFINED): cv.ensure_list(
map_sequence
),
@@ -1,8 +1,8 @@
from typing import Any, Self
from esphome.components.mipi import check_model_requirements
import esphome.config_validation as cv
from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_OFFSET_HEIGHT, CONF_WIDTH
from esphome.core import CORE
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
@@ -128,24 +128,5 @@ class EpaperModel:
return model
def check_requirements(self) -> None:
"""
Raise a friendly error if any component this model requires is not configured.
This runs during schema validation (before ID references are resolved) so that a
model whose default pins live on a pin expander reports the missing expander clearly
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
"""
if requirements := self.get_default("requires", set()):
# ``raw_config`` is populated before any component schema runs during a real
# validation, so presence of a required component is simply a top-level key.
# When it is absent (e.g. a unit test that invokes the schema directly) there
# is no config to check against, so skip.
global_config = CORE.raw_config
if global_config is None:
return
missing = {x for x in requirements if x not in global_config}
if missing:
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
raise cv.Invalid(
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
)
"""Check that the components this model needs are configured, see check_model_requirements()."""
check_model_requirements(self.name, self.defaults)
+1 -3
View File
@@ -274,9 +274,7 @@ def _model_schema(config: ConfigType) -> cv.Schema:
cv.Optional(CONF_UPDATE_MODE): update_mode,
# One or more GPIOs driven high during setup to power on the panel
# (e.g. board power-enable rails), before reset and init.
cv.Optional(
CONF_ENABLE_PIN, default=model.get_default(CONF_ENABLE_PIN, [])
): cv.ensure_list(pins.gpio_output_pin_schema),
**display.enable_pin_schema(model, config),
cv.Optional(CONF_RESET_DURATION): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=core.TimePeriod(milliseconds=500)),
+16 -2
View File
@@ -1,3 +1,5 @@
import logging
from esphome import automation, pins
import esphome.codegen as cg
import esphome.config_validation as cv
@@ -15,6 +17,8 @@ from esphome.core import CORE
from esphome.cpp_helpers import gpio_pin_expression
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@max246"]
DOMAIN = "lightwaverf"
@@ -35,10 +39,20 @@ CONFIG_SCHEMA = cv.Schema(
).extend(cv.polling_component_schema("1s"))
def _drop_unused_name(config: ConfigType) -> ConfigType:
# Remove before 2027.5.0
if config.pop(CONF_NAME, None) is not None:
_LOGGER.warning(
"[lightwaverf] 'name' on lightwaverf.send_raw has no effect; remove it. "
"Removed in 2027.5.0"
)
return config
LIGHTWAVE_SEND_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(LIGHTWAVERFComponent),
cv.Required(CONF_NAME): cv.string,
cv.Optional(CONF_NAME): cv.string,
cv.Required(CONF_CODE): cv.All(
[cv.Any(cv.hex_uint8_t)],
cv.Length(min=10),
@@ -47,7 +61,7 @@ LIGHTWAVE_SEND_SCHEMA = cv.Schema(
cv.Optional(CONF_INVERTED, default=False): cv.boolean,
cv.Optional(CONF_PULSE_LENGTH, default=330): cv.int_,
}
)
).add_extra(_drop_unused_name)
automation.register_apply_action(
+33 -8
View File
@@ -28,6 +28,7 @@ from esphome.const import (
CONF_X,
CONF_Y,
)
from esphome.cpp_generator import MockObj
from esphome.cpp_types import FixedVector
from ..automation import action_to_code
@@ -41,6 +42,7 @@ from ..defines import (
CONF_RADIUS,
CONF_SRC,
CONF_START_ANGLE,
LValidator,
addr,
literal,
)
@@ -57,7 +59,7 @@ from ..lv_validation import (
)
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr
from ..schemas import STYLE_PROPS, TEXT_SCHEMA, point_schema, remap_property
from ..types import LvType, ObjUpdateAction, lv_point_precise_t
from ..types import LvType, ObjUpdateAction, lv_gradient_t, lv_point_precise_t
from . import Widget, WidgetType, get_widgets
from .img import CONF_IMAGE
@@ -221,7 +223,6 @@ RECT_PROPS = {
"radius",
"bg_opa",
"bg_color",
"bg_grad",
"border_color",
"border_width",
"border_opa",
@@ -239,6 +240,30 @@ RECT_PROPS = {
"shadow_opa",
)
}
RECT_PROPS.update(
bg_grad=LValidator(
cv.use_id(lv_gradient_t), lv_gradient_t, retmapper=lambda v: MockObj(f"*{v}")
)
)
RECT_SCHEMA = {
**{cv.Optional(prop): validator for prop, validator in RECT_PROPS.items()},
cv.Optional(CONF_OPA): cv.invalid(
"Rectangles have no single opacity; use bg_opa, border_opa, outline_opa or shadow_opa"
),
}
POLYGON_PROPS = {
p: RECT_PROPS[p] for p in ("border_color", "border_width", "border_opa")
}
POLYGON_SCHEMA = {
**{cv.Optional(prop): validator for prop, validator in POLYGON_PROPS.items()},
**{
cv.Optional(prop): cv.invalid(
"Polygons are drawn as an outline; use border_color, border_width and border_opa"
)
for prop in (*RECT_PROPS, CONF_OPA)
if prop not in POLYGON_PROPS
},
}
def _draw_line(layer, dsc, points):
@@ -264,10 +289,10 @@ def _draw_line(layer, dsc, points):
ObjUpdateAction,
cv.Schema(
{
**DRAW_OPA_SCHEMA,
**DRAW_SCHEMA,
cv.Required(CONF_WIDTH): cv.templatable(cv.int_),
cv.Required(CONF_HEIGHT): cv.templatable(cv.int_),
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
**RECT_SCHEMA,
}
),
synchronous=True,
@@ -423,8 +448,8 @@ async def canvas_draw_image(config, action_id, template_arg, args):
LINE_PROPS = {
"width": STYLE_PROPS["line_width"],
"color": STYLE_PROPS["line_color"],
"dash-width": STYLE_PROPS["line_dash_width"],
"dash-gap": STYLE_PROPS["line_dash_gap"],
"dash_width": STYLE_PROPS["line_dash_width"],
"dash_gap": STYLE_PROPS["line_dash_gap"],
"round_start": lv_bool,
"round_end": lv_bool,
}
@@ -474,7 +499,7 @@ async def canvas_draw_line(config, action_id, template_arg, args):
{
cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t),
cv.Required(CONF_POINTS): cv.ensure_list(point_schema),
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
**POLYGON_SCHEMA,
},
).add_extra(_validate_points),
synchronous=True,
@@ -503,7 +528,7 @@ async def canvas_draw_polygon(config, action_id, template_arg, args):
_draw_line(layer, line_dsc, points)
return await draw_to_code(
config, "rect", RECT_PROPS, do_draw_polygon, action_id, template_arg, args
config, "rect", POLYGON_PROPS, do_draw_polygon, action_id, template_arg, args
)
+19 -5
View File
@@ -17,6 +17,7 @@ from esphome.const import (
CONF_WIDTH,
CONF_X,
)
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.cpp_types import nullptr
@@ -64,7 +65,7 @@ from ..lv_validation import (
)
from ..lvcode import LambdaContext, LocalVariable, lv, lv_add, lv_expr, lv_obj
from ..schemas import STATE_SCHEMA
from ..styles import LVStyle
from ..styles import LVStyle, create_style
from ..types import (
LV_EVENT,
LvCompound,
@@ -100,6 +101,12 @@ CONF_SCALES = "scales"
CONF_STRIDE = "stride"
CONF_TICK_STYLE = "tick_style"
def _arc_style_name(indicator_id: str) -> str:
"""Name of the style variable generated for an arc indicator."""
return f"meter_arc_{indicator_id}_style"
# LVGL 9.4 Migration: Use scale widget instead of removed meter widget
#
# The lv_meter widget was removed in LVGL 9.4 and replaced with the more
@@ -423,11 +430,9 @@ class MeterType(WidgetType):
get_warnings().add(
"The 'r_mod' indicator property is not supported in LVGL 9.x and will be ignored."
)
arc_style = LVStyle(f"meter_arc_{iid.id}", props)
style_var = await create_style(_arc_style_name(iid.id), props)
tvar = cg.Pvariable(iid, lv_expr.scale_add_section(scale_var))
lv.scale_section_set_style(
tvar, LV_PART.MAIN, await arc_style.get_var()
)
lv.scale_section_set_style(tvar, LV_PART.MAIN, style_var)
lw = Widget.create(iid, tvar, arc_indicator_type)
lw.parent = scale_var
await set_indicator_values(scale_var, lw, v)
@@ -610,6 +615,15 @@ async def indicator_update_to_code(config, action_id, template_arg, args):
async def set_value(w: Widget):
await set_indicator_values(w.parent, w, config)
if (opa := config.get(CONF_OPA)) is None:
return
opa = await opacity.process(opa)
if w.type is arc_indicator_type:
style = await cg.get_variable(ID(_arc_style_name(str(w.var))))
lv.style_set_arc_opa(style, opa)
lv_obj.invalidate(w.parent)
else:
lv_obj.set_style_opa(w.obj, opa, LV_PART.MAIN)
return await action_to_code(widget, set_value, action_id, template_arg, args)
+56 -20
View File
@@ -4,10 +4,12 @@
from collections.abc import Callable
import functools
import importlib.util
from typing import Any, Self
import voluptuous as vol
from esphome import pins
from esphome.components.const import CONF_COLOR_DEPTH
from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns
import esphome.config_validation as cv
@@ -23,6 +25,7 @@ from esphome.const import (
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_MODEL,
CONF_NUMBER,
CONF_OFFSET_HEIGHT,
CONF_OFFSET_WIDTH,
CONF_PAGES,
@@ -306,6 +309,57 @@ def model_schema_extractor(
return decorate
def _is_pin_expander(key: str) -> bool:
"""Check if a pin config key names a component that provides pins, e.g. an I/O expander."""
from esphome import loader
if key not in pins.PIN_SCHEMA_REGISTRY:
if importlib.util.find_spec(f"esphome.components.{key}") is None:
return False
# Loading the component registers its pin schema. If it fails to load (the
# loader logs why), still require it rather than quietly dropping it.
if loader.get_component(key) is None:
return True
return key in pins.PIN_SCHEMA_REGISTRY
def _pin_expanders(value: Any) -> set[str]:
"""Find the pin expander components used by any pin configs in a model's defaults."""
if isinstance(value, (list, tuple)):
return set().union(*(_pin_expanders(v) for v in value))
if not isinstance(value, dict):
return set()
found = set().union(*(_pin_expanders(v) for v in value.values()))
if CONF_NUMBER in value:
found.update(k for k in value if k != CONF_NUMBER and _is_pin_expander(k))
return found
def check_model_requirements(name: str, defaults: dict[str, Any]) -> None:
"""
Raise a friendly error if any component a display model requires is not configured.
These are the components in its ``requires`` default plus any pin expander used by its
default pins, which is needed even if the user config overrides those pins.
This runs during schema validation (before ID references are resolved) so that a
model whose default pins live on a pin expander reports the missing expander clearly
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
"""
# ``raw_config`` is populated before any component schema runs during a real
# validation, so presence of a required component is simply a top-level key.
# When it is absent (e.g. a unit test that invokes the schema directly) there
# is no config to check against, so skip.
global_config = CORE.raw_config
if global_config is None:
return
requirements = set(defaults.get("requires", ())) | _pin_expanders(defaults)
if missing := {x for x in requirements if x not in global_config}:
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
raise cv.Invalid(
f"{name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
)
class DriverChip:
"""
A class representing a MIPI DBI driver chip model.
@@ -671,32 +725,14 @@ class DriverChip:
def check_requirements(self) -> None:
"""
Raise a friendly error if any component this model requires is not configured.
This runs during schema validation (before ID references are resolved) so that a
model whose default pins live on a pin expander reports the missing expander clearly
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
Check that the components this model needs are configured, see check_model_requirements().
Also logs a warning if the model is deprecated.
"""
if deprecation_reason := self.get_default("deprecation_reason"):
LOGGER.warning(
"Display model %s is deprecated: %s", self.name, deprecation_reason
)
if requirements := self.get_default("requires", set()):
# ``raw_config`` is populated before any component schema runs during a real
# validation, so presence of a required component is simply a top-level key.
# When it is absent (e.g. a unit test that invokes the schema directly) there
# is no config to check against, so skip.
global_config = CORE.raw_config
if global_config is None:
return
missing = {x for x in requirements if x not in global_config}
if missing:
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
raise cv.Invalid(
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
)
check_model_requirements(self.name, self.defaults)
def requires_buffer(config) -> bool:
+8 -11
View File
@@ -85,7 +85,7 @@ COLOR_DEPTHS = {
}
def model_schema(config: ConfigType) -> cv.All:
def model_schema(config: ConfigType) -> cv.Schema:
model = MODELS[config[CONF_MODEL].upper()]
transform = model.transform_schema()
# CUSTOM model will need to provide a custom init sequence
@@ -97,16 +97,14 @@ def model_schema(config: ConfigType) -> cv.All:
# Dimensions are optional if the model has a default width
cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required
pixel_modes = (PIXEL_MODE_16BIT, PIXEL_MODE_24BIT, "16", "24")
schema = display.FULL_DISPLAY_SCHEMA.extend(
return display.FULL_DISPLAY_SCHEMA.extend(
{
model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
cv.GenerateID(): cv.declare_id(MipiDsi),
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
model.get_default(CONF_DRAW_ROUNDING, 1)
),
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
pins.gpio_output_pin_schema
),
**display.enable_pin_schema(model, config),
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(COLOR_ORDERS, upper=True),
model.option(CONF_DRAW_ROUNDING, 2): power_of_two,
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
@@ -140,11 +138,6 @@ def model_schema(config: ConfigType) -> cv.All:
model.option(CONF_VSYNC_FRONT_PORCH): cv.int_,
}
)
return cv.All(
schema,
cv.only_on_esp32,
only_on_variant(supported=[VARIANT_ESP32P4]),
)
@model_schema_extractor(MODELS, model_schema)
@@ -155,9 +148,13 @@ def _config_schema(config: ConfigType) -> ConfigType:
},
extra=cv.ALLOW_EXTRA,
)(config)
config = model_schema(config)(config)
config = cv.All(cv.only_on_esp32, only_on_variant(supported=[VARIANT_ESP32P4]))(
config
)
# Check before the schema, which would fail on default pins of a missing expander
model = MODELS[config[CONF_MODEL].upper()]
model.check_requirements()
config = model_schema(config)(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
@@ -14,7 +14,7 @@ DsiDriverChip(
pclk_frequency="60MHz",
lane_bit_rate="730Mbps",
color_order="RGB",
requires={"psram", "pi4ioe5v6408"},
requires={"psram"},
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
initsequence=[
(0xFF, 0x98, 0x81, 0x01), # Select Page 1
@@ -69,7 +69,7 @@ TAB5_ST7123 = DsiDriverChip(
pclk_frequency="80MHz",
lane_bit_rate="960Mbps",
color_order="RGB",
requires={"psram", "pi4ioe5v6408"},
requires={"psram"},
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
initsequence=[
(0x01,),
@@ -122,7 +122,7 @@ DsiDriverChip(
pclk_frequency="70MHz",
lane_bit_rate="965Mbps",
color_order="RGB",
requires={"psram", "pi4ioe5v6408"},
requires={"psram"},
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
initsequence=[
(0x01,),
+1 -1
View File
@@ -17,7 +17,7 @@ DsiDriverChip(
color_order="RGB",
enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}],
reset_pin={"xl9535": None, "number": 2},
requires={"psram", "xl9535"},
requires={"psram"},
initsequence=(
(0xE0, 0x00),
(0xE1, 0x93),
+3 -5
View File
@@ -154,9 +154,7 @@ def model_schema(config: ConfigType) -> cv.Schema:
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
model.get_default(CONF_DRAW_ROUNDING, 1)
),
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
pins.gpio_output_pin_schema
),
**display.enable_pin_schema(model, config),
model.option(CONF_COLOR_ORDER, MODE_RGB): cv.enum(COLOR_ORDERS, upper=True),
model.option(CONF_DRAW_ROUNDING, 2): power_of_two,
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
@@ -226,14 +224,14 @@ def _config_schema(config: ConfigType) -> ConfigType:
},
extra=cv.ALLOW_EXTRA,
)(config)
schema = model_schema(config)
config = cv.All(
schema,
cv.only_on_esp32,
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4, VARIANT_ESP32S31]),
)(config)
# Check before the schema, which would fail on default pins of a missing expander
model = MODELS[config[CONF_MODEL].upper()]
model.check_requirements()
config = model_schema(config)(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
+2 -2
View File
@@ -9,7 +9,7 @@ ST7701S(
spi_mode="MODE3",
cs_pin={"xl9535": None, "number": 17},
reset_pin={"xl9535": None, "number": 5},
requires={"psram", "xl9535"},
requires={"psram"},
hsync_pin=39,
vsync_pin=40,
pclk_pin=41,
@@ -57,7 +57,7 @@ t_rgb = ST7701S(
invert_colors=False,
spi_mode="MODE3",
cs_pin={"xl9535": None, "number": 3},
requires={"psram", "xl9535"},
requires={"psram"},
de_pin=45,
hsync_pin=47,
vsync_pin=41,
@@ -115,7 +115,7 @@ st7701s.extend(
vsync_pulse_width=8,
vsync_back_porch=20,
cs_pin={"pca9554": None, "number": 4},
requires={"psram", "pca9554"},
requires={"psram"},
de_pin=18,
hsync_pin=16,
vsync_pin=17,
@@ -12,7 +12,7 @@ wave_4_3 = RgbDriverChip(
pclk_frequency="16MHz",
reset_pin={"ch422g": None, "number": 3},
enable_pin={"ch422g": None, "number": 2},
requires={"psram", "ch422g"},
requires={"psram"},
de_pin=5,
hsync_pin={"number": 46, "ignore_strapping_warning": True},
vsync_pin={"number": 3, "ignore_strapping_warning": True},
+1 -3
View File
@@ -197,9 +197,7 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema:
{
cv.GenerateID(): cv.declare_id(MipiSpi),
cv_dimensions(CONF_DIMENSIONS): dimension_schema(1),
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
pins.gpio_output_pin_schema
),
**display.enable_pin_schema(model, config),
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(
COLOR_ORDERS, upper=True
),
@@ -22,7 +22,6 @@ DriverChip(
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
use_axis_flips=True,
reset_delay=20,
requires={"pca9554"},
initsequence=(
delay(100),
(0xFF, 0x20, 0x10, 0x10),
+10 -16
View File
@@ -2,24 +2,18 @@ import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.types import ConfigType
from .. import Number, number_ns
from .. import Number
NumberSensor = number_ns.class_("NumberSensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
sensor.sensor_schema(NumberSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
}
)
async def to_code(config):
source = await cg.get_variable(config[CONF_SOURCE_ID])
var = await sensor.new_sensor(config, source)
await cg.register_component(var, config)
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
# Number has the same float state callback as Sensor
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "number_sensor.h"
#include "esphome/core/log.h"
namespace esphome::number {
ESPHOME_LOG_TAG(TAG, "number.sensor");
void NumberSensor::setup() {
this->source_->add_on_state_callback([this](float value) { this->publish_state(value); });
if (this->source_->has_state())
this->publish_state(this->source_->state);
}
void NumberSensor::dump_config() { LOG_SENSOR("", "Number Sensor", this); }
} // namespace esphome::number
@@ -1,19 +0,0 @@
#pragma once
#include "../number.h"
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::number {
class NumberSensor final : public sensor::Sensor, public Component {
public:
explicit NumberSensor(Number *source) : source_(source) {}
void setup() override;
void dump_config() override;
protected:
Number *source_;
};
} // namespace esphome::number
@@ -6,9 +6,7 @@ import esphome.codegen as cg
from esphome.components.const import CONF_MAX_PACKET_SIZE
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core import ID
from esphome.cpp_generator import MockObj, MockObjClass
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import layers
@@ -36,7 +34,6 @@ LINK_LAYER_SCHEMAS = stack_schema()
__all__ = [
"CONF_MAX_PACKET_SIZE",
"CONF_PACKET_INTERFACE",
"interface_layers",
"PacketInterface",
"new_packet_interface",
"packet_interface_ns",
@@ -62,14 +59,6 @@ def _validate_layers(config: ConfigType) -> ConfigType:
return config
def interface_layers(interface_id: ID) -> ConfigType | None:
"""During final validation, the configuration holding the layers of the packet interface with this id."""
for interface in fv.full_config.get().get(DOMAIN, []):
if interface[CONF_ID].id == interface_id.id:
return interface
return None
def packet_interface_schema(
class_: MockObjClass,
default_max_packet_size: int,
@@ -8,6 +8,8 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <new>
#ifdef USE_ESP_IDF
#include "esp_task_wdt.h"
#endif
@@ -94,7 +96,38 @@ static int draw_callback(JPEGDRAW *jpeg) {
return 1;
}
JpegDecoder::JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {
RAMAllocator<JPEGDEC> allocator;
JPEGDEC *jpeg = allocator.allocate(1);
if (jpeg == nullptr) {
ESP_LOGE(TAG, "Failed to allocate memory for the JPEG decoder");
return;
}
this->jpeg_ = new (jpeg) JPEGDEC();
}
JpegDecoder::~JpegDecoder() {
if (this->jpeg_ != nullptr) {
this->jpeg_->~JPEGDEC();
RAMAllocator<JPEGDEC> allocator;
allocator.deallocate(this->jpeg_, 1);
}
}
int JpegDecoder::prepare(size_t expected_size) {
// Check before the base prepare() so a failure never leaves an active session
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
return ImageDecoder::prepare(expected_size);
}
int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
// JPEG decoder requires complete data
// If we know the expected size, wait for it
if (this->expected_size_ > 0 && size < this->expected_size_) {
@@ -105,22 +138,22 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
// If size unknown, try to decode and see if it's valid
// The JPEGDEC library will fail gracefully if data is incomplete
if (!this->jpeg_.openRAM(buffer, size, draw_callback)) {
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
if (!this->jpeg_->openRAM(buffer, size, draw_callback)) {
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError());
return DECODE_ERROR_INVALID_TYPE;
}
const int err = this->start_();
if (err != 0)
return err;
if (!this->jpeg_.decode(0, 0, this->scale_option_()))
if (!this->jpeg_->decode(0, 0, this->scale_option_()))
return this->fail_();
this->decoded_bytes_ = size;
this->jpeg_.close();
this->jpeg_->close();
return size;
}
int JpegDecoder::start_() {
auto jpeg_type = this->jpeg_.getJPEGType();
auto jpeg_type = this->jpeg_->getJPEGType();
if (jpeg_type == JPEG_MODE_INVALID) {
ESP_LOGE(TAG, "Unsupported JPEG image");
return DECODE_ERROR_INVALID_TYPE;
@@ -128,11 +161,12 @@ int JpegDecoder::start_() {
ESP_LOGE(TAG, "Progressive JPEG images not supported");
return DECODE_ERROR_UNSUPPORTED_FORMAT;
}
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp());
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_->getWidth(), this->jpeg_->getHeight(),
this->jpeg_->getBpp());
this->jpeg_.setUserPointer(this);
const int width = this->jpeg_.getWidth();
const int height = this->jpeg_.getHeight();
this->jpeg_->setUserPointer(this);
const int width = this->jpeg_->getWidth();
const int height = this->jpeg_->getHeight();
// The tables for drawing are only built once the decoded size is known, below
if (!this->resize_image_(width, height)) {
return DECODE_ERROR_OUT_OF_MEMORY;
@@ -155,17 +189,17 @@ int JpegDecoder::start_() {
if (this->direct_rgb565_) {
// JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build
// returns little-endian words for it), so the byte order is applied in draw_rgb565 instead
this->jpeg_.setPixelType(RGB565_LITTLE_ENDIAN);
this->jpeg_->setPixelType(RGB565_LITTLE_ENDIAN);
} else {
this->jpeg_.setPixelType(RGB8888);
this->jpeg_->setPixelType(RGB8888);
}
return 0;
}
int JpegDecoder::fail_() {
const bool read_failed = this->source_ != nullptr && this->source_failed_;
const int error = this->jpeg_.getLastError();
this->jpeg_.close();
const int error = this->jpeg_->getLastError();
this->jpeg_->close();
this->source_ = nullptr;
if (read_failed) {
ESP_LOGE(TAG, "Reading the image failed");
@@ -189,7 +223,7 @@ int JpegDecoder::fail_() {
void JpegDecoder::reset() {
// A decode from a source that was given up part way still has the image open
if (this->source_ != nullptr) {
this->jpeg_.close();
this->jpeg_->close();
this->source_ = nullptr;
}
ImageDecoder::reset();
@@ -218,6 +252,10 @@ int32_t JpegDecoder::source_seek(JPEGFILE *file, int32_t position) {
}
int JpegDecoder::begin_source(DecodeSource *source) {
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
if (this->expected_size_ == 0 || this->expected_size_ > INT32_MAX) {
ESP_LOGE(TAG, "Image size %zu cannot be read from a source", this->expected_size_);
return DECODE_ERROR_INVALID_TYPE;
@@ -228,19 +266,19 @@ int JpegDecoder::begin_source(DecodeSource *source) {
}
this->source_ = source;
this->source_failed_ = false;
if (!this->jpeg_.open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
if (!this->jpeg_->open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
// The caller may recover, for example by reading the whole image first, so it reports the failure
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError());
this->source_ = nullptr;
return this->source_failed_ ? DECODE_ERROR_READ : DECODE_ERROR_INVALID_TYPE;
}
const int err = this->start_();
if (err != 0) {
this->jpeg_.close();
this->jpeg_->close();
this->source_ = nullptr;
return err;
}
if (!this->jpeg_.decodeBegin(0, 0, this->scale_option_()))
if (!this->jpeg_->decodeBegin(0, 0, this->scale_option_()))
return this->fail_();
return 0;
}
@@ -248,7 +286,7 @@ int JpegDecoder::begin_source(DecodeSource *source) {
int HOT JpegDecoder::decode_step() {
if (this->source_ == nullptr)
return DECODE_ERROR_INVALID_TYPE;
const int result = this->jpeg_.decodeRows(1);
const int result = this->jpeg_->decodeRows(1);
// JPEGDEC may decode on with the missing data, so check the read before its result
if (this->source_failed_)
return this->fail_();
@@ -259,7 +297,7 @@ int HOT JpegDecoder::decode_step() {
if (result == 0)
return this->fail_();
this->decoded_bytes_ = this->expected_size_;
this->jpeg_.close();
this->jpeg_->close();
this->source_ = nullptr;
return DECODE_STEP_DONE;
}
@@ -18,9 +18,10 @@ class JpegDecoder : public ImageDecoder {
*
* @param image The RuntimeImage to decode the stream into.
*/
JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {}
~JpegDecoder() override {}
JpegDecoder(RuntimeImage *image);
~JpegDecoder() override;
int prepare(size_t expected_size) override;
int HOT decode(uint8_t *buffer, size_t size) override;
void reset() override;
@@ -46,7 +47,8 @@ class JpegDecoder : public ImageDecoder {
static int32_t source_seek(JPEGFILE *file, int32_t position);
static void source_close(void *handle) {}
JPEGDEC jpeg_{};
/// About 18 KB of decoder state, allocated like pngle in PngDecoder: PSRAM first, nullptr if out of memory
JPEGDEC *jpeg_{nullptr};
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
bool source_failed_{false};
+5
View File
@@ -124,6 +124,7 @@ from esphome.core import (
)
from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH
from esphome.core.entity_helpers import (
FollowEntity,
SubEntities,
entity_duplicate_validator,
lazy_load_validator,
@@ -1109,6 +1110,10 @@ def sub_sensors(
return SubEntities(new_sensor, config, parent)
# Makes a plain sensor follow another sensor: follow_sensor(entity, source)
follow_sensor = FollowEntity("add_on_state_callback", ((cg.float_, "x"),), "x")
SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All(
{
cv.Required(CONF_ID): cv.use_id(Sensor),
@@ -2,29 +2,22 @@ import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.core.entity_helpers import FollowEntity
from esphome.types import ConfigType
from .. import Switch, switch_ns
from .. import Switch
CODEOWNERS = ["@ssieb"]
SwitchBinarySensor = switch_ns.class_(
"SwitchBinarySensor", binary_sensor.BinarySensor, cg.Component
_FOLLOW_SWITCH = FollowEntity("add_on_state_callback", ((cg.bool_, "x"),), "x")
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(binary_sensor.BinarySensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Switch),
}
)
CONFIG_SCHEMA = (
binary_sensor.binary_sensor_schema(SwitchBinarySensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Switch),
}
)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = await binary_sensor.new_binary_sensor(config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
_FOLLOW_SWITCH(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,15 +0,0 @@
#include "switch_binary_sensor.h"
#include "esphome/core/log.h"
namespace esphome::switch_ {
ESPHOME_LOG_TAG(TAG, "switch.binary_sensor");
void SwitchBinarySensor::setup() {
source_->add_on_state_callback([this](bool value) { this->publish_state(value); });
this->publish_state(source_->state);
}
void SwitchBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Switch Binary Sensor", this); }
} // namespace esphome::switch_
@@ -1,19 +0,0 @@
#pragma once
#include "../switch.h"
#include "esphome/core/component.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
namespace esphome::switch_ {
class SwitchBinarySensor final : public binary_sensor::BinarySensor, public Component {
public:
void set_source(Switch *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
Switch *source_;
};
} // namespace esphome::switch_
@@ -4,23 +4,16 @@ import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.types import ConfigType
from .. import Text, text_ns
from .. import Text
TextTextSensor = text_ns.class_("TextTextSensor", text_sensor.TextSensor, cg.Component)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema(TextTextSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
}
)
async def to_code(config: ConfigType) -> None:
source = await cg.get_variable(config[CONF_SOURCE_ID])
var = await text_sensor.new_text_sensor(config, source)
await cg.register_component(var, config)
var = await text_sensor.new_text_sensor(config)
# Text has the same string state callback as TextSensor
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "text_text_sensor.h"
#include "esphome/core/log.h"
namespace esphome::text {
ESPHOME_LOG_TAG(TAG, "text.text_sensor");
void TextTextSensor::setup() {
this->source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
if (this->source_->has_state())
this->publish_state(this->source_->state);
}
void TextTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Text Text Sensor", this); }
} // namespace esphome::text
@@ -1,19 +0,0 @@
#pragma once
#include "../text.h"
#include "esphome/core/component.h"
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome::text {
class TextTextSensor final : public text_sensor::TextSensor, public Component {
public:
explicit TextTextSensor(Text *source) : source_(source) {}
void setup() override;
void dump_config() override;
protected:
Text *source_;
};
} // namespace esphome::text
@@ -22,6 +22,7 @@ from esphome.const import (
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
FollowEntity,
SubEntities,
entity_duplicate_validator,
queue_entity_register,
@@ -238,6 +239,12 @@ def sub_text_sensors(
return SubEntities(new_text_sensor, config, parent)
# Makes a plain text sensor follow another text sensor: follow_text_sensor(entity, source)
follow_text_sensor = FollowEntity(
"add_on_state_callback", ((cg.std_string_ref.operator("const"), "x"),), "x"
)
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
cg.add_global(text_sensor_ns.using)
+13
View File
@@ -1264,6 +1264,18 @@ class CoreFinalValidateStep(ConfigValidationStep):
fv.full_config.reset(token)
class OptionalPinValidationStep(ConfigValidationStep):
"""Remove optional pins that are also used elsewhere"""
# After the ID pass, and before component final validations which may inspect pin use
priority = -15.0
def run(self, result: Config) -> None:
if result.errors:
return
pins.PIN_SCHEMA_REGISTRY.remove_optional_conflicts(result)
class PinUseValidationCheck(ConfigValidationStep):
"""Check for pin reuse"""
@@ -1437,6 +1449,7 @@ def validate_config(
result.add_validation_step(PrefetchRemoteFilesValidationStep())
result.add_validation_step(IDPassValidationStep())
result.add_validation_step(CoreFinalValidateStep())
result.add_validation_step(OptionalPinValidationStep())
result.add_validation_step(PinUseValidationCheck())
result.add_validation_step(RemoveReferenceValidationStep())
+6 -3
View File
@@ -416,10 +416,13 @@ void Application::teardown_components(uint32_t timeout_ms) {
}
}
void Application::add_looping_components_by_state_(bool match_loop_done) {
void Application::add_looping_components_by_state_(bool inactive) {
for (auto *obj : this->components_) {
if (obj->has_overridden_loop() &&
((obj->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) == match_loop_done) {
if (!obj->has_overridden_loop()) {
continue;
}
const uint8_t state = obj->get_component_state() & COMPONENT_STATE_MASK;
if ((state == COMPONENT_STATE_LOOP_DONE || state == COMPONENT_STATE_FAILED) == inactive) {
this->looping_components_.push_back(obj);
}
}
+5 -6
View File
@@ -433,16 +433,15 @@ class Application {
// FixedVector capacity was pre-initialized by codegen with the exact count
// of components that override loop(), computed at C++ compile time.
// Add all components with loop override that aren't already LOOP_DONE
// Some components (like logger) may call disable_loop() during initialization
// before setup runs, so we need to respect their LOOP_DONE state
// Add all components with loop override that aren't already LOOP_DONE or FAILED
// Some components (like logger) may call disable_loop() during initialization,
// and some call mark_failed() from their constructor, before setup runs
this->add_looping_components_by_state_(false);
this->looping_components_active_end_ = this->looping_components_.size();
// Then add any components that are already LOOP_DONE to the inactive section
// This handles components that called disable_loop() during initialization
// Then add any components that are already LOOP_DONE or FAILED to the inactive section
this->add_looping_components_by_state_(true);
}
void add_looping_components_by_state_(bool match_loop_done);
void add_looping_components_by_state_(bool inactive);
// These methods are called by Component::disable_loop() and Component::enable_loop()
// Components should not call these directly - use this->disable_loop() or this->enable_loop()
+1 -1
View File
@@ -169,7 +169,7 @@ struct Color {
uint8_t r = rand >> 16;
uint8_t g = rand >> 8;
uint8_t b = rand >> 0;
const uint16_t max_rgb = std::max({r, g, b});
const uint16_t max_rgb = std::max({r, g, b, uint8_t(1)});
return Color(uint8_t((uint16_t(r) * 255U / max_rgb)), uint8_t((uint16_t(g) * 255U / max_rgb)),
uint8_t((uint16_t(b) * 255U / max_rgb)), w);
}
-2
View File
@@ -76,8 +76,6 @@
#define USE_DISPLAY
#define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1
#define DUCO_DEVICE_COUNT 4
#define ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT 1
#define ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT 1
#define USE_EMONTX_VERSION
#define USE_EMONTX_VERSION_TEXT_SENSOR
#define USE_ENTITY_DEVICE_CLASS
+18
View File
@@ -45,6 +45,7 @@ from esphome.core.config import (
UNIT_OF_MEASUREMENT_MAX_LENGTH,
)
from esphome.cpp_generator import (
LambdaExpression,
MockObj,
MockObjClass,
RawExpression,
@@ -519,6 +520,23 @@ class ApplyEntity:
_APPLY_DOMAIN = "entity_apply"
@dataclass(frozen=True, slots=True)
class FollowEntity:
"""A state callback, its parameters and the value an entity republishes from it."""
callback: str
params: tuple[tuple[SafeExpType, str], ...]
value: str
def __call__(self, entity: MockObj, source: MockObj, *args: SafeExpType) -> None:
"""Make ``entity`` publish every state of ``source``; ``args`` precede the callback."""
publish = f"{parent_ref(entity).publish_state(RawExpression(self.value))};"
callback = LambdaExpression(
[publish], list(self.params), capture="", return_type=cg.void
)
add(getattr(source, self.callback)(*args, callback))
@dataclass(frozen=True, slots=True)
class SubEntities:
"""Create the child entity configured under key, set parent (if bound) and pass it to setter.
+7 -7
View File
@@ -483,13 +483,6 @@ uint32_t HOT Scheduler::call(uint32_t now) {
// Not reached timeout yet, done for this call
break;
}
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
if (this->is_item_failed_(item)) {
LockGuard guard{this->lock_};
this->recycle_item_main_loop_(this->pop_raw_locked_());
continue;
}
// Check if item is marked for removal
// This handles two cases:
// 1. Item was marked for removal after cleanup_() but before we got here
@@ -514,6 +507,13 @@ uint32_t HOT Scheduler::call(uint32_t now) {
}
#endif
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
if (this->is_item_failed_(item)) {
LockGuard guard{this->lock_};
this->recycle_item_main_loop_(this->pop_raw_locked_());
continue;
}
#ifdef ESPHOME_DEBUG_SCHEDULER
{
SchedulerNameLog name_log;
+2
View File
@@ -331,6 +331,8 @@ class FloatLiteral(Literal):
def __str__(self):
if math.isnan(self.f):
return "NAN"
if math.isinf(self.f):
return "INFINITY" if self.f > 0 else "-INFINITY"
return f"{self.f}f"
+7 -1
View File
@@ -466,9 +466,15 @@ def receive_exactly(
while len(data) < amount:
try:
data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator]
chunk = recv_decode(sock, amount - len(data), decode=decode)
except OSError as err:
raise OTANetworkError(f"receiving {msg}: {err}") from err
if not chunk:
raise OTANetworkError(
f"receiving {msg}: device closed connection after "
f"{len(data)} of {amount} bytes"
)
data += chunk # type: ignore[operator]
return data
+108 -10
View File
@@ -1,8 +1,10 @@
from collections.abc import Callable, Hashable
from contextvars import ContextVar
from functools import reduce
import logging
from logging import Logger
import operator
from typing import Any
from typing import TYPE_CHECKING, Any
import esphome.config_validation as cv
from esphome.const import (
@@ -21,17 +23,64 @@ from esphome.const import (
)
from esphome.core import CORE, ID
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.schema_extractors import schema_extractor
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType, SafeExpType
if TYPE_CHECKING:
from esphome.config import Config
_LOGGER = logging.getLogger(__name__)
# A use of a pin: the path of the component using it, the pin provider's id and the pin config
PinUse = tuple[list, Any, ConfigType]
# Set while validating pins that should give way to any other use of the same pin
_validating_optional = ContextVar("validating_optional_pins", default=False)
def _remove_value(conf: Any, target: Any) -> bool:
"""Remove the object ``target`` from wherever it is nested in ``conf``."""
items = conf.items() if isinstance(conf, dict) else enumerate(conf)
for key, value in items:
if value is target:
del conf[key]
return True
if isinstance(value, (dict, list)) and _remove_value(value, target):
return True
return False
class PinRegistry(dict):
def __init__(self):
super().__init__()
self.pins_used = {}
# Entries of pins_used that give way to other uses, keyed by id()
self.optional_pins = {}
def reset(self):
self.pins_used = {}
self.optional_pins = {}
def optional(self, validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
"""
Wrap a pin validator so the pins it validates give way to any other use of the
same pin. Instead of the pin being reported as used in multiple places,
remove_optional_conflicts() removes it from the config, unless every other use
sets allow_other_uses, in which case the pin is shared. Use this for pins a
component supplies as defaults that a user may want to take over.
"""
@schema_extractor("schema")
def wrapper(value: Any) -> Any:
if value == SCHEMA_EXTRACT:
return validator
token = _validating_optional.set(True)
try:
return validator(value)
finally:
_validating_optional.reset(token)
return wrapper
def get_count(self, key, id, number):
"""
@@ -86,7 +135,10 @@ class PinRegistry(dict):
self.pins_used[pin_key] = []
# client_id identifies the instance of the providing component
client_id = result.get(key)
self.pins_used[pin_key].append((path_context.get(), client_id, result))
entry = (path_context.get(), client_id, result)
self.pins_used[pin_key].append(entry)
if _validating_optional.get():
self.optional_pins[id(entry)] = entry
# return the validated pin config
return result
@@ -110,23 +162,69 @@ class PinRegistry(dict):
key = self.get_key(key) if isinstance(key, dict) else key
return self[key][0]
def final_validate(self, fconf):
"""
Run the final validation for all pins, and check for reuse
:param fconf: The full config
"""
def _grouped(self) -> dict[tuple, list[PinUse]]:
"""Group pin uses by (schema key, resolved provider id, pin number)."""
# Regroup by (schema key, resolved provider id, pin number) rather than
# the validate()-time bucket, so the same physical pin reached via an
# explicit id and via {address: ...} (or an omitted id) is recognized as
# one reuse group regardless of which syntax was used to reference the hub.
grouped: dict[tuple, list] = {}
grouped: dict[tuple, list[PinUse]] = {}
for (key, _, number), pin_list in self.pins_used.items():
for entry in pin_list:
client_id = entry[1]
provider_id = client_id.id if isinstance(client_id, ID) else client_id
grouped.setdefault((key, provider_id, number), []).append(entry)
return grouped
for (key, _, _), pin_list in grouped.items():
def remove_optional_conflicts(self, fconf: "Config") -> None:
"""
Remove optional pins that are also used elsewhere from the config, or mark them
as shared if every other use sets allow_other_uses. This runs once ids are resolved and before the components' final validation,
so those only see the pins that will be used.
:param fconf: The full config
"""
if not self.optional_pins:
return
for (key, provider_id, _), pin_list in self._grouped().items():
optional = [e for e in pin_list if id(e) in self.optional_pins]
others = [e for e in pin_list if id(e) not in self.optional_pins]
if not optional or not others:
continue
if all(e[2].get(CONF_ALLOW_OTHER_USES, False) for e in others):
# The other uses ask to share the pin, so share it with the optional ones too
for _, _, pin_config in optional:
pin_config[CONF_ALLOW_OTHER_USES] = True
continue
for entry in optional:
self._remove_optional(fconf, key, provider_id, entry)
def _remove_optional(
self, fconf: "Config", key: str, provider_id: str | None, entry: PinUse
) -> None:
"""
Remove an optional pin from the config and from the pins used. If it cannot be
found in the config it is kept, so final_validate() reports the conflict.
"""
pin_path, _, pin_config = entry
if not _remove_value(fconf.get_config_for_path(pin_path), pin_config):
return
for pin_list in self.pins_used.values():
pin_list[:] = [e for e in pin_list if e is not entry]
del self.optional_pins[id(entry)]
pin = f"{key} {provider_id} pin" if provider_id is not None else "Pin"
_LOGGER.info(
"%s %s is used elsewhere, so is not applied as a default for %s",
pin,
pin_config[CONF_NUMBER],
"->".join(str(p) for p in pin_path),
)
def final_validate(self, fconf):
"""
Run the final validation for all pins, and check for reuse
:param fconf: The full config
"""
for (key, _, _), pin_list in self._grouped().items():
count = len(pin_list) # number of places same pin used.
final_val_fun = self[key][2] # final validation function
for pin_path, client_id, pin_config in pin_list:
@@ -0,0 +1,17 @@
esphome:
name: test
esp32:
board: esp32-s3-devkitc-1
psram:
mode: octal
spi:
clk_pin: GPIO7
mosi_pin: GPIO9
display:
- platform: epaper_spi
id: epaper_display
model: seeed-reterminal-sticky
@@ -0,0 +1,23 @@
esphome:
name: test
esp32:
board: esp32-s3-devkitc-1
psram:
mode: octal
spi:
clk_pin: GPIO7
mosi_pin: GPIO9
# Takes over the model's default enable pin
switch:
- platform: gpio
id: panel_power
pin: GPIO47
display:
- platform: epaper_spi
id: epaper_display
model: seeed-reterminal-sticky
@@ -0,0 +1,46 @@
"""Tests for applying the default enable pins of epaper_spi models."""
from collections.abc import Callable
from pathlib import Path
import re
def _enable_pin_numbers(main_cpp: str) -> list[int]:
"""Get the GPIO numbers passed to set_enable_pins() in the generated code."""
match = re.search(r"epaper_display->set_enable_pins\(\{(.*)\}\);", main_cpp)
if match is None:
return []
numbers = []
for pin_id in match.group(1).split(", "):
pin = re.search(rf"{pin_id}->set_pin\(::GPIO_NUM_(\d+)\);", main_cpp)
assert pin is not None
numbers.append(int(pin.group(1)))
return numbers
def test_default_enable_pin(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The model's enable pin is applied when it is not used elsewhere."""
main_cpp = generate_main(component_config_path("enable_pin_default.yaml"))
assert _enable_pin_numbers(main_cpp) == [47]
def test_default_enable_pin_used_elsewhere(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A default enable pin used by a switch is left out instead of failing validation."""
main_cpp = generate_main(component_config_path("enable_pin_used_elsewhere.yaml"))
assert _enable_pin_numbers(main_cpp) == []
assert "panel_power->set_pin(" in main_cpp
def test_user_enable_pins(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Enable pins given in the config are applied as given."""
main_cpp = generate_main(component_config_path("enable_pin_test.yaml"))
assert _enable_pin_numbers(main_cpp) == [25, 26]
@@ -0,0 +1,26 @@
esphome:
name: it8951-enable-pins
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
psram:
mode: octal
spi:
clk_pin: 7
mosi_pin: 9
miso_pin: 8
# Takes over the model's default 1.8V logic supply enable pin
switch:
- platform: gpio
id: logic_supply
pin: GPIO21
display:
- platform: it8951
id: epaper_display
model: seeed-reterminal-e1003
+17
View File
@@ -1,6 +1,8 @@
"""Tests for it8951 configuration validation."""
from collections.abc import Callable
from pathlib import Path
import re
from typing import Any
from esphome.components.const import CONF_HOLD_STATE
@@ -26,3 +28,18 @@ def test_reterminal_e1003_enable_pins_hold_state(
enable_pins = config[CONF_ENABLE_PIN]
assert [pin[CONF_NUMBER] for pin in enable_pins] == [21, 11]
assert all(pin[CONF_HOLD_STATE] for pin in enable_pins)
def test_reterminal_e1003_enable_pin_used_elsewhere(
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
) -> None:
"""A default enable pin used by a switch is left out, and the other is still applied."""
main_cpp = generate_main(component_fixture_path("enable_pins.yaml"))
match = re.search(r"epaper_display->set_enable_pins\(\{(.*)\}\);", main_cpp)
assert match is not None
pin_ids = match.group(1).split(", ")
assert len(pin_ids) == 1
assert f"{pin_ids[0]}->set_pin(::GPIO_NUM_11);" in main_cpp
assert "logic_supply->set_pin(" in main_cpp
Whitespace-only changes.
@@ -0,0 +1,24 @@
"""lightwaverf.send_raw never used name; it is dropped with a warning until 2027.5.0."""
import pytest
from esphome.automation import ACTION_REGISTRY
import esphome.components.lightwaverf # noqa: F401
from esphome.const import CONF_NAME, CONF_REPEAT
CODE = [0x04, 0x00, 0x00, 0x00, 0x0F, 0x03, 0x0D, 0x09, 0x08, 0x08]
def test_name_is_dropped_with_a_warning(caplog: pytest.LogCaptureFixture) -> None:
config = ACTION_REGISTRY["lightwaverf.send_raw"].schema(
{"code": CODE, CONF_NAME: "Sofa"}
)
assert CONF_NAME not in config
assert "'name' on lightwaverf.send_raw has no effect" in caplog.text
assert "2027.5.0" in caplog.text
def test_name_is_no_longer_required(caplog: pytest.LogCaptureFixture) -> None:
config = ACTION_REGISTRY["lightwaverf.send_raw"].schema({"code": CODE})
assert config[CONF_REPEAT] == 10
assert "lightwaverf" not in caplog.text
@@ -0,0 +1,50 @@
esphome:
name: test-canvas-draw-options
on_boot:
- lvgl.canvas.draw_rectangle:
id: test_canvas
x: 0
y: 0
width: 20
height: 10
bg_grad: test_grad
- lvgl.canvas.draw_line:
id: test_canvas
dash_width: 4
dash_gap: 2
points:
- 0,0
- 10,10
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
gradients:
- id: test_grad
direction: hor
stops:
- color: 0xFF0000
position: 0
- color: 0x0000FF
position: 255
widgets:
- canvas:
id: test_canvas
width: 32
height: 32
@@ -0,0 +1,58 @@
esphome:
name: test-indicator-update-opa
on_boot:
- lvgl.indicator.update:
id: test_arc
opa: 50%
- lvgl.indicator.update:
id: test_needle
value: 5
opa: 25%
- lvgl.indicator.update:
id: test_image
opa: 75%
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
image:
- platform: file
file: image.png
id: meter_image
resize: 8x8
type: binary
lvgl:
widgets:
- meter:
height: 100px
width: 100px
scales:
- id: test_scale
range_from: 0
range_to: 10
indicators:
- arc:
id: test_arc
start_value: 0
end_value: 5
- line:
id: test_needle
- image:
id: test_image
src: meter_image
@@ -0,0 +1,69 @@
"""Canvas draw options must map onto the LVGL 9 draw descriptor fields."""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome import config_validation as cv
from esphome.__main__ import generate_cpp_contents
from esphome.automation import ACTION_REGISTRY
import esphome.components.lvgl.widgets.canvas # noqa: F401 - registers the actions
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "canvas_draw_options_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_rect_gradient_is_copied_by_value(main_cpp: str) -> None:
assert ".bg_grad = *test_grad;" in main_cpp
def test_line_dash_fields(main_cpp: str) -> None:
assert ".dash_width = 4;" in main_cpp
assert ".dash_gap = 2;" in main_cpp
def test_rect_opa_names_the_per_part_keys() -> None:
with pytest.raises(cv.Invalid, match="bg_opa"):
ACTION_REGISTRY["lvgl.canvas.draw_rectangle"].schema(
{"id": "cv", "x": 0, "y": 0, "width": 10, "height": 10, "opa": "50%"}
)
@pytest.mark.parametrize(
"key", ["opa", "bg_color", "bg_grad", "radius", "shadow_width"]
)
def test_polygon_rejects_non_border_keys(key: str) -> None:
with pytest.raises(cv.Invalid, match="drawn as an outline"):
ACTION_REGISTRY["lvgl.canvas.draw_polygon"].schema(
{"id": "cv", "points": [{"x": 0, "y": 0}], key: 1}
)
def test_polygon_accepts_border_keys() -> None:
config = ACTION_REGISTRY["lvgl.canvas.draw_polygon"].schema(
{
"id": "cv",
"points": [{"x": 0, "y": 0}],
"border_color": 0xFF0000,
"border_width": 2,
"border_opa": "50%",
}
)
assert config["border_width"] == 2
@@ -0,0 +1,49 @@
"""lvgl.indicator.update must apply opa to arc, needle and image indicators."""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "indicator_update_opa_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_arc_section_opa_is_applied(main_cpp: str) -> None:
assert (
"lv_style_set_arc_opa(meter_arc_test_arc_style, static_cast<uint8_t>(127.5f));"
in main_cpp
)
assert "lv_obj_invalidate(test_scale);" in main_cpp
def test_needle_opa_is_applied(main_cpp: str) -> None:
assert (
"lv_obj_set_style_opa(test_needle->obj, static_cast<uint8_t>(63.75f), LV_PART_MAIN);"
in main_cpp
)
def test_image_opa_is_applied(main_cpp: str) -> None:
assert (
"lv_obj_set_style_opa(test_image, static_cast<uint8_t>(191.25f), LV_PART_MAIN);"
in main_cpp
)
@@ -0,0 +1,38 @@
esphome:
name: enable-pins-address
esp32:
board: esp32-p4-evboard
framework:
type: esp-idf
psram:
speed: 200MHz
esp_ldo:
- channel: 3
voltage: 2.5V
i2c:
sda: GPIO7
scl: GPIO8
pi4ioe5v6408:
- id: pi4ioe1
address: 0x43
- id: pi4ioe2
address: 0x44
# Takes over the default enable pin on the hub at 0x44, given by address
switch:
- platform: gpio
id: takeover
pin:
pi4ioe5v6408:
address: 0x44
number: 1
display:
- platform: mipi_dsi
id: default_pins
model: TEST-ENABLE-BY-ADDRESS
@@ -0,0 +1,40 @@
"""Tests for applying the default enable pins of mipi_dsi models."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.components.mipi import DriverChip
def test_default_enable_pins_by_address(
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Default enable pins naming a hub by address resolve to that hub, and only
the pin used elsewhere is left out, not the same pin number on the other hub."""
from esphome.components.mipi_dsi.display import MODELS
models = set(DriverChip.models)
model = MODELS["M5STACK-TAB5"].extend(
"TEST-ENABLE-BY-ADDRESS",
enable_pin=[
{"pi4ioe5v6408": {"address": 0x43}, "number": 1},
{"pi4ioe5v6408": {"address": 0x44}, "number": 1},
],
)
for name in set(DriverChip.models) - models:
monkeypatch.delitem(DriverChip.models, name)
monkeypatch.setitem(MODELS, model.name, model)
main_cpp = generate_main(component_fixture_path("enable_pins_address.yaml"))
match = re.search(r"default_pins->set_enable_pins\(\{(.*)\}\);", main_cpp)
assert match is not None
pin_ids = match.group(1).split(", ")
assert len(pin_ids) == 1
assert f"{pin_ids[0]}->set_parent(pi4ioe1);" in main_cpp
assert f"{pin_ids[0]}->set_pin(1);" in main_cpp
@@ -8,7 +8,7 @@ import re
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32P4
from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32P4, VARIANT_ESP32S3
# Importing the I/O expanders registers their pin schemas with
# pins.PIN_SCHEMA_REGISTRY so that models whose defaults reference expander-backed
@@ -215,3 +215,20 @@ def test_code_generation(
assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" not in main_cpp
assert "custom_id->set_rotation(display::DISPLAY_ROTATION_180_DEGREES);" in main_cpp
# assert "backlight_id = new light::LightState(mipi_dsi_dsibacklight_id);" in main_cpp
def test_unsupported_variant_reported_before_requirements(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On an unsupported chip the variant error is shown, not the missing components."""
from esphome.components.mipi_dsi.display import CONFIG_SCHEMA
from esphome.core import CORE
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
CORE.raw_config = {}
with pytest.raises(cv.Invalid, match=r"This feature is only available on ESP32P4"):
CONFIG_SCHEMA({"model": "SEEED-RETERMINAL-D1001"})
@@ -0,0 +1,32 @@
esphome:
name: enable-pins-test
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
psram:
mode: octal
speed: 80MHz
i2c:
sda: 8
scl: 9
ch422g:
- id: ch422g_hub
# Takes over the model's default backlight enable pin
switch:
- platform: gpio
id: backlight
pin:
ch422g: ch422g_hub
number: 2
display:
- platform: mipi_rgb
id: default_pins
model: ESP32-S3-TOUCH-LCD-7-800X480
update_interval: never
@@ -0,0 +1,24 @@
esphome:
name: enable-pins-test
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
psram:
mode: octal
speed: 80MHz
i2c:
sda: 8
scl: 9
ch422g:
- id: ch422g_hub
display:
- platform: mipi_rgb
id: default_pins
model: ESP32-S3-TOUCH-LCD-7-800X480
update_interval: never
@@ -0,0 +1,36 @@
"""Tests for applying the default enable pins of mipi_rgb models."""
from collections.abc import Callable
from pathlib import Path
import re
def _enable_pin_numbers(main_cpp: str) -> list[int]:
"""Get the expander pin numbers passed to set_enable_pins() in the generated code."""
match = re.search(r"default_pins->set_enable_pins\(\{(.*)\}\);", main_cpp)
assert match is not None
numbers = []
for pin_id in match.group(1).split(", "):
pin = re.search(rf"{pin_id}->set_pin\((\d+)\);", main_cpp)
assert pin is not None
numbers.append(int(pin.group(1)))
return numbers
def test_default_enable_pins(
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
) -> None:
"""All of the model's enable pins are applied when none are used elsewhere."""
main_cpp = generate_main(component_fixture_path("enable_pins_default.yaml"))
assert _enable_pin_numbers(main_cpp) == [2, 6]
def test_default_enable_pin_used_elsewhere(
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
) -> None:
"""A default enable pin used by a switch is left out instead of failing validation."""
main_cpp = generate_main(component_fixture_path("enable_pins.yaml"))
assert _enable_pin_numbers(main_cpp) == [6]
assert "backlight->set_pin(" in main_cpp
+81 -1
View File
@@ -8,7 +8,12 @@ from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32S3
from esphome.components.esp32 import (
KEY_BOARD,
KEY_VARIANT,
VARIANT_ESP32C3,
VARIANT_ESP32S3,
)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
# Importing pca9554 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that
@@ -87,3 +92,78 @@ def test_model_requires_psram_and_expander(
"spi_id": "spi_bus",
}
)
def test_model_requires_expander_when_pins_overridden(
set_core_config: SetCoreConfigCallable,
set_component_config: Any,
) -> None:
"""A model with default pins on an expander requires it even when the user config
overrides all of those pins, and without the expander being listed in ``requires``."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
set_component_config("psram", True)
CORE.raw_config = {"psram": True}
with pytest.raises(
cv.Invalid,
match=r"ESP32-S3-TOUCH-LCD-7-800X480 requires component 'ch422g' to be configured",
):
_validated(
{
"model": "ESP32-S3-TOUCH-LCD-7-800X480",
"reset_pin": 10,
"enable_pin": [11, 12],
}
)
def test_model_requires_expander_with_default_pins(
set_core_config: SetCoreConfigCallable,
set_component_config: Any,
) -> None:
"""The missing expander is reported before the model's default pins on it are validated."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
set_component_config("psram", True)
CORE.raw_config = {"psram": True}
with pytest.raises(
cv.Invalid,
match=r"ESP32-S3-TOUCH-LCD-7-800X480 requires component 'ch422g' to be configured",
):
_validated({"model": "ESP32-S3-TOUCH-LCD-7-800X480"})
def test_unsupported_variant_reported_before_requirements(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On an unsupported chip the variant error is shown, not the missing components."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-c3-devkitm-1", KEY_VARIANT: VARIANT_ESP32C3},
)
CORE.raw_config = {}
with pytest.raises(
cv.Invalid,
match=r"This feature is only available on ESP32S3, ESP32P4, ESP32S31",
):
_validated({"model": "ESP32-S3-TOUCH-LCD-7-800X480"})
def test_expander_that_fails_to_load_is_still_required(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An expander component that exists but fails to load is still counted as required."""
from esphome import loader, pins
from esphome.components import mipi
monkeypatch.setattr(pins, "PIN_SCHEMA_REGISTRY", pins.PinRegistry())
monkeypatch.setattr(loader, "get_component", lambda key: None)
defaults = {"enable_pin": {"xl9535": "hub", "number": 1, "mode": "output"}}
assert mipi._pin_expanders(defaults) == {"xl9535"}
+31
View File
@@ -36,6 +36,30 @@ esphome:
service: notify.html5
data:
message: Button was pressed
- homeassistant.service:
service: !lambda 'return std::string("notify.html5");'
data:
title: Title
data_template:
message: The value is {{ my_value }}
variables:
my_value: !lambda 'return std::string("42");'
capture_response: true
response_template: !lambda 'return std::string("{{ response.value }}");'
on_success:
- logger.log: "service succeeded"
on_error:
- logger.log:
format: "service failed: %s"
args: [error.c_str()]
- homeassistant.action:
action: weather.get_forecasts
data:
entity_id: weather.forecast_home
capture_response: true
response_template: !lambda 'return std::string("{{ response }}");'
on_success:
- logger.log: "forecast received"
- homeassistant.tag_scanned: pulse
- homeassistant.action:
action: weather.get_forecasts
@@ -307,6 +331,13 @@ api:
success: false
error_message: !lambda 'return error_msg;'
- action: test_respond_lambda_success
variables:
ok: bool
then:
- api.respond:
success: !lambda 'return ok;'
# ==========================================================================
# supports_response: optional (auto-detected - api.respond with data)
# Has call_id and return_response - client decides if it wants response
+8
View File
@@ -21,6 +21,14 @@ esphome:
id: waveradar
frequency: 5800MHz
power_consumption: 91uA
- at581x.settings:
id: waveradar
hw_frontend_reset: !lambda return true;
sensing_distance: !lambda return 300;
protect_time: !lambda return 1500;
trigger_base: !lambda return 600;
trigger_keep: !lambda return 12000;
stage_gain: !lambda return 4;
- at581x.reset:
id: waveradar
@@ -7,6 +7,8 @@ esphome:
- bk72xx_ble_tracker.start_scan
- bk72xx_ble_tracker.start_scan:
continuous: true
- bk72xx_ble_tracker.start_scan:
continuous: !lambda return false;
- bk72xx_ble_tracker.stop_scan
- bk72xx_ble_tracker.stop_scan: ble_tracker
+7
View File
@@ -109,3 +109,10 @@ button:
- logger.log: Level calibration failed
- motion.calibrate_heading:
id: bmi270_motion
- motion.calibrate_heading:
id: bmi270_motion
save: true
on_success:
- logger.log: Heading calibrated
on_error:
- logger.log: Heading calibration failed
+43
View File
@@ -9,6 +9,49 @@ esphome:
id: bq25186_charger
battery_regulation_voltage: 4.1V
fast_charge_current: 200mA
- bq25186.apply_configuration:
id: bq25186_charger
charge_enabled: false
flash_charging_mode: true
precharge_is_iterm: true
termination_percent: 5%
vindpm_mode: 4.7v
thermal_regulation: 80c
battery_ocp_limit: 1500ma
battery_uvlo: 3.0v
mask_charge_status_interrupt: false
mask_ilim_interrupt: false
mask_vindpm_interrupt: true
ts_auto_function: false
vlowv: 2.8v
recharge_threshold: 200mv
double_timer_during_dpm: true
safety_timer: 12h
watchdog: 160s_hardware_reset
long_press_duration: 15s
hw_reset_requires_vin: true
autowake: 2s
input_current_limit: 200ma
- bq25186.apply_configuration:
id: bq25186_charger
push_button_long_press_action: shutdown_mode
wake1_timer: 1s
wake2_timer: 3s
enable_push_button: false
system_regulation: 4.6v
sys_mode: battery_only
watchdog_15s_enable: false
disable_vdppm: true
ts_hot: 65c
ts_cold: 3c
ts_warm_disable: true
ts_cool_disable: true
ts_ichg: 0.2x
ts_vrcg: -200mv
mask_ts_interrupt: true
mask_treg_interrupt: false
mask_bat_interrupt: true
mask_pg_interrupt: true
bq25186:
- id: bq25186_charger
+3
View File
@@ -14,6 +14,9 @@ button:
id: bms
address: 0x09
- bq76972.program_i2c_address: !lambda "return 0x08;"
- bq76972.program_i2c_address:
id: bms
address: !lambda return 0x0A;
sensor:
- platform: bq76972
+4
View File
@@ -69,3 +69,7 @@ button:
- canbus.send: !lambda |-
uint8_t val = (uint8_t)id(canbus_test_number).state;
return std::vector<uint8_t>{0xAA, val, 0xBB};
- canbus.send:
can_id: 0x123
remote_transmission_request: true
data: []
@@ -5,6 +5,8 @@
#include <gtest/gtest.h>
#include <chrono>
#include <memory>
#include <string>
#include <thread>
namespace esphome::core::testing {
@@ -41,4 +43,40 @@ TEST(Scheduler, TimeoutAddedByACancelledCallbackIsVisibleAfterCall) {
scheduler.trim_freelist();
}
// A cancelled timeout of a component that has since failed has to be counted as removed when call()
// reaches it, or the leftover count makes the next few cancellations trigger a full cleanup early.
TEST(Scheduler, CancelledItemOfFailedComponentIsCountedAsRemoved) {
Scheduler scheduler;
SchedulerTestComponent healthy;
SchedulerTestComponent failing;
// The healthy item is due first, so cleanup_() stops at it and the cancelled item is only reached in the run loop
scheduler.set_timeout(&healthy, "first", 1, []() {});
scheduler.set_timeout(&failing, "cancelled", 2, []() {});
scheduler.process_to_add();
scheduler.cancel_timeout(&failing, "cancelled");
failing.mark_failed();
std::this_thread::sleep_for(std::chrono::milliseconds(5));
scheduler.call(millis());
// Four cancelled items behind a live one stay below the full cleanup threshold of five, so their callbacks are
// released only once they reach the top of the heap
auto token = std::make_shared<int>(0);
scheduler.set_timeout(&healthy, "keep", 10000, []() {});
static constexpr const char *NAMES[] = {"c0", "c1", "c2", "c3"};
for (const char *name : NAMES)
scheduler.set_timeout(&healthy, name, 20000, [token]() {});
scheduler.process_to_add();
for (const char *name : NAMES)
scheduler.cancel_timeout(&healthy, name);
ASSERT_EQ(token.use_count(), 5);
scheduler.call(millis());
EXPECT_EQ(token.use_count(), 5);
scheduler.cancel_timeout(&healthy, "keep");
scheduler.call(millis());
EXPECT_EQ(token.use_count(), 1);
scheduler.trim_freelist();
}
} // namespace esphome::core::testing
+3
View File
@@ -5,6 +5,9 @@ esphome:
id: counter_sensor
value: 100
- counter.increment: counter_sensor
- counter.set_value:
id: counter_sensor
value: !lambda return 42LL;
- counter.increment:
id: counter_sensor
value: -5
+2
View File
@@ -14,6 +14,8 @@ esphome:
then:
- deep_sleep.enter:
sleep_duration: 60min
- deep_sleep.enter:
sleep_duration: !lambda return 60000;
- if:
condition:
lambda: 'return deep_sleep::woke_from_deep_sleep();'
@@ -2,7 +2,8 @@
# (the default on ESP-IDF). Entering deep sleep runs the safe shutdown hooks,
# where safe_mode confirms the running app image so the bootloader does not
# roll back a fresh OTA update when the device goes to sleep before
# boot_is_good_after has elapsed.
# boot_is_good_after has elapsed. The network this needs also serves the
# deep_sleep.enter until/time_id case, which requires a time source.
substitutions:
wakeup_pin: GPIO4
@@ -17,3 +18,16 @@ wifi:
ota:
- platform: esphome
password: "superlongpasswordthatnoonewillknow"
time:
- platform: sntp
id: sntp_time
button:
- platform: template
name: Sleep until
on_press:
- deep_sleep.enter:
id: deep_sleep_1
until: "06:30:00"
time_id: sntp_time
+6
View File
@@ -27,6 +27,12 @@ esphome:
- dfplayer.set_volume: !lambda "return id(dfplayer_test).is_playing() ? 10 : 5;"
- dfplayer.set_eq: ROCK
- dfplayer.set_eq: !lambda "return id(dfplayer_test).is_playing() ? dfplayer::JAZZ : dfplayer::BASS;"
- dfplayer.play_mp3:
file: 7
- dfplayer.set_volume:
volume: 12
- dfplayer.set_eq:
eq_preset: CLASSIC
- dfplayer.play_next
- dfplayer.play_previous
- dfplayer.reset
@@ -12,6 +12,10 @@ esphome:
delay_after_detect: 0s
delay_after_disappear: 0s
sensitivity: 6
- dfrobot_sen0395.settings:
id: mmwave
factory_reset: !lambda return false;
sensitivity: !lambda return 7;
- dfrobot_sen0395.reset
dfrobot_sen0395:
+30
View File
@@ -52,6 +52,36 @@ button:
minute: !lambda return 45;
- ds3231.set_alarm_2:
mode: every_minute
- ds3231.set_alarm_1:
mode: weekly
day_of_week: 3
hour: !lambda return 8;
second: !lambda return 30;
enabled: !lambda return true;
- ds3231.set_alarm_1:
mode: monthly
day_of_month: !lambda return 20;
minute: !lambda return 10;
- ds3231.set_alarm_2:
mode: monthly
day_of_month: 10
enabled: false
- ds3231.set_alarm_2:
mode: weekly
day_of_week: !lambda return 5;
hour: !lambda return 18;
- ds3231.enable_alarm:
alarm: !lambda return 2;
- ds3231.disable_alarm:
alarm: !lambda return 1;
- if:
condition:
ds3231.is_alarm_triggered:
alarm: !lambda return 1;
then:
- logger.log: alarm 1 triggered
- ds3231.set_32khz_output:
state: !lambda return false;
- ds3231.enable_alarm:
alarm: 1
- ds3231.disable_alarm:
@@ -1,19 +0,0 @@
import esphome.codegen as cg
from esphome.core import CORE
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:
cg.add_define("ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT", 2)
cg.add_define("ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT", 2)
cg.add_define("USE_PACKET_INTERFACE_BASIC_SESSION")
# No entity platforms are configured, so count the test's own entities to emit USE_SENSOR and friends
CORE.platform_counts["sensor"] += 2
CORE.platform_counts["binary_sensor"] += 2
manifest.to_code = to_code_testing
# A multi_conf component gets an empty list in the test build, so to_code would not run
manifest.multi_conf = False
manifest.dependencies = manifest.dependencies + ["sensor", "binary_sensor"]
@@ -1,33 +0,0 @@
packet_interface:
- platform: uart
id: entity_transport_link
reliability:
sensor:
- platform: template
id: entity_transport_sensor
lambda: return 1.0;
- platform: entity_transport
provider: remote-device
remote_id: entity_transport_sensor
name: Remote sensor
binary_sensor:
- platform: template
id: entity_transport_binary_sensor
lambda: return true;
- platform: entity_transport
id: remote_binary_sensor
provider: remote-device
remote_id: door
name: Remote binary sensor
entity_transport:
- packet_interface: entity_transport_link
update_interval: 60s
timeout: 3min
sensors:
- entity_transport_sensor
binary_sensors:
- id: entity_transport_binary_sensor
broadcast_id: door
@@ -1,552 +0,0 @@
#include <gtest/gtest.h>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <initializer_list>
#include <memory>
#include <vector>
#include "esphome/components/entity_transport/entity_transport.h"
#include "esphome/core/application.h"
namespace esphome::entity_transport::testing {
// Copies a packet into a vector, for comparing in tests
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
std::vector<uint8_t> result(data.size());
data.copy_to(result.data(), result.size());
return result;
}
using packet_interface::PacketBuffer;
using packet_interface::PacketMetaData;
using Packet = std::vector<uint8_t>;
static constexpr const char *DEVICE_NAME = "this-device";
static constexpr uint8_t PACKET_DICTIONARY = 0x11;
static constexpr uint8_t PACKET_DATA = 0x12;
static constexpr uint8_t PACKET_REQUEST = 0x13;
// A packet interface that records what is sent, can refuse to send, and can act as a session layer
class MockInterface : public packet_interface::PacketInterface {
public:
explicit MockInterface(size_t max_packet_size = 64) : packet_interface::PacketInterface(max_packet_size) {}
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &) override {
if (!this->accept || this->budget == 0)
return false;
this->budget--;
this->sent.push_back(to_vector(data));
return true;
}
void inject(const Packet &packet) { this->on_receive_from_interface_(PacketBuffer(packet), {}); }
void set_capabilities(uint8_t capabilities) { this->capabilities_ = capabilities; }
void report_session(bool connected) { this->session_callback_.call(connected); }
// Deliver everything sent so far to another interface
void deliver_to(MockInterface &other) {
auto packets = std::move(this->sent);
this->sent.clear();
for (auto &packet : packets)
other.inject(packet);
}
std::vector<Packet> sent;
bool accept{true};
size_t budget{SIZE_MAX}; // packets accepted before refusing, like a full queue
};
class TestTransport : public EntityTransport {
public:
using EntityTransport::EntityTransport;
uint32_t get_dictionary_id() const { return this->dictionary_id_; }
uint32_t get_update_interval() const { return this->update_interval_ms_; }
using EntityTransport::clear_remote_states_;
};
static Packet header(uint8_t packet_type, uint32_t id) {
return {packet_type, static_cast<uint8_t>(id), static_cast<uint8_t>(id >> 8), static_cast<uint8_t>(id >> 16),
static_cast<uint8_t>(id >> 24)};
}
// The start of a dictionary packet from this device's name: the header, the part byte, then the name
static Packet dictionary_start(uint32_t id, uint8_t part = 0x80) {
Packet packet = header(PACKET_DICTIONARY, id);
packet.push_back(part);
packet.push_back(strlen(DEVICE_NAME));
packet.insert(packet.end(), DEVICE_NAME, DEVICE_NAME + strlen(DEVICE_NAME));
return packet;
}
class EntityTransportTest : public ::testing::Test {
protected:
void SetUp() override {
App.~Application();
new (&App) Application();
static char name[] = "this-device";
static char friendly_name[] = "";
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
}
void TearDown() override {
App.~Application();
new (&App) Application(); // later tests and the test listener use App
}
// The sender, with a sensor and a binary sensor
std::unique_ptr<TestTransport> make_sender_(MockInterface *link = nullptr) {
auto transport = std::make_unique<TestTransport>(link != nullptr ? link : &this->link_a_);
transport->set_entity_count(2);
transport->add_sensor("temp", &this->temp_);
transport->add_binary_sensor("door", &this->door_);
transport->setup();
return transport;
}
// The receiver, showing the sender's entities; both run on this device, so they share its name
std::unique_ptr<TestTransport> make_receiver_(const char *provider = DEVICE_NAME, uint32_t timeout_ms = 0) {
auto transport = std::make_unique<TestTransport>(&this->link_b_);
transport->set_remote_timeout(timeout_ms);
transport->add_remote_sensor(provider, "temp", &this->remote_temp_);
transport->add_remote_binary_sensor(provider, "door", &this->remote_door_);
transport->setup();
return transport;
}
MockInterface link_a_, link_b_;
sensor::Sensor temp_, remote_temp_;
binary_sensor::BinarySensor door_, remote_door_;
};
TEST_F(EntityTransportTest, SendsDictionaryThenStates) {
this->temp_.publish_state(21.5f);
this->door_.publish_state(true);
auto sender = this->make_sender_();
sender->loop();
uint32_t id = sender->get_dictionary_id();
Packet dictionary = dictionary_start(id);
dictionary.insert(dictionary.end(), {1, 0, 1, 4, 't', 'e', 'm', 'p', 2, 0, 1, 4, 'd', 'o', 'o', 'r'});
Packet data = header(PACKET_DATA, id);
// Tags: sensor 0 with a 4 byte state, then binary sensor 0 with its state in the top bit
data.insert(data.end(), {0x00, 0x21, 0x00, 0x00, 0xAC, 0x41, 0x00, 0x82});
ASSERT_EQ(this->link_a_.sent.size(), 2);
EXPECT_EQ(this->link_a_.sent[0], dictionary);
EXPECT_EQ(this->link_a_.sent[1], data);
}
TEST_F(EntityTransportTest, SendsOnlyChangedStates) {
auto sender = this->make_sender_();
sender->loop();
this->link_a_.sent.clear();
this->temp_.publish_state(3.0f);
sender->loop();
Packet data = header(PACKET_DATA, sender->get_dictionary_id());
data.insert(data.end(), {0x00, 0x21, 0x00, 0x00, 0x40, 0x40});
ASSERT_EQ(this->link_a_.sent.size(), 1);
EXPECT_EQ(this->link_a_.sent[0], data);
}
TEST_F(EntityTransportTest, StatesReachRemoteEntities) {
this->temp_.publish_state(12.25f);
this->door_.publish_state(true);
auto sender = this->make_sender_();
auto receiver = this->make_receiver_();
sender->loop();
this->link_a_.deliver_to(this->link_b_);
EXPECT_EQ(this->remote_temp_.get_state(), 12.25f);
EXPECT_TRUE(this->remote_door_.state);
this->door_.invalidate_state();
this->temp_.publish_state(NAN);
sender->loop();
ASSERT_EQ(this->link_a_.sent.size(), 1);
// Both records have no state, and so no payload
EXPECT_EQ(Packet(this->link_a_.sent[0].begin() + 5, this->link_a_.sent[0].end()), (Packet{0x00, 0x61, 0x00, 0x62}));
this->link_a_.deliver_to(this->link_b_);
EXPECT_FALSE(this->remote_door_.has_state());
EXPECT_TRUE(std::isnan(this->remote_temp_.get_state()));
}
TEST_F(EntityTransportTest, SplitsAcrossPackets) {
MockInterface link(5 + 2 + strlen(DEVICE_NAME) + 3 + 5);
this->temp_.publish_state(1.0f);
this->door_.publish_state(true);
auto sender = this->make_sender_(&link);
auto receiver = this->make_receiver_();
sender->loop();
EXPECT_EQ(link.sent.size(), 3); // one dictionary packet for each entity, then one data packet
link.deliver_to(this->link_b_);
EXPECT_EQ(this->remote_temp_.get_state(), 1.0f);
EXPECT_TRUE(this->remote_door_.state);
}
TEST_F(EntityTransportTest, RefusedPacketSentAgain) {
this->link_a_.accept = false;
auto sender = this->make_sender_();
sender->loop();
EXPECT_TRUE(this->link_a_.sent.empty());
this->link_a_.accept = true;
sender->loop();
EXPECT_EQ(this->link_a_.sent.size(), 2);
}
TEST_F(EntityTransportTest, LongRefusalWarns) {
this->link_a_.accept = false;
auto sender = this->make_sender_();
{
LoopBlockingGuard guard{nullptr, nullptr, 1000};
}
sender->loop();
{
LoopBlockingGuard guard{nullptr, nullptr, 60999};
}
sender->loop();
EXPECT_FALSE(sender->status_has_warning());
{
LoopBlockingGuard guard{nullptr, nullptr, 61000};
}
sender->loop();
EXPECT_TRUE(sender->status_has_warning());
this->link_a_.accept = true;
sender->loop();
EXPECT_FALSE(sender->status_has_warning());
EXPECT_EQ(this->link_a_.sent.size(), 2);
}
TEST_F(EntityTransportTest, UnknownDictionaryRequested) {
this->temp_.publish_state(5.0f);
auto sender = this->make_sender_();
auto receiver = this->make_receiver_();
sender->loop();
this->link_a_.sent.erase(this->link_a_.sent.begin()); // the dictionary is lost
this->link_a_.deliver_to(this->link_b_);
EXPECT_FALSE(this->remote_temp_.has_state());
ASSERT_EQ(this->link_b_.sent.size(), 1);
EXPECT_EQ(this->link_b_.sent[0], header(PACKET_REQUEST, sender->get_dictionary_id()));
this->link_b_.deliver_to(this->link_a_);
sender->loop();
this->link_a_.deliver_to(this->link_b_);
EXPECT_EQ(this->remote_temp_.get_state(), 5.0f);
}
TEST_F(EntityTransportTest, ChangedDictionaryRequested) {
this->temp_.publish_state(5.0f);
auto sender = this->make_sender_();
auto receiver = this->make_receiver_();
sender->loop();
this->link_a_.deliver_to(this->link_b_);
EXPECT_EQ(this->remote_temp_.get_state(), 5.0f);
// The provider restarts with other entities, and its new dictionary is lost
sensor::Sensor temp;
MockInterface link;
TestTransport changed(&link);
changed.set_entity_count(1);
changed.add_sensor("temp", &temp);
changed.setup();
temp.publish_state(6.0f);
changed.loop();
link.sent.erase(link.sent.begin());
link.deliver_to(this->link_b_);
ASSERT_EQ(this->link_b_.sent.size(), 1);
EXPECT_EQ(this->link_b_.sent[0], header(PACKET_REQUEST, changed.get_dictionary_id()));
this->link_b_.deliver_to(link);
changed.loop();
link.deliver_to(this->link_b_);
EXPECT_EQ(this->remote_temp_.get_state(), 6.0f);
}
TEST_F(EntityTransportTest, MalformedDataNotRequested) {
auto receiver = this->make_receiver_();
// A data packet under an unknown dictionary, cut off inside its first record
Packet data = header(PACKET_DATA, 0x12345678);
data.insert(data.end(), {0x00, 0x21, 0x00});
this->link_b_.inject(data);
EXPECT_TRUE(this->link_b_.sent.empty());
}
TEST_F(EntityTransportTest, EntityLeftOutOfNewDictionaryCleared) {
this->temp_.publish_state(5.0f);
this->door_.publish_state(true);
auto sender = this->make_sender_();
auto receiver = this->make_receiver_();
sender->loop();
this->link_a_.deliver_to(this->link_b_);
EXPECT_TRUE(this->remote_door_.state);
// The provider restarts without the door
sensor::Sensor temp;
MockInterface link;
TestTransport changed(&link);
changed.set_entity_count(1);
changed.add_sensor("temp", &temp);
changed.setup();
temp.publish_state(6.0f);
changed.loop();
link.deliver_to(this->link_b_);
EXPECT_EQ(this->remote_temp_.get_state(), 6.0f);
EXPECT_FALSE(this->remote_door_.has_state());
}
TEST_F(EntityTransportTest, EntityLeftOutOfSeenDictionaryCleared) {
// The provider names only the sensor, then both entities, then goes back to naming only the sensor
sensor::Sensor temp;
MockInterface link;
auto make_temp_only = [&] {
auto transport = std::make_unique<TestTransport>(&link);
transport->set_entity_count(1);
transport->add_sensor("temp", &temp);
transport->setup();
return transport;
};
temp.publish_state(4.0f);
auto receiver = this->make_receiver_();
make_temp_only()->loop();
link.deliver_to(this->link_b_);
this->door_.publish_state(true);
this->make_sender_()->loop();
this->link_a_.deliver_to(this->link_b_);
EXPECT_TRUE(this->remote_door_.state);
make_temp_only()->loop();
link.deliver_to(this->link_b_);
EXPECT_EQ(this->remote_temp_.get_state(), 4.0f);
EXPECT_FALSE(this->remote_door_.has_state());
}
TEST_F(EntityTransportTest, LostDictionaryPartRequested) {
// One entity in each dictionary packet
MockInterface link(5 + 2 + strlen(DEVICE_NAME) + 3 + 5);
this->temp_.publish_state(2.0f);
this->door_.publish_state(true);
auto sender = this->make_sender_(&link);
auto receiver = this->make_receiver_();
sender->loop();
ASSERT_EQ(link.sent.size(), 3);
EXPECT_EQ(link.sent[0][5], 0x00);
EXPECT_EQ(link.sent[1][5], 0x81);
link.sent.erase(link.sent.begin() + 1); // the part naming the binary sensor is lost
link.deliver_to(this->link_b_);
EXPECT_EQ(this->remote_temp_.get_state(), 2.0f);
EXPECT_FALSE(this->remote_door_.has_state());
// The sensor is known, but the dictionary is not complete, so it is asked for
ASSERT_EQ(this->link_b_.sent.size(), 1);
EXPECT_EQ(this->link_b_.sent[0], header(PACKET_REQUEST, sender->get_dictionary_id()));
this->link_b_.deliver_to(link);
sender->loop();
link.deliver_to(this->link_b_);
EXPECT_TRUE(this->remote_door_.state);
// Now complete, it is not asked for again
{
LoopBlockingGuard guard{nullptr, nullptr, 10000};
}
this->door_.publish_state(false);
sender->loop();
link.deliver_to(this->link_b_);
EXPECT_FALSE(this->remote_door_.state);
EXPECT_TRUE(this->link_b_.sent.empty());
}
TEST_F(EntityTransportTest, RefusedRequestSentAgain) {
auto receiver = this->make_receiver_();
Packet data = header(PACKET_DATA, 0x12345678);
this->link_b_.accept = false;
this->link_b_.inject(data);
this->link_b_.accept = true;
this->link_b_.inject(data);
ASSERT_EQ(this->link_b_.sent.size(), 1);
EXPECT_EQ(this->link_b_.sent[0], header(PACKET_REQUEST, 0x12345678));
}
TEST_F(EntityTransportTest, RequestsLimited) {
auto receiver = this->make_receiver_();
Packet data = header(PACKET_DATA, 0x12345678);
data.insert(data.end(), {0x00, 0x61}); // sensor 0 with no state
this->link_b_.inject(data);
this->link_b_.inject(data);
EXPECT_EQ(this->link_b_.sent.size(), 1);
}
TEST_F(EntityTransportTest, OtherProviderAndOwnPacketsIgnored) {
this->temp_.publish_state(5.0f);
auto sender = this->make_sender_();
auto receiver = this->make_receiver_("other-device");
sender->loop();
auto packets = this->link_a_.sent;
this->link_a_.deliver_to(this->link_b_);
EXPECT_FALSE(this->remote_temp_.has_state());
// The dictionary named none of the receiver's entities, so it is not asked for again
EXPECT_TRUE(this->link_b_.sent.empty());
for (auto &packet : packets)
this->link_a_.inject(packet);
this->link_a_.inject(header(PACKET_REQUEST, 0x12345678));
sender->loop();
EXPECT_TRUE(this->link_a_.sent.empty());
}
TEST_F(EntityTransportTest, MalformedPacketsIgnored) {
auto sender = this->make_sender_();
auto receiver = this->make_receiver_();
sender->loop();
Packet dictionary = this->link_a_.sent[0];
Packet data = this->link_a_.sent[1];
this->link_b_.inject(Packet(dictionary.begin(), dictionary.end() - 1));
this->link_b_.inject(dictionary);
this->link_b_.inject(Packet(data.begin(), data.end() - 1));
this->link_b_.inject({PACKET_DATA, 1, 2});
this->link_b_.inject({0x21, 0, 0, 0, 0});
EXPECT_FALSE(this->remote_temp_.has_state());
}
// Data packets from the sender's dictionary, holding the given records
class EntityTransportRecordTest : public EntityTransportTest {
protected:
void SetUp() override {
EntityTransportTest::SetUp();
this->sender_ = this->make_sender_();
this->receiver_ = this->make_receiver_();
this->sender_->loop();
this->link_a_.sent.resize(1); // the dictionary only
this->link_a_.deliver_to(this->link_b_);
}
void inject_data_(std::initializer_list<uint8_t> records) {
Packet data = header(PACKET_DATA, this->sender_->get_dictionary_id());
data.insert(data.end(), records);
this->link_b_.inject(data);
}
std::unique_ptr<TestTransport> sender_, receiver_;
};
TEST_F(EntityTransportRecordTest, WholePacketCheckedBeforeUse) {
// A good record for each entity, then a record cut short
this->inject_data_({0x00, 0x21, 0x00, 0x00, 0x10, 0x41, 0x00, 0x82, 0x00, 0x21, 0x00});
// A good record, then a sensor record without its state, then a binary sensor record with a payload
this->inject_data_({0x00, 0x21, 0x00, 0x00, 0x10, 0x41, 0x00, 0x01});
this->inject_data_({0x00, 0x82, 0x00, 0x22, 0x00, 0x00, 0x00, 0x00});
// The value bit is only for records without a payload, and type 0 is not used
this->inject_data_({0x00, 0xA1, 0x00, 0x00, 0x10, 0x41});
this->inject_data_({0x00, 0x82, 0x00, 0x00});
// A length byte past the end
this->inject_data_({0x00, 0x82, 0x00, 0x45, 0x03, 0x01});
EXPECT_FALSE(this->remote_temp_.has_state());
EXPECT_FALSE(this->remote_door_.has_state());
this->inject_data_({0x00, 0x21, 0x00, 0x00, 0x10, 0x41, 0x00, 0x82});
EXPECT_EQ(this->remote_temp_.get_state(), 9.0f);
EXPECT_TRUE(this->remote_door_.state);
}
TEST_F(EntityTransportRecordTest, UnknownTypesSkipped) {
// Type 5 with 3 bytes after a length byte, type 6 with a 4 byte payload, type 7 without a state, then the sensor
this->inject_data_({0x00, 0x45, 0x03, 0x01, 0x02, 0x03, 0x01, 0x26, 0x00, 0x00,
0x00, 0x00, 0x02, 0x67, 0x00, 0x21, 0x00, 0x00, 0x10, 0x41});
EXPECT_EQ(this->remote_temp_.get_state(), 9.0f);
}
TEST_F(EntityTransportTest, PartlyValidDictionaryNotLearnt) {
auto receiver = this->make_receiver_();
static constexpr uint32_t OTHER_ID = 0x0BADF00D;
Packet dictionary = dictionary_start(OTHER_ID);
// A good section naming the sensor, then sections cut short, of type 0, and of more than 255 numbers
dictionary.insert(dictionary.end(), {1, 0, 1, 4, 't', 'e', 'm', 'p'});
for (Packet bad : {Packet{2, 0, 2, 4, 'd', 'o', 'o', 'r'}, Packet{0, 0, 1, 1, 'x'}, Packet{2, 255, 2, 1, 'a', 1, 'b'},
Packet{2, 0, 1}}) {
Packet packet = dictionary;
packet.insert(packet.end(), bad.begin(), bad.end());
this->link_b_.inject(packet);
}
Packet data = header(PACKET_DATA, OTHER_ID);
data.insert(data.end(), {0x00, 0x21, 0x00, 0x00, 0x80, 0x3F});
this->link_b_.inject(data);
EXPECT_FALSE(this->remote_temp_.has_state());
this->link_b_.inject(dictionary);
this->link_b_.inject(data);
EXPECT_EQ(this->remote_temp_.get_state(), 1.0f);
}
TEST_F(EntityTransportTest, UpdateIntervalFollowsCapabilities) {
EXPECT_EQ(this->make_sender_()->get_update_interval(), 15000);
this->link_a_.set_capabilities(packet_interface::CAPABILITY_SESSION);
EXPECT_EQ(this->make_sender_()->get_update_interval(), 15000);
this->link_a_.set_capabilities(packet_interface::CAPABILITY_SESSION | packet_interface::CAPABILITY_RELIABILITY);
EXPECT_EQ(this->make_sender_()->get_update_interval(), SCHEDULER_DONT_RUN);
auto transport = std::make_unique<TestTransport>(&this->link_a_);
transport->set_update_interval(5000);
transport->setup();
EXPECT_EQ(transport->get_update_interval(), 5000);
}
TEST_F(EntityTransportTest, SessionConnectSendsAllAndLossClearsRemote) {
this->link_a_.set_capabilities(packet_interface::CAPABILITY_SESSION);
this->link_b_.set_capabilities(packet_interface::CAPABILITY_SESSION);
this->temp_.publish_state(7.0f);
auto sender = this->make_sender_();
auto receiver = this->make_receiver_();
sender->loop();
this->link_a_.deliver_to(this->link_b_);
this->link_a_.report_session(true);
sender->loop();
EXPECT_EQ(this->link_a_.sent.size(), 2);
this->link_b_.report_session(false);
EXPECT_TRUE(std::isnan(this->remote_temp_.get_state()));
EXPECT_FALSE(this->remote_door_.has_state());
}
TEST_F(EntityTransportTest, RefusedDictionaryPartResumes) {
// One entity in each dictionary packet
MockInterface link(5 + 2 + strlen(DEVICE_NAME) + 3 + 5);
this->temp_.publish_state(4.0f);
this->door_.publish_state(true);
auto sender = this->make_sender_(&link);
auto receiver = this->make_receiver_();
link.budget = 1;
sender->loop();
ASSERT_EQ(link.sent.size(), 1);
link.budget = SIZE_MAX;
sender->loop();
int dictionaries = 0;
for (auto &packet : link.sent)
dictionaries += packet[0] == PACKET_DICTIONARY;
EXPECT_EQ(dictionaries, 2); // the first part was not sent again
link.deliver_to(this->link_b_);
EXPECT_EQ(this->remote_temp_.get_state(), 4.0f);
EXPECT_TRUE(this->remote_door_.state);
}
TEST_F(EntityTransportTest, RemoteStatesTimeOut) {
this->temp_.publish_state(6.0f);
this->door_.publish_state(true);
auto sender = this->make_sender_();
auto receiver = this->make_receiver_(DEVICE_NAME, 5000);
{
LoopBlockingGuard guard{nullptr, nullptr, 1000};
}
sender->loop();
this->link_a_.deliver_to(this->link_b_);
{
LoopBlockingGuard guard{nullptr, nullptr, 6000};
}
receiver->clear_remote_states_(true);
EXPECT_EQ(this->remote_temp_.get_state(), 6.0f);
EXPECT_TRUE(this->remote_door_.has_state());
{
LoopBlockingGuard guard{nullptr, nullptr, 6001};
}
receiver->clear_remote_states_(true);
EXPECT_TRUE(std::isnan(this->remote_temp_.get_state()));
EXPECT_FALSE(this->remote_door_.has_state());
}
TEST_F(EntityTransportTest, IdMatchingOnlyUpToNulNotLearnt) {
auto receiver = this->make_receiver_();
static constexpr uint32_t OTHER_ID = 0x0BADF00D;
Packet dictionary = dictionary_start(OTHER_ID);
// The id "temp" followed by a NUL and more bytes, nine in all
dictionary.insert(dictionary.end(), {1, 0, 1, 9, 't', 'e', 'm', 'p', 0, 'a', 'b', 'c', 'd'});
this->link_b_.inject(dictionary);
Packet data = header(PACKET_DATA, OTHER_ID);
data.insert(data.end(), {0x00, 0x21, 0x00, 0x00, 0x80, 0x3F});
this->link_b_.inject(data);
EXPECT_FALSE(this->remote_temp_.has_state());
}
TEST_F(EntityTransportTest, PacketTooSmallForIdsFailsSetup) {
// One byte short of a dictionary packet holding the device name and a four character id
MockInterface small_link(5 + 2 + strlen(DEVICE_NAME) + 3 + 1 + 4 - 1);
auto sender = this->make_sender_(&small_link);
EXPECT_TRUE(sender->is_failed());
EXPECT_FALSE(this->make_sender_()->is_failed());
}
} // namespace esphome::entity_transport::testing
@@ -1,3 +0,0 @@
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
entity_transport: !include common.yaml
+2
View File
@@ -3,6 +3,8 @@ esphome:
then:
- audio_adc.set_mic_gain: 0db
- audio_adc.set_mic_gain: !lambda 'return 4;'
- audio_adc.set_mic_gain:
mic_gain: 6db
audio_adc:
- platform: es7210
+12
View File
@@ -35,6 +35,18 @@ espnow:
data: [0x01, 0x02, 0x03, 0x04, 0x05]
- espnow.broadcast:
data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};'
- espnow.broadcast:
address: FF:FF:FF:FF:FF:FF
data: "options"
wait_for_sent: true
continue_on_error: false
on_sent:
- logger.log: "broadcast sent"
on_error:
- logger.log: "broadcast failed"
- espnow.send:
address: !lambda 'return {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};'
data: "lambda address"
- espnow.send:
address: 11:22:33:44:55:66
data: !lambda 'return std::vector<uint8_t>(data, data + size);'
+7
View File
@@ -46,6 +46,13 @@ text_sensor:
- ezo_pmp.set_calibration_volume:
id: hcl_pump
volume: 10
- ezo_pmp.dose_with_constant_flow_rate:
id: hcl_pump
volume_per_minute: !lambda return 5.5f;
duration: !lambda return 3;
- ezo_pmp.set_calibration_volume:
id: hcl_pump
volume: !lambda return 12.5f;
- ezo_pmp.find: hcl_pump
- ezo_pmp.dose_continuously: hcl_pump
- ezo_pmp.clear_total_volume_dosed: hcl_pump
@@ -14,3 +14,10 @@ button:
speed: 200
color: BLUE
count: 2
- fingerprint_grow.aura_led_control:
state: !lambda return fingerprint_grow::FLASHING;
speed: !lambda return 100;
color: !lambda return fingerprint_grow::RED;
count: !lambda return 3;
- fingerprint_grow.delete:
finger_id: !lambda return 2;
+3
View File
@@ -9,6 +9,9 @@ hdmi_cec:
- hdmi_cec.send:
destination: !lambda return source;
data: [0x90, 0x01] # Report Power Status: standby
- hdmi_cec.send:
destination: 0x0
data: !lambda return {0x90, 0x01};
- opcode: 0x8C # Give Device Vendor ID
then:
- lambda: |-
+2
View File
@@ -5,6 +5,8 @@ esphome:
name: "Test"
direction: 1
- hlk_fm22x.delete: 5
- hlk_fm22x.delete:
face_id: 7
- hlk_fm22x.delete_all:
- hlk_fm22x.reset:
- hlk_fm22x.scan:
@@ -35,6 +35,59 @@ esphome:
- http_request.get:
url: https://esphome.io
timeout: !lambda "return 10000;"
- http_request.get:
url: !lambda 'return std::string("https://esphome.io/get");'
method: GET
collect_headers:
- Content-Type
- X-Request-Id
max_response_buffer_size: 2kB
capture_response: true
on_response:
then:
- logger.log:
format: "Content-Type %s, body %zu bytes"
args:
- response->get_response_header("content-type").c_str()
- body.size()
- http_request.post:
url: !lambda 'return std::string("https://esphome.io/post");'
method: POST
body: !lambda 'return std::string("{\"key\": \"value\"}");'
timeout: 5s
collect_headers:
- Location
max_response_buffer_size: 512
on_error:
- logger.log: "POST failed"
- http_request.post:
url: https://esphome.io
body: "plain body"
timeout: !lambda "return 2500;"
- http_request.send:
method: PATCH
url: !lambda 'return std::string("https://esphome.io/send");'
json:
key: value
dynamic: !lambda 'return std::string("x");'
collect_headers:
- ETag
max_response_buffer_size: 4kB
capture_response: true
timeout: !lambda "return 3000;"
on_response:
then:
- logger.log:
format: "Send status %d, body %s"
args:
- response->status_code
- body.c_str()
on_error:
- logger.log: "Send failed"
- http_request.send:
method: DELETE
url: https://esphome.io/item
body: !lambda 'return std::string("id=1");'
- http_request.post:
url: https://esphome.io
request_headers:
+6
View File
@@ -21,3 +21,9 @@ button:
- htu21d.set_heater_level:
id: htu21d_sensor
level: 5
- htu21d.set_heater:
id: htu21d_sensor
status: !lambda return false;
- htu21d.set_heater_level:
id: htu21d_sensor
level: !lambda return 3;
+3
View File
@@ -10,3 +10,6 @@ esphome:
- hub75.set_brightness:
id: my_hub75
brightness: 50
- hub75.set_brightness:
id: my_hub75
brightness: !lambda return 150;
@@ -108,3 +108,6 @@ interval:
id: m5epd_display
mode: A2
- it8951.update: m5epd_display
- it8951.update:
id: m5epd_display
mode: !lambda return it8951::UPDATE_MODE_GL16;
+27
View File
@@ -155,6 +155,33 @@ esphome:
- light.turn_off:
id: test_rgb_light
transition_length: 300ms
- light.turn_off:
id: test_rgb_light
state: false
transition_length: !lambda return 200;
- light.turn_on:
id: test_rgb_light
red: !lambda return 0.2f;
green: !lambda return 0.4f;
blue: !lambda return 0.6f;
transition_length: !lambda return 350;
- light.turn_on:
id: test_cwww_light
color_temperature: 300 mireds
- light.turn_on:
id: test_cwww_light
color_temperature: !lambda return 250.0f;
- light.control:
id: test_rgbww_light
cold_white: !lambda return 0.6f;
warm_white: !lambda return 0.2f;
- light.control:
id: test_monochromatic_light
effect: !lambda return "My Flicker";
- light.dim_relative:
id: test_monochromatic_light
relative_brightness: !lambda return 0.1f;
transition_length: !lambda return 150;
# Test static effect name resolution at codegen time
- light.turn_on:
id: test_monochromatic_light
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff. Show more