Compare commits

..
Author SHA1 Message Date
Kevin Ahrendt af61645b84 [sendspin] Add the source role, streaming a microphone to the server
The server starts and stops the stream; the microphone runs only while it is
open. Each buffer is passed to sendspin-cpp with its capture time. Supports PCM
and Opus.
2026-10-26 08:30:50 -04:00
Kevin Ahrendt 099ffda05d [microphone] Pass a capture time with each audio buffer
Data callbacks can take the capture time of the buffer's first frame as a second
argument. Callbacks that only take the data keep working. Microphone platforms
publish through publish_audio_().
2026-10-26 08:30:42 -04:00
Bascht74andJ. Nick Koston 2d37ad6836 [api] Keep an entity's events in order under backpressure (#21567)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-25 23:57:11 -10:00
Clyde Stubbs d2f6b389d4 [runtime_image] Fix LVGL widgets on image resize (#21368) 2026-10-26 20:08:06 +11:00
Raphael Hehl 875b2076c0 [esp32_camera] Fix lost single image requests (#21613) 2026-10-25 22:04:24 -10:00
c21660251c [modbus_client] Pass the request's address to the send handlers (#20461)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Dale Klein <49771139+dalklein@users.noreply.github.com>
Co-authored-by: Bonne Eggleston <bonne@exciton.com.au>
2026-10-25 21:05:10 -10:00
esphome[bot] 995acb94b4 [ci] Refresh integration test durations (#21640) 2026-10-26 05:58:30 +00:00
Manuel MauszandJ. Nick Koston 24b7eefaad [remote_base] Add support for Cardin S438 remotes (#21507)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-25 19:56:32 -10:00
Clyde StubbsandClaude Opus 5.5 2349e0be00 [esp32] Check that code is mapped when reporting a crash (#21630)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:49:16 +11:00
Clyde StubbsandClaude Opus 5.5 63349801b7 [ci] Include dependencies added by overrides of dependency components (#21635)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:18:17 -10:00
afdfa7b298 [gree] Add use_fahrenheit for Fahrenheit display on the unit's panel (#20751)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-25 17:09:04 -10:00
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
247 changed files with 5875 additions and 5905 deletions

No files matched your search

-5
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
@@ -642,9 +641,7 @@ esphome/components/sun_gtil2/* @Mat931
esphome/components/switch/* @esphome/core
esphome/components/switch/binary_sensor/* @ssieb
esphome/components/sx126x/* @swoboda1337
esphome/components/sx126x/packet_interface/* @clydebarrow
esphome/components/sx127x/* @swoboda1337
esphome/components/sx127x/packet_interface/* @clydebarrow
esphome/components/sy6970/* @linkedupbits
esphome/components/syslog/* @clydebarrow
esphome/components/systa_bus/* @Mat931
@@ -656,8 +653,6 @@ esphome/components/tca8418/* @zebble
esphome/components/tca9548a/* @andreashergert1984
esphome/components/tca9555/* @mobrembski
esphome/components/tcl112/* @glmnet
esphome/components/tcp/* @clydebarrow
esphome/components/tcp/packet_interface/* @clydebarrow
esphome/components/tcp_uart/* @Bascht74
esphome/components/tee501/* @Stock-M
esphome/components/teleinfo/* @0hax @FredM67
+11 -2
View File
@@ -1555,8 +1555,17 @@ void APIConnection::on_water_heater_command_request(const WaterHeaterCommandRequ
// Event is a special case - unlike other entities with simple state fields,
// events store their state in a member accessed via obj->get_last_event_type()
void APIConnection::send_event(event::Event *event) {
this->send_message_smart_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE,
event->get_last_event_type_index());
const uint8_t type_index = event->get_last_event_type_index();
const size_t size_before = this->deferred_batch_.size();
if (this->deferred_batch_.contains(event, EventResponse::MESSAGE_TYPE)) {
// Sending now would overtake the older event of this entity that still waits in the batch
this->schedule_message_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE, type_index);
} else {
this->send_message_smart_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE, type_index);
}
// Events are rare, so a queued one sends the batch now rather than after batch_delay
if (this->deferred_batch_.size() > size_before)
this->make_batch_due_now_();
}
uint16_t APIConnection::try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn,
uint32_t remaining_size) {
+5 -68
View File
@@ -2,6 +2,7 @@
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_deferred_batch.h"
#include "api_frame_helper.h"
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
@@ -20,7 +21,6 @@
#include "esphome/core/string_ref.h"
#include <functional>
#include <limits>
#include <vector>
namespace esphome {
@@ -656,73 +656,6 @@ class APIConnection final : public APIServerConnectionBase {
// Function pointer type for message encoding
using MessageCreatorPtr = uint16_t (*)(EntityBase *, APIConnection *, uint32_t remaining_size);
// Generic batching mechanism for both state updates and entity info
struct DeferredBatch {
// Sentinel value for unused aux_data_index
static constexpr uint8_t AUX_DATA_UNUSED = std::numeric_limits<uint8_t>::max();
struct BatchItem {
EntityBase *entity; // 4 bytes - Entity pointer
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
};
std::vector<BatchItem> items;
uint32_t batch_start_time{0};
// No pre-allocation - log connections never use batching, and for
// connections that do, buffers are released after initial sync anyway
// Add item to the batch (with deduplication)
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = AUX_DATA_UNUSED) {
// Dedup: O(n) scan but optimized for RAM over performance
// Skip deduplication for events - they are edge-triggered, every occurrence matters
#ifdef USE_EVENT
if (message_type != EventResponse::MESSAGE_TYPE)
#endif
{
for (const auto &item : this->items) {
if (item.entity == entity && item.message_type == message_type)
return; // Already queued
}
}
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
}
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
// Swap to front avoids expensive vector::insert which shifts all elements
this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
if (this->items.size() > 1) {
std::swap(this->items.front(), this->items.back());
}
}
// Clear all items
void clear() {
items.clear();
batch_start_time = 0;
}
// Remove processed items from the front — noinline to keep memmove out of warm callers
void remove_front(size_t count) __attribute__((noinline)) { items.erase(items.begin(), items.begin() + count); }
bool empty() const { return items.empty(); }
size_t size() const { return items.size(); }
const BatchItem &operator[](size_t index) const { return items[index]; }
// Release excess capacity - only releases if items already empty
void release_buffer() {
// Safe to call: batch is processed before release_buffer is called,
// and if any items remain (partial processing), we must not clear them.
// Use swap trick since shrink_to_fit() is non-binding and may be ignored.
if (items.empty()) {
std::vector<BatchItem>().swap(items);
}
}
};
// DeferredBatch here (16 bytes, 4-byte aligned)
DeferredBatch deferred_batch_;
@@ -795,6 +728,10 @@ class APIConnection final : public APIServerConnectionBase {
static constexpr size_t MAX_BATCH_PACKET_SIZE = 1390; // MTU
bool schedule_batch_();
// Sends the batch on the next loop, so a queued event does not wait for batch_delay; queued states go with it
void make_batch_due_now_() {
this->deferred_batch_.batch_start_time = App.get_loop_component_start_time() - this->get_batch_delay_ms_();
}
void process_batch_();
void process_batch_multi_(APIBuffer &shared_buf, size_t num_items, uint8_t header_padding, uint8_t footer_size)
__attribute__((noinline));
@@ -0,0 +1,88 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_pb2.h"
#include "esphome/core/entity_base.h"
#include <algorithm>
#include <cstdint>
#include <limits>
#include <vector>
namespace esphome::api {
// Generic batching mechanism for both state updates and entity info
struct DeferredBatch {
// Sentinel value for unused aux_data_index
static constexpr uint8_t AUX_DATA_UNUSED = std::numeric_limits<uint8_t>::max();
struct BatchItem {
EntityBase *entity; // 4 bytes - Entity pointer
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
};
std::vector<BatchItem> items;
uint32_t batch_start_time{0};
// No pre-allocation - log connections never use batching, and for
// connections that do, buffers are released after initial sync anyway
// O(n) scan but optimized for RAM over performance
bool contains(EntityBase *entity, uint16_t message_type) const {
for (const auto &item : this->items) {
if (item.entity == entity && item.message_type == message_type)
return true;
}
return false;
}
// Add item to the batch (with deduplication)
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = AUX_DATA_UNUSED) {
// Skip deduplication for events - they are edge-triggered, every occurrence matters
#ifdef USE_EVENT
if (message_type != EventResponse::MESSAGE_TYPE)
#endif
{
if (this->contains(entity, message_type))
return; // Already queued
}
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
}
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
// Shift rather than swap, so the queued items keep their order (two events of one entity must not swap)
this->items.push_back({});
std::move_backward(this->items.begin(), this->items.end() - 1, this->items.end());
this->items.front() = {entity, message_type, estimated_size, AUX_DATA_UNUSED};
}
// Clear all items
void clear() {
items.clear();
batch_start_time = 0;
}
// Remove processed items from the front — noinline to keep memmove out of warm callers
void remove_front(size_t count) __attribute__((noinline)) { items.erase(items.begin(), items.begin() + count); }
bool empty() const { return items.empty(); }
size_t size() const { return items.size(); }
const BatchItem &operator[](size_t index) const { return items[index]; }
// Release excess capacity - only releases if items already empty
void release_buffer() {
// Safe to call: batch is processed before release_buffer is called,
// and if any items remain (partial processing), we must not clear them.
// Use swap trick since shrink_to_fit() is non-binding and may be ignored.
if (items.empty()) {
std::vector<BatchItem>().swap(items);
}
}
};
} // namespace esphome::api
#endif // USE_API
+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,177 +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,
final_validate_interface,
interface_has_layer,
)
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."""
if CONF_TIMEOUT in config and interface_has_layer(
config[CONF_PACKET_INTERFACE], CONF_SESSION
):
raise cv.Invalid(
f"Not allowed with a '{CONF_SESSION}' layer, or a link that does its job, 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 = cv.All(
final_validate_interface(CONF_PACKET_INTERFACE), _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)
+16 -2
View File
@@ -31,11 +31,25 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t addr) {
}
#if CONFIG_IDF_TARGET_ARCH_RISCV
// Bounds of this image's flash-mapped code, from the ESP-IDF linker script.
extern "C" char _instruction_reserved_start;
extern "C" char _instruction_reserved_end;
// Check that [start, end) lies in this image's flash-mapped code or in IRAM.
// The IROM range is larger than what the MMU maps, and reading an unmapped
// address faults.
static inline bool is_readable_code(uint32_t start, uint32_t end) {
auto irom_start = reinterpret_cast<uint32_t>(&_instruction_reserved_start);
auto irom_end = reinterpret_cast<uint32_t>(&_instruction_reserved_end);
return (start >= irom_start && end <= irom_end) || (start >= SOC_IRAM_LOW && end <= SOC_IRAM_HIGH);
}
// Check if a code address is a real return address by verifying the preceding
// instruction is a JAL or JALR with rd=ra (x1). Called at log time (not during
// panic) so flash cache is available and both IRAM and IROM are safely readable.
// panic) so flash cache is available. The address may come from an earlier
// build, so check that the bytes before it are mapped before reading them.
static inline bool is_return_addr(uint32_t addr) {
if (!is_code_addr(addr) || addr < 4)
if (!is_code_addr(addr) || !is_readable_code(addr - 4, addr))
return false;
// A return address on the stack points to the instruction after a call.
// Check for 4-byte JAL/JALR call instruction before this address.
@@ -271,7 +271,9 @@ void ESP32Camera::loop() {
fb->timestamp = timestamp;
}
#endif
this->current_image_ = std::make_shared<ESP32CameraImage>(fb, this->single_requesters_ | this->stream_requesters_);
// A request that arrives while this image is handed out is kept for the next frame
const uint8_t single_requesters = this->single_requesters_.exchange(0);
this->current_image_ = std::make_shared<ESP32CameraImage>(fb, single_requesters | this->stream_requesters_);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
ESP_LOGV(TAG, "Got Image: len=%u", fb->len);
@@ -291,7 +293,6 @@ void ESP32Camera::loop() {
listener->on_camera_image(this->current_image_);
}
this->last_update_ = now;
this->single_requesters_ = 0;
}
/* ---------------- constructors ---------------- */
+5 -1
View File
@@ -1,7 +1,7 @@
import esphome.codegen as cg
from esphome.components import climate_ir
import esphome.config_validation as cv
from esphome.const import CONF_MODEL
from esphome.const import CONF_MODEL, CONF_USE_FAHRENHEIT
from esphome.types import ConfigType
from . import gree_ns
@@ -19,6 +19,7 @@ MODELS = Model.options(
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
{
cv.Required(CONF_MODEL): cv.enum(MODELS, lower=True),
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
}
)
@@ -26,5 +27,8 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
async def to_code(config: ConfigType) -> None:
var = await climate_ir.new_climate_ir(config)
cg.add(var.set_model(config[CONF_MODEL]))
if config[CONF_USE_FAHRENHEIT]:
cg.add_define("USE_GREE_FAHRENHEIT")
cg.add(var.set_use_fahrenheit(True))
if config[CONF_MODEL] == "yaw1f":
cg.add_define("USE_GREE_YAW1F")
+30 -9
View File
@@ -1,4 +1,6 @@
#include "gree.h"
#include <cmath>
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/helpers.h"
@@ -34,6 +36,21 @@ void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
this->transmit_state();
}
#ifdef USE_GREE_FAHRENHEIT
uint8_t fahrenheit_half_steps(float target_celsius) {
if (!std::isfinite(target_celsius)) {
return 0;
}
// Whole degrees shown at k = 0 and at the top of the range
static constexpr int MIN_F = static_cast<int>(celsius_to_fahrenheit(GREE_TEMP_MIN));
static constexpr int MAX_F = static_cast<int>(celsius_to_fahrenheit(GREE_TEMP_MAX));
const int desired_f = static_cast<int>(roundf(clamp<float>(celsius_to_fahrenheit(target_celsius), MIN_F, MAX_F)));
// The display at k is (10 * 32 + 18 * GREE_TEMP_MIN + 9 * k) / 10; pick the smallest k that reaches desired_f
const int tenths_above_min = 10 * desired_f - (10 * 32 + 18 * GREE_TEMP_MIN);
return static_cast<uint8_t>((tenths_above_min + 8) / 9);
}
#endif
void GreeClimate::transmit_state() {
uint8_t remote_state[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00};
const Model model = this->model_;
@@ -41,7 +58,12 @@ void GreeClimate::transmit_state() {
const bool yag_layout = model == GREE_YAG || model == GREE_YAW1F;
remote_state[0] = this->fan_speed_() | this->operation_mode_();
#ifdef USE_GREE_FAHRENHEIT
const uint8_t half_steps = this->use_fahrenheit_ ? fahrenheit_half_steps(this->target_temperature) : 0;
remote_state[1] = this->use_fahrenheit_ ? half_steps >> 1 : this->temperature_();
#else
remote_state[1] = this->temperature_();
#endif
if (model == GREE_YAN) {
remote_state[3] = 0x50; // bits 4..7 always 0101
@@ -107,16 +129,15 @@ void GreeClimate::transmit_state() {
remote_state[0] |= GREE_PRESET_SLEEP_BIT;
}
// Calculate the checksum
if (model == GREE_YAN) {
remote_state[7] = ((remote_state[0] << 4) + (remote_state[1] << 4) + 0xC0);
} else {
remote_state[7] =
((((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) +
((remote_state[4] & 0xF0) >> 4) + ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + 0x0A) &
0x0F)
<< 4);
#ifdef USE_GREE_FAHRENHEIT
if (this->use_fahrenheit_) {
remote_state[3] |= GREE_FAHRENHEIT_BIT;
if (half_steps & 1)
remote_state[3] |= GREE_TEMP_HALF_DEGREE_BIT;
}
#endif
remote_state[7] = frame_checksum(remote_state);
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
+27
View File
@@ -1,6 +1,7 @@
#pragma once
#include "esphome/components/climate_ir/climate_ir.h"
#include "esphome/core/defines.h"
namespace esphome::gree {
@@ -90,6 +91,26 @@ static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20;
static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40;
static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80;
// Byte 3 Fahrenheit bits
static constexpr uint8_t GREE_FAHRENHEIT_BIT = 0x08;
static constexpr uint8_t GREE_TEMP_HALF_DEGREE_BIT = 0x04; // Half a degree Celsius added to the temperature
/// Checksum of a state frame: the low nibbles of bytes 0-3 plus the high nibbles of bytes 4-6 plus 0x0A, in the high
/// nibble.
inline uint8_t frame_checksum(const uint8_t *remote_state) {
return (
(((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) +
((remote_state[4] & 0xF0) >> 4) + ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + 0x0A) &
0x0F)
<< 4);
}
#ifdef USE_GREE_FAHRENHEIT
/// Return k = 2 * temp_field + half_degree_bit (0..28) for a Celsius target. The unit displays
/// trunc(celsius_to_fahrenheit(GREE_TEMP_MIN + k / 2)), so k is the smallest value that shows the target in F.
uint8_t fahrenheit_half_steps(float target_celsius);
#endif
// Model codes
enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG, GREE_YAW1F };
@@ -103,6 +124,9 @@ class GreeClimate final : public climate_ir::ClimateIR {
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {}
void set_model(Model model);
#ifdef USE_GREE_FAHRENHEIT
void set_use_fahrenheit(bool use_fahrenheit) { this->use_fahrenheit_ = use_fahrenheit; }
#endif
void set_mode_bit(uint8_t bit_mask, bool enabled);
protected:
@@ -118,6 +142,9 @@ class GreeClimate final : public climate_ir::ClimateIR {
Model model_{};
uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise
#ifdef USE_GREE_FAHRENHEIT
bool use_fahrenheit_{false};
#endif
};
} // namespace esphome::gree
@@ -277,14 +277,14 @@ void I2SAudioMicrophone::mic_task(void *params) {
xEventGroupSetBits(this_microphone->event_group_, MicrophoneEventGroupBits::TASK_RUNNING);
while (!(xEventGroupGetBits(this_microphone->event_group_) & MicrophoneEventGroupBits::COMMAND_STOP)) {
if (this_microphone->data_callbacks_.size() > 0) {
if (this_microphone->has_data_callbacks_()) {
samples.resize(bytes_to_read);
size_t bytes_read = this_microphone->read_(samples.data(), bytes_to_read, 2 * READ_DURATION_MS);
samples.resize(bytes_read);
if (this_microphone->correct_dc_offset_) {
this_microphone->fix_dc_offset_(samples);
}
this_microphone->data_callbacks_.call(samples);
this_microphone->publish_audio_(samples);
} else {
vTaskDelay(pdMS_TO_TICKS(READ_DURATION_MS));
}
+16
View File
@@ -1,6 +1,7 @@
#include "image.h"
#include <algorithm>
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
@@ -165,6 +166,21 @@ Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off
}
#ifdef USE_LVGL
lv_image_dsc_t *Image::get_lv_image_dsc() {
// No pixels yet. A widget keeps this descriptor and reads it on every draw, and LVGL warns on every draw of an image
// without data, so give it a transparent pixel instead
static constexpr uint8_t TRANSPARENT_PIXEL = 0;
if (this->data_start_ == nullptr) {
if (this->dsc_.data != &TRANSPARENT_PIXEL) {
memset(&this->dsc_, 0, sizeof(this->dsc_));
this->dsc_.header.cf = LV_COLOR_FORMAT_A8;
this->dsc_.header.w = 1;
this->dsc_.header.h = 1;
this->dsc_.header.stride = 1;
this->dsc_.data_size = 1;
this->dsc_.data = &TRANSPARENT_PIXEL;
}
return &this->dsc_;
}
// lazily construct lvgl image_dsc.
if (this->dsc_.data != this->data_start_) {
this->dsc_.data = this->data_start_;
+6 -1
View File
@@ -43,7 +43,12 @@ class Image : public display::BaseImage {
bool has_transparency() const { return this->transparency_ != TRANSPARENCY_OPAQUE; }
#ifdef USE_LVGL
lv_image_dsc_t *get_lv_image_dsc();
virtual lv_image_dsc_t *get_lv_image_dsc();
#endif
#ifdef USE_LVGL_IMAGE
/// Called when an LVGL image widget is given this image. An image whose size can change keeps the widget, so it can
/// give the widget its source again when the size changes: the widget keeps the size it read from the descriptor.
virtual void add_lvgl_widget(lv_obj_t *obj) {}
#endif
protected:
/// Per-pixel draw of the clipped range, rows (true) or columns (false) outer.
+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(
+4 -1
View File
@@ -82,7 +82,10 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
#ifdef USE_IMAGE
#ifdef USE_LVGL_IMAGE
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) {
::lv_image_set_src(obj, image->get_lv_image_dsc());
image->add_lvgl_widget(obj);
}
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
+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)
+39 -4
View File
@@ -4,6 +4,8 @@
#include <cstddef>
#include <cstdint>
#include <type_traits>
#include <utility>
#include <vector>
#include "esphome/core/helpers.h"
@@ -16,16 +18,38 @@ enum State : uint8_t {
STATE_STOPPING,
};
/// @brief Adapts a data callback to the (data, capture_time_us) form.
///
/// A callback that already takes the capture time is returned as is; one that only takes the data is wrapped so the
/// capture time is dropped.
template<typename F> auto with_capture_time(F &&callback) {
using Fn = std::decay_t<F>;
if constexpr (std::is_invocable_v<const Fn &, const std::vector<uint8_t> &, int64_t>) {
return Fn(std::forward<F>(callback));
} else {
static_assert(std::is_invocable_v<const Fn &, const std::vector<uint8_t> &>,
"A data callback takes (const std::vector<uint8_t> &) or (const std::vector<uint8_t> &, int64_t)");
return [callback = Fn(std::forward<F>(callback))](const std::vector<uint8_t> &data, int64_t) { callback(data); };
}
}
class Microphone {
public:
virtual void start() = 0;
virtual void stop() = 0;
/// @brief Registers a callback for captured audio.
///
/// The callback takes either (const std::vector<uint8_t> &data) or (const std::vector<uint8_t> &data,
/// int64_t capture_time_us). capture_time_us is when the first frame in data was captured, in esp_timer_get_time()
/// microseconds, or 0 if unknown. Callbacks run on the microphone's own task.
template<typename F> void add_data_callback(F &&data_callback) {
this->data_callbacks_.add([this, data_callback](const std::vector<uint8_t> &data) {
this->data_callbacks_.add([this, callback = with_capture_time(std::forward<F>(data_callback))](
const std::vector<uint8_t> &data, int64_t capture_time_us) {
if (this->mute_state_) {
data_callback(std::vector<uint8_t>(data.size(), 0));
callback(std::vector<uint8_t>(data.size(), 0), capture_time_us);
} else {
data_callback(data);
callback(data, capture_time_us);
}
});
}
@@ -39,12 +63,23 @@ class Microphone {
audio::AudioStreamInfo get_audio_stream_info() { return this->audio_stream_info_; }
protected:
/// @brief Passes captured audio to every registered callback.
/// @param data Captured audio in the microphone's format
/// @param capture_time_us When the first frame in data was captured, in esp_timer_get_time() microseconds, or 0 if
/// unknown
void publish_audio_(const std::vector<uint8_t> &data, int64_t capture_time_us = 0) {
this->data_callbacks_.call(data, capture_time_us);
}
bool has_data_callbacks_() const { return this->data_callbacks_.size() > 0; }
State state_{STATE_STOPPED};
bool mute_state_{false};
audio::AudioStreamInfo audio_stream_info_;
CallbackManager<void(const std::vector<uint8_t> &)> data_callbacks_{};
private:
CallbackManager<void(const std::vector<uint8_t> &, int64_t)> data_callbacks_{};
};
} // namespace esphome::microphone
@@ -45,8 +45,11 @@ class MicrophoneSource final {
/// @param channel 0-indexed channel number to enable
void add_channel(uint8_t channel) { this->channels_.set(channel); }
/// @brief Registers a callback for the processed audio. Takes the same callback forms as
/// Microphone::add_data_callback and passes the capture time through unchanged.
template<typename F> void add_data_callback(F &&data_callback) {
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
this->mic_->add_data_callback([this, callback = with_capture_time(std::forward<F>(data_callback))](
const std::vector<uint8_t> &data, int64_t capture_time_us) {
if (this->enabled_ || this->passive_) {
if (this->processed_samples_ == nullptr) {
// Create vector if its unused
@@ -56,7 +59,7 @@ class MicrophoneSource final {
// Take temporary ownership of samples vector to avoid deallocation before the callback finishes
std::shared_ptr<std::vector<uint8_t>> output_samples = this->processed_samples_;
this->process_audio_(data, *output_samples);
data_callback(*output_samples);
callback(*output_samples, capture_time_us);
}
});
}
+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),
+14 -11
View File
@@ -69,8 +69,9 @@ PackedBits = modbus.modbus_ns.class_("PackedBits")
# The exception code passed to on_error handlers.
ExceptionCode = modbus.modbus_ns.enum("ExceptionCode")
# Lambda argument types for the reply handlers: the device address the send targeted, and the
# request/response PDUs (function code + data). The spans are only valid for the duration of the handler.
# Lambda arguments: the shared handlers and send's on_response get the address of their own request first,
# then the request/response PDUs (function code + data). The spans are only valid for the duration of the handler.
_ADDRESS_ARG = (cg.uint8, "address")
_PDU_SPAN = cg.std_span.template(cg.uint8.operator("const"))
# The pdu lambda's return type: a stack-allocated StaticVector capped at the Modbus PDU limit
@@ -135,11 +136,11 @@ _ACTION_BASE_SCHEMA = cv.Schema(
cv.GenerateID(modbus.CONF_MODBUS_ID): cv.use_id(modbus.ModbusClient),
cv.Required(CONF_ADDRESS): cv.templatable(cv.hex_uint8_t),
# Optional handlers. on_sent fires when the frame reaches the wire; the reply handlers arrive
# later (fire-and-continue), so all run with the request/reply available - not the outer
# automation's variables.
# later (fire-and-continue), so all run with the address and request/reply available - not the
# outer automation's variables.
cv.Optional(CONF_ON_SENT): _handler_schema(),
cv.Optional(CONF_ON_ERROR): _handler_schema(),
# on_no_response takes either a returning lambda (`!lambda "return <bool>;"`, gets `request`,
# on_no_response takes either a returning lambda (`!lambda "return <bool>;"`, gets `address`/`request`,
# returns true to have the hub retry the frame) OR a `then:` automation of actions; the automation
# form may also carry an optional `retry:` returning lambda to run actions AND decide the retry.
cv.Optional(CONF_ON_NO_RESPONSE): cv.All(
@@ -199,7 +200,7 @@ async def register_client_action(
)
if sent_conf := config.get(CONF_ON_SENT):
await automation.build_automation(
var.get_sent_trigger(), [(_PDU_SPAN, "request")], sent_conf
var.get_sent_trigger(), [_ADDRESS_ARG, (_PDU_SPAN, "request")], sent_conf
)
if response_conf := config.get(CONF_ON_RESPONSE):
await automation.build_automation(
@@ -217,7 +218,7 @@ async def register_client_action(
if error_conf := config.get(CONF_ON_ERROR):
await automation.build_automation(
var.get_error_trigger(),
[(_PDU_SPAN, "request"), (ExceptionCode, "exception_code")],
[_ADDRESS_ARG, (_PDU_SPAN, "request"), (ExceptionCode, "exception_code")],
error_conf,
)
if (no_response_conf := config.get(CONF_ON_NO_RESPONSE)) is not None:
@@ -228,18 +229,20 @@ async def register_client_action(
else:
await automation.build_automation(
var.get_no_response_trigger(),
[(_PDU_SPAN, "request")],
[_ADDRESS_ARG, (_PDU_SPAN, "request")],
no_response_conf,
)
retry_conf = no_response_conf.get(CONF_RETRY)
if retry_conf is not None:
retry_lambda = await cg.process_lambda(
retry_conf, [(_PDU_SPAN, "request")], return_type=cg.bool_
retry_conf, [_ADDRESS_ARG, (_PDU_SPAN, "request")], return_type=cg.bool_
)
cg.add(var.set_retry(retry_lambda))
if not_sent_conf := config.get(CONF_ON_NOT_SENT):
await automation.build_automation(
var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf
var.get_not_sent_trigger(),
[_ADDRESS_ARG, (_PDU_SPAN, "request")],
not_sent_conf,
)
# Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the
# matching direction so a write action never generates a read option's setter.
@@ -265,7 +268,7 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args):
var,
config,
args,
[(_PDU_SPAN, "request"), (_PDU_SPAN, "response")],
[_ADDRESS_ARG, (_PDU_SPAN, "request"), (_PDU_SPAN, "response")],
)
@@ -13,45 +13,51 @@ namespace esphome::modbus_client {
/// the hub routes every reply (or its lack) straight back to the action that sent it, so there is no
/// central client object and no request matching. The device address is templatable; it is stamped on the
/// device at play() time; the hub routes each reply by device pointer, so a changed address never
/// mis-routes an earlier reply. (The address is not passed to the reply triggers - under overlapping
/// sends it could misreport, and the handler can recompute the expression it configured.)
/// mis-routes an earlier reply. The handlers wired here and send's on_response get `address` first: the
/// address of their own request, which the hub takes from its frame, so overlapping sends each report their own.
template<typename... Ts> class ClientActionBase : public Action<Ts...>, public modbus::ModbusClientDevice {
public:
TEMPLATABLE_VALUE(uint8_t, target_address) // the modbus device address
Trigger<std::span<const uint8_t>> *get_sent_trigger() { return &this->sent_trigger_; }
Trigger<std::span<const uint8_t>, modbus::ExceptionCode> *get_error_trigger() { return &this->error_trigger_; }
Trigger<std::span<const uint8_t>> *get_no_response_trigger() { return &this->no_response_trigger_; }
Trigger<std::span<const uint8_t>> *get_not_sent_trigger() { return &this->not_sent_trigger_; }
Trigger<uint8_t, std::span<const uint8_t>> *get_sent_trigger() { return &this->sent_trigger_; }
Trigger<uint8_t, std::span<const uint8_t>, modbus::ExceptionCode> *get_error_trigger() {
return &this->error_trigger_;
}
Trigger<uint8_t, std::span<const uint8_t>> *get_no_response_trigger() { return &this->no_response_trigger_; }
Trigger<uint8_t, std::span<const uint8_t>> *get_not_sent_trigger() { return &this->not_sent_trigger_; }
/// The retry decision for on_no_response: given the request PDU, return true to have the hub re-queue
/// The retry decision for on_no_response: given the request (address, PDU), return true to have the hub re-queue
/// the frame. Set from the lambda form or a then: automation's nested retry lambda; may coexist with
/// the no_response trigger (actions run, then this decides the retry).
using retry_func_t = bool (*)(std::span<const uint8_t>);
using retry_func_t = bool (*)(uint8_t, std::span<const uint8_t>);
void set_retry(retry_func_t f) { this->retry_func_ = f; }
/// The frame was written to the wire: fires once per transmission, before any reply, and never for a
/// send that ended in on_not_sent. request_pdu is the PDU sent (function code + data).
void on_sent(std::span<const uint8_t> request_pdu) override { this->sent_trigger_.trigger(request_pdu); }
void on_sent(uint8_t address, std::span<const uint8_t> request_pdu) override {
this->sent_trigger_.trigger(address, request_pdu);
}
/// Never reached the wire, from either of two sources. The hub calls this for a request it accepted
/// and then dropped, which happens only when clear_tx_queue_for_address() retires it - a modbus
/// device going offline, say. Everything the hub refuses at the door instead returns false from
/// queue_pdu() with no callback at all, so send_or_resolve_() below turns those into this same
/// callback: a full queue, a duplicate write, or an empty PDU from a rejecting builder.
void on_not_sent(std::span<const uint8_t> request_pdu) override { this->not_sent_trigger_.trigger(request_pdu); }
void on_not_sent(uint8_t address, std::span<const uint8_t> request_pdu) override {
this->not_sent_trigger_.trigger(address, request_pdu);
}
/// A Modbus exception reply. Lives here beside its trigger so every action subclass gets the pairing:
/// register_client_action() wires on_error for all of them, so a derived class must not have to
/// remember the override.
void on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override {
this->error_trigger_.trigger(request_pdu, exception_code);
void on_error(uint8_t address, std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override {
this->error_trigger_.trigger(address, request_pdu, exception_code);
}
/// No reply within send_wait_time. Run the on_no_response actions (empty in the pure-lambda form),
/// then let the retry lambda, if set, decide whether the hub re-queues the frame (true = retry). The
/// two coexist: a then: automation can also carry a retry lambda. No lambda = no retry.
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_trigger_.trigger(request_pdu);
bool on_no_response(uint8_t address, std::span<const uint8_t> request_pdu) override {
this->no_response_trigger_.trigger(address, request_pdu);
if (this->retry_func_ != nullptr)
return this->retry_func_(request_pdu);
return this->retry_func_(address, request_pdu);
return false;
}
/// Stamp the templated device address before every play(): subclasses cannot forget it, and the hub
@@ -70,13 +76,13 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
/// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call.
void send_or_resolve_(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
if (!this->queue_pdu(pdu, options))
this->on_not_sent(pdu);
this->on_not_sent(this->address_, pdu);
}
Trigger<std::span<const uint8_t>> sent_trigger_;
Trigger<std::span<const uint8_t>, modbus::ExceptionCode> error_trigger_;
Trigger<std::span<const uint8_t>> no_response_trigger_;
Trigger<std::span<const uint8_t>> not_sent_trigger_;
Trigger<uint8_t, std::span<const uint8_t>> sent_trigger_;
Trigger<uint8_t, std::span<const uint8_t>, modbus::ExceptionCode> error_trigger_;
Trigger<uint8_t, std::span<const uint8_t>> no_response_trigger_;
Trigger<uint8_t, std::span<const uint8_t>> not_sent_trigger_;
retry_func_t retry_func_{nullptr};
};
@@ -119,7 +125,7 @@ template<typename... Ts> class WriteCommandOptions {
};
/// modbus_client.send: fire a raw PDU (function code + data; the hub adds address and CRC). The reply is
/// delivered raw - on_response(request, response) - deliberately bypassing the typed dispatch, so
/// delivered raw - on_response(address, request, response) - deliberately bypassing the typed dispatch, so
/// non-standard/custom transactions pass through untouched.
/// The PDU is a stack-allocated modbus::helpers::PduBuffer, so a pdu lambda can build one with the
/// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert).
@@ -133,7 +139,7 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>,
public:
TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu)
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> *get_response_trigger() {
Trigger<uint8_t, std::span<const uint8_t>, std::span<const uint8_t>> *get_response_trigger() {
return &this->response_trigger_;
}
@@ -143,12 +149,13 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>,
this->send_or_resolve_(this->pdu_.value(x...), options);
}
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->response_trigger_.trigger(request_pdu, response_pdu);
void on_response(uint8_t address, std::span<const uint8_t> request_pdu,
std::span<const uint8_t> response_pdu) override {
this->response_trigger_.trigger(address, request_pdu, response_pdu);
}
protected:
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> response_trigger_;
Trigger<uint8_t, std::span<const uint8_t>, std::span<const uint8_t>> response_trigger_;
};
/// Typed actions: these do NOT override the raw on_response, so the base ModbusClientDevice default runs
+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
+3
View File
@@ -42,6 +42,7 @@ ONLINE_IMAGE_SCHEMA = (
# AUTO (Content-Type detection) is online_image specific; not in the shared registry
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, "AUTO", upper=True),
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
cv.Optional(runtime_image.CONF_DOUBLE_BUFFER, default=False): cv.boolean,
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
cv.Schema({cv.string: cv.templatable(cv.string)})
),
@@ -141,6 +142,8 @@ async def setup_online_image(config: ConfigType) -> None:
)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID])
if settings.double_buffer:
cg.add(var.set_double_buffer(True))
for key, value in config.get(CONF_REQUEST_HEADERS, {}).items():
if isinstance(value, Lambda):
@@ -428,9 +428,8 @@ void OnlineImage::finish_download_() {
void OnlineImage::end_connection_() {
// Abort any in-progress decode; the decoder object is kept warm for the next decode.
// Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release().
if (this->is_decoding()) {
RuntimeImage::release();
this->abort_decode();
}
if (this->downloader_) {
this->downloader_->end();
+17 -134
View File
@@ -1,4 +1,3 @@
from collections.abc import Callable
import importlib
import pkgutil
from typing import Any
@@ -7,26 +6,14 @@ 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
from .layers import (
ADDRESSING,
BROADCAST_ADDRESS,
CONF_ADDRESSING,
CONF_DESTINATION,
CONF_PEERS,
CONF_SESSION,
STACK_ORDER,
PacketInterface,
build_stack,
check_packet_sizes,
check_session,
check_single_peer,
layer_needing_session,
packet_interface_ns,
stack_auto_load,
stack_schema,
@@ -42,21 +29,12 @@ CONF_PACKET_INTERFACE = "packet_interface"
for module_info in pkgutil.iter_modules(layers.__path__):
importlib.import_module(f".layers.{module_info.name}", package=__package__)
# The layer keys that each link class does itself, by class name
_PROVIDED_LAYERS: dict[str, tuple[str, ...]] = {}
LINK_LAYER_SCHEMAS = {
cv.Optional(CONF_ADDRESSING): ADDRESSING.schema(),
**stack_schema(),
}
LINK_LAYER_SCHEMAS = stack_schema()
__all__ = [
"CONF_MAX_PACKET_SIZE",
"CONF_PACKET_INTERFACE",
"interface_has_layer",
"interface_layers",
"PacketInterface",
"final_validate_interface",
"new_packet_interface",
"packet_interface_ns",
"packet_interface_schema",
@@ -64,7 +42,7 @@ __all__ = [
def AUTO_LOAD(config: list[ConfigType] | None = None) -> list[str]:
"""Load only what the configured layers use, on the link and in each addressing peer.
"""Load only what the configured layers use.
Without a config, as tooling that lists dependencies calls it, everything a layer could load.
"""
@@ -73,89 +51,11 @@ def AUTO_LOAD(config: list[ConfigType] | None = None) -> list[str]:
loads: set[str] = set()
for interface in config:
loads |= stack_auto_load(interface)
for peer in interface.get(CONF_ADDRESSING, {}).get(CONF_PEERS, []):
loads |= stack_auto_load(peer)
return sorted(loads)
def _validate_layers(config: ConfigType) -> ConfigType:
"""With peers, the layers go in each peer; otherwise check them against the one destination."""
check_session(config)
size = config[CONF_MAX_PACKET_SIZE]
if (addressing := config.get(CONF_ADDRESSING)) is None:
check_packet_sizes(config, size)
return config
size = ADDRESSING.get_type(addressing).packet_size(addressing, size)
if CONF_PEERS in addressing:
for kind in STACK_ORDER:
if kind.key in config:
raise cv.Invalid(
f"With '{CONF_PEERS}', '{kind.key}' goes in each peer",
path=[kind.key],
)
for index, peer in enumerate(addressing[CONF_PEERS]):
try:
check_packet_sizes(peer, size)
except cv.Invalid as err:
err.prepend([CONF_ADDRESSING, CONF_PEERS, index])
raise
return config
addressing.setdefault(CONF_DESTINATION, BROADCAST_ADDRESS)
check_single_peer(config, addressing[CONF_DESTINATION])
check_packet_sizes(config, size)
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.
The id is a link's, or an addressing peer's.
"""
for interface in fv.full_config.get().get(DOMAIN, []):
if interface[CONF_ID].id == interface_id.id:
return interface
for peer in interface.get(CONF_ADDRESSING, {}).get(CONF_PEERS, []):
if peer[CONF_ID].id == interface_id.id:
return peer
return None
def interface_has_layer(interface_id: ID, key: str) -> bool:
"""During final validation, whether the packet interface with this id has the layer, configured or done by its link."""
interface = interface_layers(interface_id)
if interface is None:
return False
return key in interface or key in _PROVIDED_LAYERS.get(
str(interface[CONF_ID].type), ()
)
def final_validate_interface(key: str) -> Callable[[ConfigType], ConfigType]:
"""Final validation for an application: a link with peers must be used through one of the peers."""
def validator(config: ConfigType) -> ConfigType:
interface_id: ID = config[key]
interface = interface_layers(interface_id)
if interface is not None and (
peers := interface.get(CONF_ADDRESSING, {}).get(CONF_PEERS)
):
raise cv.Invalid(
f"'{interface_id.id}' has peers, so use the id of one of them, such as '{peers[0][CONF_ID].id}'",
path=[key],
)
return config
return validator
def _check_provided_session(config: ConfigType) -> ConfigType:
"""A link's own session does not send packets through the layers, so none can need them from the peer."""
if key := layer_needing_session(config):
raise cv.Invalid(
f"This '{key}' needs packets from the peer, which this link does not send through its layers, "
"so it cannot be used on this link",
path=[key],
)
return config
@@ -163,31 +63,21 @@ def packet_interface_schema(
class_: MockObjClass,
default_max_packet_size: int,
max_packet_size_limit: int = 4096,
provides: tuple[str, ...] = (),
) -> cv.Schema:
"""Schema for a link platform; provides names the layer keys, such as reliability, that the link does itself."""
if provides:
_PROVIDED_LAYERS[str(class_)] = provides
link_layers = {
key: (
cv.invalid(f"This link provides '{key}' itself, so it cannot be configured")
if key in provides
else schema
"""Schema for a link platform, with the layers that can be stacked on it."""
return (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(class_),
cv.Optional(
CONF_MAX_PACKET_SIZE, default=default_max_packet_size
): cv.int_range(16, max_packet_size_limit),
**LINK_LAYER_SCHEMAS,
}
)
for key, schema in LINK_LAYER_SCHEMAS.items()
}
schema = cv.Schema(
{
cv.GenerateID(): cv.declare_id(class_),
cv.Optional(
CONF_MAX_PACKET_SIZE, default=default_max_packet_size
): cv.int_range(16, max_packet_size_limit),
**link_layers,
}
).extend(cv.COMPONENT_SCHEMA)
if CONF_SESSION in provides:
schema = schema.add_extra(_check_provided_session)
return schema.add_extra(_validate_layers)
.extend(cv.COMPONENT_SCHEMA)
.add_extra(_validate_layers)
)
async def new_packet_interface(config: ConfigType, *args: Any) -> MockObj:
@@ -196,17 +86,10 @@ async def new_packet_interface(config: ConfigType, *args: Any) -> MockObj:
The link's constructor takes its maximum packet size, usually as a std::span over a buffer that code generation
sized from the configuration, so the two cannot differ.
Returns the link layer; applications reach the top layer with PacketInterface::top(). With several addressing
peers, applications use the peers' ids instead.
Returns the link layer; applications reach the top layer with PacketInterface::top().
"""
var = cg.new_Pvariable(config[CONF_ID], *args)
cg.add(var.set_name(config[CONF_ID].id))
await cg.register_component(var, config)
if (addressing := config.get(CONF_ADDRESSING)) is None:
await build_stack(config, var)
return var
for port, layers_config in await ADDRESSING.get_type(addressing).build(
addressing, var
):
await build_stack(config if layers_config is None else layers_config, port)
await build_stack(config, var)
return var
@@ -1,97 +0,0 @@
#include "basic_addressing.h"
#ifdef USE_PACKET_INTERFACE_BASIC_ADDRESSING
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.addressing");
void BasicAddressing::setup() {
this->link_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
}
void BasicAddressing::dump_config() {
ESP_LOGCONFIG(TAG,
"Basic Addressing on '%s':\n"
" Address: 0x%04X",
this->link_->get_name(), this->address_);
}
bool BasicAddressing::send(const PacketBuffer &data, const PacketMetaData &meta_data, uint16_t destination) {
if (meta_data.destination_address != 0)
destination = meta_data.destination_address;
uint8_t header[HEADER_SIZE] = {static_cast<uint8_t>(destination), static_cast<uint8_t>(destination >> 8),
static_cast<uint8_t>(this->address_), static_cast<uint8_t>(this->address_ >> 8)};
PacketBuffer packet(header, HEADER_SIZE, &data, nullptr, 0);
return this->link_->send_to_interface(packet, meta_data);
}
void BasicAddressing::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
// Lower layers deliver packets in one piece; the payload is passed up without copying
if (!data.is_contiguous()) {
ESP_LOGW(TAG, "%s: Packet split across buffers is not supported", this->link_->get_name());
return;
}
auto packet = data.as_span();
if (packet.size() < HEADER_SIZE) {
ESP_LOGV(TAG, "%s: Packet of %zu bytes is shorter than the address header, dropped", this->link_->get_name(),
packet.size());
return;
}
uint16_t destination = encode_uint16(packet[1], packet[0]);
uint16_t source = encode_uint16(packet[3], packet[2]);
if (source == this->address_ || (destination != this->address_ && destination != BROADCAST_ADDRESS))
return;
AddressingPort *source_port = nullptr;
AddressingPort *broadcast_port = nullptr;
for (auto *port : this->ports_) {
if (port->destination_ == source) {
source_port = port;
} else if (port->destination_ == BROADCAST_ADDRESS) {
broadcast_port = port;
}
}
// A broadcast goes to the broadcast port, even from a device with a port of its own, as that port's layers expect
// only its point-to-point packets; anything else goes to the source's port, or to the broadcast port if it has none
AddressingPort *target = source_port;
if (broadcast_port != nullptr && (destination == BROADCAST_ADDRESS || source_port == nullptr))
target = broadcast_port;
if (target == nullptr) {
ESP_LOGV(TAG, "%s: Packet from 0x%04X dropped", this->link_->get_name(), source);
return;
}
PacketMetaData received = meta_data;
received.source_address = source;
received.destination_address = destination;
target->on_receive_from_interface_(PacketBuffer(packet.data() + HEADER_SIZE, packet.size() - HEADER_SIZE), received);
}
AddressingPort::AddressingPort(BasicAddressing *addressing, uint16_t destination, bool only_port)
: PacketInterface(
remaining_packet_size(addressing->get_link()->get_max_packet_size(), BasicAddressing::HEADER_SIZE)),
addressing_(addressing),
destination_(destination) {
if (only_port) {
this->stack_on_(addressing->get_link(), CAPABILITY_ADDRESSING);
} else {
this->inherit_from_(addressing->get_link(), CAPABILITY_ADDRESSING);
}
// A link with no room leaves the layers above none either, so they fail their setup
addressing->add_port(this);
}
void AddressingPort::dump_config() {
ESP_LOGCONFIG(TAG,
"Addressing Port '%s':\n"
" Max Packet Size: %zu",
this->name_, this->max_packet_size_);
if (this->destination_ == BasicAddressing::BROADCAST_ADDRESS) {
ESP_LOGCONFIG(TAG, " Destination: broadcast");
} else {
ESP_LOGCONFIG(TAG, " Destination: 0x%04X", this->destination_);
}
}
} // namespace esphome::packet_interface
#endif
@@ -1,69 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_PACKET_INTERFACE_BASIC_ADDRESSING
#include "esphome/core/helpers.h"
#include "packet_interface.h"
namespace esphome::packet_interface {
class AddressingPort;
/**
* An addressing layer for channels shared by several devices, such as an RS485 bus or a LoRa radio.
*
* Each packet starts with a 16-bit destination address and a 16-bit source address, little endian.
* BROADCAST_ADDRESS sends to every device. A packet is dropped unless it is addressed to this device or broadcast, and
* a packet from this device's own address, such as an echo on a half-duplex bus, is dropped too.
*
* Above this layer are one or more ports, each the bottom of its own stack of layers. A port with a destination
* address is a point-to-point link to that device: it sends to it and receives only its packets, so the reliability
* and session layers and the rolling code, which work between two devices only, can be used above it. A port with
* the broadcast destination sends to every device, and receives every broadcast, as well as packets from devices
* that have no port of their own. Without a broadcast port, a device's broadcasts go to its own port.
*/
class BasicAddressing : public Component {
public:
static constexpr uint16_t BROADCAST_ADDRESS = 0xFFFF;
/// Destination and source addresses.
static constexpr size_t HEADER_SIZE = 4;
BasicAddressing(PacketInterface *link, uint16_t address) : link_(link), address_(address) {}
void setup() override;
void dump_config() override;
PacketInterface *get_link() const { return this->link_; }
void add_port(AddressingPort *port) { this->ports_.push_back(port); }
/// Sends \p data to \p destination, or to the destination in \p meta_data if set.
bool send(const PacketBuffer &data, const PacketMetaData &meta_data, uint16_t destination);
protected:
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
PacketInterface *link_;
const uint16_t address_;
StaticVector<AddressingPort *, PACKET_INTERFACE_ADDRESSING_PORT_COUNT> ports_;
};
/// The bottom of the stack of layers for one destination, or for broadcast, on an addressed channel.
class AddressingPort : public PacketInterface {
public:
/// With \p only_port, the port is the top of the link, so top() on the link reaches this port's stack.
AddressingPort(BasicAddressing *addressing, uint16_t destination, bool only_port);
void dump_config() override;
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override {
return this->addressing_->send(data, meta_data, this->destination_);
}
uint16_t get_destination() const { return this->destination_; }
protected:
friend class BasicAddressing;
BasicAddressing *addressing_;
const uint16_t destination_;
};
} // namespace esphome::packet_interface
#endif
@@ -1,160 +0,0 @@
#include "basic_fragmentation.h"
#ifdef USE_PACKET_INTERFACE_BASIC_FRAGMENTATION
#include <algorithm>
#include <cstring>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.fragmentation");
static constexpr uint8_t LAST_FRAGMENT = 0x80;
static constexpr uint8_t INDEX_MASK = 0x7F;
BasicFragmentation::BasicFragmentation(PacketInterface *lower, size_t max_packet_size)
: PacketInterface(max_packet_size), lower_(lower) {
this->stack_on_(lower, CAPABILITY_FRAGMENTATION);
}
size_t BasicFragmentation::fragments_for_(size_t size) const {
size_t fragment_size = this->fragment_size_();
if (fragment_size == 0)
return SIZE_MAX;
return std::max<size_t>(1, (size + fragment_size - 1) / fragment_size);
}
void BasicFragmentation::setup() {
if (this->fragment_size_() == 0) {
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
return;
}
if (this->fragments_for_(this->max_packet_size_) > MAX_FRAGMENTS) {
this->mark_failed(LOG_STR("max_packet_size needs more than 128 fragments"));
return;
}
// Checked now, while the layer below holds nothing; a failed layer below reports its own failure instead
this->queue_too_short_ =
!this->lower_->is_failed() && this->fragments_for_(this->max_packet_size_) > this->lower_->get_send_capacity();
RAMAllocator<uint8_t> allocator;
this->tx_buffer_ = allocator.allocate(this->fragment_size_() + this->max_packet_size_);
if (this->tx_buffer_ == nullptr) {
this->mark_failed(LOG_STR("Not enough memory for the packet buffers"));
return;
}
this->rx_buffer_ = this->tx_buffer_ + this->fragment_size_();
// Numbering from a random start makes it unlikely that a packet joins fragments from before a restart
this->tx_number_ = random_uint32();
this->lower_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
}
void BasicFragmentation::dump_config() {
// A link with no room for fragments fails setup, which core reports below
size_t fragments = this->fragment_size_() == 0 ? 0 : this->fragments_for_(this->max_packet_size_);
ESP_LOGCONFIG(TAG,
"Basic Fragmentation on '%s':\n"
" Max Packet Size: %zu\n"
" Fragment Size: %zu\n"
" Fragments in the Largest Packet: %zu",
this->name_, this->max_packet_size_, this->fragment_size_(), fragments);
if (this->queue_too_short_) {
ESP_LOGW(TAG, " The reliability queue is too short for the largest packets, which cannot be sent");
}
}
bool BasicFragmentation::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
if (this->tx_buffer_ == nullptr)
return false;
size_t size = data.size();
if (size > this->max_packet_size_) {
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
return false;
}
size_t fragments = this->fragments_for_(size);
if (fragments > this->lower_->get_send_capacity()) {
ESP_LOGV(TAG, "%s: No room for %zu fragments", this->name_, fragments);
return false;
}
uint8_t header[HEADER_SIZE] = {this->tx_number_++, 0};
if (fragments == 1) {
header[1] = LAST_FRAGMENT;
return this->lower_->send_to_interface(PacketBuffer(header, HEADER_SIZE, &data, nullptr, 0), meta_data);
}
size_t fragment_size = this->fragment_size_();
for (size_t index = 0; index != fragments; index++) {
size_t length = data.copy_to(this->tx_buffer_, fragment_size, index * fragment_size);
header[1] = index | (index + 1 == fragments ? LAST_FRAGMENT : 0);
PacketBuffer body(this->tx_buffer_, length);
if (!this->lower_->send_to_interface(PacketBuffer(header, HEADER_SIZE, &body, nullptr, 0), meta_data)) {
// The peer drops the part already sent when the next packet starts
ESP_LOGV(TAG, "%s: Fragment %zu of %zu not accepted", this->name_, index, fragments);
return false;
}
}
return true;
}
void BasicFragmentation::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
// Lower layers deliver packets in one piece; a packet in one fragment is passed up without copying
if (!data.is_contiguous()) {
ESP_LOGW(TAG, "%s: Packet split across buffers is not supported", this->name_);
return;
}
auto fragment = data.as_span();
if (fragment.size() < HEADER_SIZE) {
ESP_LOGV(TAG, "%s: Fragment of %zu bytes is shorter than its header, dropped", this->name_, fragment.size());
return;
}
uint8_t number = fragment[0];
uint8_t index = fragment[1] & INDEX_MASK;
bool last = (fragment[1] & LAST_FRAGMENT) != 0;
const uint8_t *payload = fragment.data() + HEADER_SIZE;
size_t length = fragment.size() - HEADER_SIZE;
if (index == 0 && last) {
// A packet in one fragment leaves a packet being joined from another device intact
if (this->rx_joining_ && meta_data.source_address == this->rx_source_) {
ESP_LOGV(TAG, "%s: Incomplete packet %u dropped", this->name_, this->rx_number_);
this->rx_joining_ = false;
}
if (length > this->max_packet_size_) {
ESP_LOGV(TAG, "%s: Packet %u is larger than %zu, dropped", this->name_, number, this->max_packet_size_);
return;
}
this->on_receive_from_interface_(PacketBuffer(payload, length), meta_data);
return;
}
if (index == 0) {
if (this->rx_joining_) {
ESP_LOGV(TAG, "%s: Incomplete packet %u dropped", this->name_, this->rx_number_);
}
this->rx_joining_ = true;
this->rx_number_ = number;
this->rx_source_ = meta_data.source_address;
this->rx_next_index_ = 0;
this->rx_length_ = 0;
} else if (this->rx_joining_ && meta_data.source_address != this->rx_source_) {
// Only this fragment is lost, so of two devices sending at once, the one that started last still gets through
ESP_LOGV(TAG, "%s: Fragment %u from 0x%04X dropped", this->name_, index, meta_data.source_address);
return;
} else if (!this->rx_joining_ || number != this->rx_number_ || index != this->rx_next_index_) {
ESP_LOGV(TAG, "%s: Fragment %u of packet %u dropped", this->name_, index, number);
this->rx_joining_ = false;
return;
}
if (this->rx_length_ + length > this->max_packet_size_) {
ESP_LOGV(TAG, "%s: Packet %u is larger than %zu, dropped", this->name_, number, this->max_packet_size_);
this->rx_joining_ = false;
return;
}
memcpy(this->rx_buffer_ + this->rx_length_, payload, length);
this->rx_length_ += length;
this->rx_next_index_++;
if (last) {
this->rx_joining_ = false;
this->on_receive_from_interface_(PacketBuffer(this->rx_buffer_, this->rx_length_), meta_data);
}
}
} // namespace esphome::packet_interface
#endif
@@ -1,60 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_PACKET_INTERFACE_BASIC_FRAGMENTATION
#include "packet_interface.h"
namespace esphome::packet_interface {
/**
* A fragmentation layer: packets up to this layer's max_packet_size are split into fragments that fit the layer
* below, and joined again at the other end.
*
* Each fragment starts with a 2 byte header: an 8-bit packet number, then the fragment's index in bits 0-6, with bit
* 7 set on the last fragment. A packet that fits in one fragment still has the header, and is sent and passed up
* without being copied.
*
* A packet is started only if the layer below can take all of its fragments now (get_send_capacity()), so a full
* reliability queue does not leave the peer with part of a packet. The receiver joins one packet at a time, and drops
* it when a fragment is missing or arrives with another packet number, or when the same source, or another one,
* starts a new packet of several fragments. Other fragments from another source address are dropped, leaving the
* packet being joined intact. Above a reliability layer, fragments arrive in order and none are missing.
*/
class BasicFragmentation : public PacketInterface {
public:
/// Packet number, then index and last flag.
static constexpr size_t HEADER_SIZE = 2;
/// The index has 7 bits.
static constexpr size_t MAX_FRAGMENTS = 128;
BasicFragmentation(PacketInterface *lower, size_t max_packet_size);
void setup() override;
void dump_config() override;
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
protected:
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
size_t fragment_size_() const {
size_t lower_size = this->lower_->get_max_packet_size();
return lower_size > HEADER_SIZE ? lower_size - HEADER_SIZE : 0;
}
size_t fragments_for_(size_t size) const;
PacketInterface *lower_;
// One fragment being sent, and the packet being joined; one block allocated in setup(), and tx_buffer_ stays null
// if that fails
uint8_t *tx_buffer_{nullptr};
uint8_t *rx_buffer_{nullptr};
size_t rx_length_{0};
uint16_t rx_source_{0};
uint8_t tx_number_{0};
uint8_t rx_number_{0};
uint8_t rx_next_index_{0};
bool rx_joining_{false};
bool queue_too_short_{false};
};
} // namespace esphome::packet_interface
#endif
@@ -2,7 +2,7 @@
Each module in this package implements one layer type and registers it with its kind, for example:
@RELIABILITY.register("hdlc", schema=HDLC_SCHEMA, single_peer=lambda config: True)
@RELIABILITY.register("hdlc", schema=HDLC_SCHEMA)
async def build_hdlc(config: ConfigType, lower: MockObj) -> MockObj:
...
@@ -16,23 +16,17 @@ from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ENCRYPTION, CONF_FRAGMENTATION, CONF_KEY, CONF_TYPE
from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_TYPE
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
packet_interface_ns = cg.esphome_ns.namespace("packet_interface")
PacketInterface = packet_interface_ns.class_("PacketInterface", cg.Component)
CONF_ADDRESSING = "addressing"
CONF_DESTINATION = "destination"
CONF_PEERS = "peers"
CONF_RELIABILITY = "reliability"
CONF_SESSION = "session"
BROADCAST_ADDRESS = 0xFFFF
# Builds a layer above lower and returns it. An addressing layer instead returns a list of (port, config) pairs, one
# for each stack it carries, where config holds the layers for that stack, or is None for the layers on the link.
# Builds a layer above lower and returns it
LayerBuilder = Callable[[ConfigType, MockObj], Awaitable[Any]]
@@ -44,37 +38,16 @@ def _nothing(config: ConfigType) -> list[str]:
return []
def _same_size(config: ConfigType, size: int) -> int:
return size
def _no_limit(config: ConfigType) -> int | None:
return None
def _fits(config: ConfigType, size: int, capacity: int | None) -> None:
"""Accept any layer below."""
@dataclass(frozen=True)
class LayerType:
"""One implementation of a kind of layer."""
# Returns the schema; called once every layer type is registered, as some schemas contain other layers
schema_factory: Callable[[], Any]
schema: Any
build: LayerBuilder
# True if this configuration keeps state for one peer, so cannot be used with broadcast addressing
single_peer: Callable[[ConfigType], bool]
# True if this configuration works only when the peer sends packets back, which a session layer makes sure of
needs_session: Callable[[ConfigType], bool]
# The components this configuration needs loaded
auto_load: Callable[[ConfigType], list[str]]
# The largest packet this configuration takes, from the largest that the layer below takes, as the C++ works it out
packet_size: Callable[[ConfigType, int], int]
# How many packets this configuration takes at once, or None for no limit
send_capacity: Callable[[ConfigType], int | None]
# Raises cv.Invalid if this configuration cannot work above a layer with this packet size and send capacity
check_lower: Callable[[ConfigType, int, int | None], None]
class LayerKind:
@@ -95,19 +68,11 @@ class LayerKind:
self,
name: str,
*,
schema: Any = None,
schema_factory: Callable[[], Any] | None = None,
single_peer: Callable[[ConfigType], bool] = _never,
schema: Any,
needs_session: Callable[[ConfigType], bool] = _never,
auto_load: Callable[[ConfigType], list[str]] = _nothing,
packet_size: Callable[[ConfigType, int], int] = _same_size,
send_capacity: Callable[[ConfigType], int | None] = _no_limit,
check_lower: Callable[[ConfigType, int, int | None], None] = _fits,
) -> Callable[[LayerBuilder], LayerBuilder]:
"""Register a type with its schema, or with a function returning a schema that contains other layers."""
def fixed_schema() -> Any:
return schema
"""Register a type with its schema."""
def decorator(build: LayerBuilder) -> LayerBuilder:
if self.frozen:
@@ -119,16 +84,7 @@ class LayerKind:
raise ValueError(
f"{self.key} layer type '{name}' is already registered"
)
self.types[name] = LayerType(
schema_factory or fixed_schema,
build,
single_peer,
needs_session,
auto_load,
packet_size,
send_capacity,
check_lower,
)
self.types[name] = LayerType(schema, build, needs_session, auto_load)
return build
return decorator
@@ -147,7 +103,7 @@ class LayerKind:
return cv.All(
self._expand,
cv.typed_schema(
{name: layer.schema_factory() for name, layer in self.types.items()},
{name: layer.schema for name, layer in self.types.items()},
default_type=self.default_type,
),
)
@@ -156,15 +112,11 @@ class LayerKind:
return self.types[config[CONF_TYPE]]
# An addressing type's configuration has either a destination or a list of peers, each with an id, a destination
# and its own layers.
ADDRESSING = LayerKind(CONF_ADDRESSING, "basic")
ENCRYPTION = LayerKind(CONF_ENCRYPTION, "xxtea", shorthand=CONF_KEY)
RELIABILITY = LayerKind(CONF_RELIABILITY, "hdlc")
FRAGMENTATION = LayerKind(CONF_FRAGMENTATION, "basic")
SESSION = LayerKind(CONF_SESSION, "basic")
# The kinds stacked above the link, or above each port of an addressing layer, from the bottom up
STACK_ORDER = (ENCRYPTION, RELIABILITY, FRAGMENTATION, SESSION)
# The kinds stacked above the link, from the bottom up
STACK_ORDER = (ENCRYPTION, RELIABILITY, SESSION)
def stack_schema() -> dict:
@@ -172,61 +124,21 @@ def stack_schema() -> dict:
return {cv.Optional(kind.key): kind.schema() for kind in STACK_ORDER}
def layer_needing_session(config: ConfigType) -> str | None:
"""The key of the first layer in this stack that needs packets back from the peer, if any."""
for kind in STACK_ORDER:
if (layer := config.get(kind.key)) is not None and kind.get_type(
layer
).needs_session(layer):
return kind.key
return None
def check_session(config: ConfigType) -> None:
"""Layers that need packets back from the peer need a session layer, whose keep-alives send them."""
if CONF_SESSION not in config and (key := layer_needing_session(config)):
raise cv.Invalid(
f"This '{key}' needs packets from the peer, so it needs a '{CONF_SESSION}' layer, whose "
"keep-alives make sure they are sent",
path=[key],
)
def check_single_peer(config: ConfigType, destination: int) -> None:
"""Layers that keep state for one peer need a destination other than broadcast."""
if destination != BROADCAST_ADDRESS:
if CONF_SESSION in config:
return
for kind in STACK_ORDER:
if (layer := config.get(kind.key)) is not None and kind.get_type(
layer
).single_peer(layer):
).needs_session(layer):
raise cv.Invalid(
f"This '{kind.key}' works between two devices only, so it needs a '{CONF_DESTINATION}' other than "
"broadcast",
f"This '{kind.key}' needs packets from the peer, so it needs a '{CONF_SESSION}' layer, whose "
"keep-alives make sure they are sent",
path=[kind.key],
)
def remaining_packet_size(size: int, used: int) -> int:
return max(size - used, 0)
def check_packet_sizes(config: ConfigType, size: int) -> None:
"""Check each layer in config against the layer below it; size is the largest packet that the stack sits on takes."""
capacity = None
for kind in STACK_ORDER:
if (layer := config.get(kind.key)) is None:
continue
layer_type = kind.get_type(layer)
try:
layer_type.check_lower(layer, size, capacity)
except cv.Invalid as err:
err.prepend([kind.key])
raise
size = layer_type.packet_size(layer, size)
capacity = layer_type.send_capacity(layer)
def stack_auto_load(config: ConfigType) -> set[str]:
"""The components that the layers configured in config need loaded."""
loads: set[str] = set()
@@ -1,132 +0,0 @@
"""Addressing layer with 16-bit addresses, carrying one stack of layers or one for each of several peers."""
from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import (
ADDRESSING,
BROADCAST_ADDRESS,
CONF_DESTINATION,
CONF_PEERS,
PacketInterface,
check_session,
check_single_peer,
packet_interface_ns,
remaining_packet_size,
stack_schema,
)
CONF_PORT_ID = "port_id"
# The destination and source addresses
HEADER_SIZE = 4
BasicAddressing = packet_interface_ns.class_("BasicAddressing", cg.Component)
AddressingPort = packet_interface_ns.class_("AddressingPort", PacketInterface)
_request_port_slot = cg.slot_counter("PACKET_INTERFACE_ADDRESSING_PORT_COUNT")
def _destination(value: Any) -> int:
"""A device address, or broadcast."""
if isinstance(value, str) and value.lower() == "broadcast":
return BROADCAST_ADDRESS
return cv.hex_int_range(1, BROADCAST_ADDRESS)(value)
def _validate_peer(config: ConfigType) -> ConfigType:
check_session(config)
check_single_peer(config, config[CONF_DESTINATION])
return config
def _unique_destinations(peers: list[ConfigType]) -> list[ConfigType]:
destinations = [peer[CONF_DESTINATION] for peer in peers]
if len(set(destinations)) != len(destinations):
raise cv.Invalid("Each peer must have a different destination")
return peers
def _not_own_address(config: ConfigType) -> ConfigType:
"""Packets from this device's own address are dropped as echoes, so it cannot be a destination."""
address = config[CONF_ADDRESS]
if config.get(CONF_DESTINATION) == address:
raise cv.Invalid(
"Must not be this device's own address", path=[CONF_DESTINATION]
)
for index, peer in enumerate(config.get(CONF_PEERS, [])):
if peer[CONF_DESTINATION] == address:
raise cv.Invalid(
"Must not be this device's own address",
path=[CONF_PEERS, index, CONF_DESTINATION],
)
return config
# A function, as each peer holds the other layers, whose schemas exist only once every layer type is registered
def _basic_addressing_schema() -> cv.Schema:
peer_schema = cv.All(
cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(AddressingPort),
cv.Required(CONF_DESTINATION): _destination,
**stack_schema(),
}
),
_validate_peer,
)
return cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BasicAddressing),
cv.GenerateID(CONF_PORT_ID): cv.declare_id(AddressingPort),
cv.Required(CONF_ADDRESS): cv.hex_int_range(1, BROADCAST_ADDRESS - 1),
cv.Optional(CONF_DESTINATION): _destination,
cv.Optional(CONF_PEERS): cv.All(
cv.ensure_list(peer_schema), cv.Length(min=1), _unique_destinations
),
}
).extend(cv.COMPONENT_SCHEMA),
cv.has_at_most_one_key(CONF_DESTINATION, CONF_PEERS),
_not_own_address,
)
async def _new_port(
port_id: ID, addressing: MockObj, destination: int, only_port: bool
) -> MockObj:
_request_port_slot(str(addressing))
port = cg.new_Pvariable(port_id, addressing, destination, only_port)
await cg.register_component(port, {})
return port
@ADDRESSING.register(
"basic",
schema_factory=_basic_addressing_schema,
packet_size=lambda config, size: remaining_packet_size(size, HEADER_SIZE),
)
async def build_basic_addressing(
config: ConfigType, link: MockObj
) -> list[tuple[MockObj, ConfigType | None]]:
cg.add_define("USE_PACKET_INTERFACE_BASIC_ADDRESSING")
hub = cg.new_Pvariable(config[CONF_ID], link, config[CONF_ADDRESS])
await cg.register_component(hub, config)
if (peers := config.get(CONF_PEERS)) is None:
port = await _new_port(
config[CONF_PORT_ID], hub, config[CONF_DESTINATION], True
)
return [(port, None)]
stacks = []
# Each peer has its own stack, named after the peer for logs and saved preferences
for peer in peers:
port = await _new_port(peer[CONF_ID], hub, peer[CONF_DESTINATION], False)
cg.add(port.set_name(peer[CONF_ID].id))
stacks.append((port, peer))
return stacks
@@ -1,56 +0,0 @@
"""Fragmentation layer that splits packets larger than the layers below allow, and joins them again."""
import math
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.cpp_generator import MockObj
from esphome.types import ConfigType
from . import FRAGMENTATION, PacketInterface, packet_interface_ns
# The packet number, and the fragment's index with the flag on the last fragment
HEADER_SIZE = 2
MAX_FRAGMENTS = 128
BasicFragmentation = packet_interface_ns.class_("BasicFragmentation", PacketInterface)
BASIC_FRAGMENTATION_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(BasicFragmentation),
# The packet being joined is held in a buffer of this size
cv.Optional(CONF_MAX_PACKET_SIZE, default=1024): cv.int_range(16, 16384),
}
).extend(cv.COMPONENT_SCHEMA)
def _check_lower(config: ConfigType, size: int, capacity: int | None) -> None:
"""The largest packet must fit in the fragments that the layer below can take at once."""
fragment_size = size - HEADER_SIZE
if fragment_size <= 0:
raise cv.Invalid("The layers below leave no room for fragments")
fragments = max(1, math.ceil(config[CONF_MAX_PACKET_SIZE] / fragment_size))
limit = MAX_FRAGMENTS if capacity is None else min(MAX_FRAGMENTS, capacity)
if fragments > limit:
raise cv.Invalid(
f"A packet of {config[CONF_MAX_PACKET_SIZE]} bytes needs {fragments} fragments of {fragment_size} bytes, "
f"but at most {limit} can be sent at once",
path=[CONF_MAX_PACKET_SIZE],
)
# Fragments from different devices are told apart by the addressing layer's source address, so of two devices sending
# fragmented packets at once, one still gets through; without addressing, the receiver can drop both
@FRAGMENTATION.register(
"basic",
schema=BASIC_FRAGMENTATION_SCHEMA,
packet_size=lambda config, size: config[CONF_MAX_PACKET_SIZE],
check_lower=_check_lower,
)
async def build_basic_fragmentation(config: ConfigType, lower: MockObj) -> MockObj:
cg.add_define("USE_PACKET_INTERFACE_BASIC_FRAGMENTATION")
var = cg.new_Pvariable(config[CONF_ID], lower, config[CONF_MAX_PACKET_SIZE])
await cg.register_component(var, config)
return var
@@ -57,7 +57,6 @@ BASIC_SESSION_SCHEMA = cv.All(
@SESSION.register(
"basic",
schema=BASIC_SESSION_SCHEMA,
single_peer=lambda config: True,
auto_load=lambda config: ["binary_sensor"] if CONF_BINARY_SENSOR in config else [],
)
async def build_basic_session(config: ConfigType, lower: MockObj) -> MockObj:
@@ -6,15 +6,12 @@ from esphome.const import CONF_ID, CONF_TIMEOUT
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import RELIABILITY, PacketInterface, packet_interface_ns, remaining_packet_size
from . import RELIABILITY, PacketInterface, packet_interface_ns
CONF_ACK_DELAY = "ack_delay"
CONF_MAX_QUEUE_LENGTH = "max_queue_length"
CONF_MAX_RETRIES = "max_retries"
# The control byte and the CRC-16
FRAME_OVERHEAD = 3
HdlcReliability = packet_interface_ns.class_("HdlcReliability", PacketInterface)
@@ -50,13 +47,7 @@ HDLC_SCHEMA = cv.All(
)
@RELIABILITY.register(
"hdlc",
schema=HDLC_SCHEMA,
single_peer=lambda config: True,
packet_size=lambda config, size: remaining_packet_size(size, FRAME_OVERHEAD),
send_capacity=lambda config: config[CONF_MAX_QUEUE_LENGTH],
)
@RELIABILITY.register("hdlc", schema=HDLC_SCHEMA)
async def build_hdlc(config: ConfigType, lower: MockObj) -> MockObj:
cg.add_define("USE_PACKET_INTERFACE_HDLC")
var = cg.new_Pvariable(config[CONF_ID], lower)
@@ -8,7 +8,7 @@ from esphome.const import CONF_ID, CONF_KEY
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import ENCRYPTION, PacketInterface, packet_interface_ns, remaining_packet_size
from . import ENCRYPTION, PacketInterface, packet_interface_ns
CONF_ROLLING_CODE_ENABLE = "rolling_code_enable"
@@ -18,10 +18,6 @@ CONF_ROLLING_CODE_ENABLE = "rolling_code_enable"
KEY_SALT = b"esphome packet_interface xxtea"
KEY_ITERATIONS = 600_000
# The check word, and with the rolling code the boot numbers and counter
CHECK_SIZE = 4
ROLLING_SIZE = 12
XxteaEncryption = packet_interface_ns.class_("XxteaEncryption", PacketInterface)
XXTEA_SCHEMA = cv.Schema(
@@ -39,19 +35,12 @@ def derive_key(text: str) -> list[int]:
return [int.from_bytes(digest[i : i + 4], "little") for i in range(0, 32, 4)]
# The rolling code keeps the last counter of one peer, and accepts a packet only once the peer has sent back this
# device's boot number
@ENCRYPTION.register(
"xxtea",
schema=XXTEA_SCHEMA,
single_peer=lambda config: config[CONF_ROLLING_CODE_ENABLE],
# The rolling code accepts a packet only once the peer has sent back this device's boot number
needs_session=lambda config: config[CONF_ROLLING_CODE_ENABLE],
auto_load=lambda config: ["xxtea"],
# XXTEA works on whole 32-bit words
packet_size=lambda config, size: remaining_packet_size(
size & ~3,
CHECK_SIZE + (ROLLING_SIZE if config[CONF_ROLLING_CODE_ENABLE] else 0),
),
)
async def build_xxtea(config: ConfigType, lower: MockObj) -> MockObj:
cg.add_define("USE_PACKET_INTERFACE_XXTEA")
@@ -1,45 +0,0 @@
#pragma once
#include <cstdint>
namespace esphome::packet_interface {
/**
* Decides whether a half-duplex radio link may send while its radio reports that it is receiving a packet.
*
* Sending is refused while the radio is busy, so the packet being received is not lost. A busy period starts when a
* send first finds the radio busy, and ends when a send finds it free or end_busy() is called. Once a busy period has
* lasted max_busy_ms, sends go ahead, so a busy flag that never clears cannot stop sending for good, however often
* sends are tried. Code generation sets max_busy_ms from the time the radio takes to receive the largest packet.
*/
class ListenBeforeTalk {
public:
static constexpr uint32_t DEFAULT_MAX_BUSY_MS = 2000;
void set_max_busy(uint32_t max_busy_ms) { this->max_busy_ms_ = max_busy_ms; }
uint32_t get_max_busy() const { return this->max_busy_ms_; }
/// True if a packet may be sent at \p now; \p busy says whether the radio is receiving.
bool may_send(bool busy, uint32_t now) {
if (!busy) {
this->busy_ = false;
return true;
}
if (!this->busy_) {
this->busy_ = true;
this->busy_since_ms_ = now;
}
return now - this->busy_since_ms_ >= this->max_busy_ms_;
}
/// Ends the busy period, when the radio is seen to be free other than by a send.
void end_busy() { this->busy_ = false; }
/// How long the current busy period has lasted at \p now.
uint32_t busy_for(uint32_t now) const { return now - this->busy_since_ms_; }
protected:
uint32_t busy_since_ms_{0};
uint32_t max_busy_ms_{DEFAULT_MAX_BUSY_MS};
bool busy_{false};
};
} // namespace esphome::packet_interface
@@ -7,12 +7,7 @@ from esphome.components.packet_transport import (
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
CONF_PACKET_INTERFACE,
PacketInterface,
final_validate_interface,
packet_interface_ns,
)
from .. import CONF_PACKET_INTERFACE, PacketInterface, packet_interface_ns
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["packet_interface"]
@@ -27,8 +22,6 @@ CONFIG_SCHEMA = transport_schema(PacketInterfaceTransport).extend(
}
)
FINAL_VALIDATE_SCHEMA = final_validate_interface(CONF_PACKET_INTERFACE)
async def to_code(config: ConfigType) -> None:
interface = await cg.get_variable(config[CONF_PACKET_INTERFACE])
@@ -1,54 +0,0 @@
#include "radio_link.h"
#include <cinttypes>
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.radio");
bool RadioLinkBase::check_size_(size_t size) const {
if (size <= this->max_packet_size_)
return true;
ESP_LOGW(TAG, "Packet of %zu bytes is larger than %zu", size, this->max_packet_size_);
return false;
}
bool RadioLinkBase::may_send_(bool busy) {
uint32_t now = App.get_loop_component_start_time();
if (busy)
this->enable_loop();
if (!this->listen_before_talk_.may_send(busy, now))
return false;
if (busy) {
// The limit is longer than any packet takes, so the radio's busy indication is stuck or the channel is jammed
ESP_LOGW(TAG, "Radio busy for %" PRIu32 "ms, sending anyway", this->listen_before_talk_.busy_for(now));
}
return true;
}
bool RadioLinkBase::skip_empty_() const {
ESP_LOGV(TAG, "Empty packet not sent");
return true;
}
void RadioLinkBase::radio_not_ready_(bool failed) {
if (!failed || this->failed_warned_)
return;
this->failed_warned_ = true;
ESP_LOGW(TAG, "%s: The radio failed to set up, so nothing can be sent", this->name_);
}
void RadioLinkBase::radio_free_() {
this->listen_before_talk_.end_busy();
this->disable_loop();
}
void RadioLinkBase::receive_(const std::vector<uint8_t> &packet, float rssi, float snr) {
PacketMetaData meta_data;
meta_data.rssi = rssi;
meta_data.snr = snr;
this->on_receive_from_interface_(PacketBuffer(packet.data(), packet.size()), meta_data);
}
} // namespace esphome::packet_interface
@@ -1,87 +0,0 @@
#pragma once
#include <vector>
#include "esphome/components/packet_interface/listen_before_talk.h"
#include "esphome/components/packet_interface/packet_interface.h"
namespace esphome::packet_interface {
/// The parts of RadioLink that do not depend on the radio.
class RadioLinkBase : public PacketInterface {
public:
void set_max_busy(uint32_t max_busy_ms) { this->listen_before_talk_.set_max_busy(max_busy_ms); }
protected:
explicit RadioLinkBase(size_t max_packet_size) : PacketInterface(max_packet_size) {}
/// False if a packet of this size is too large.
bool check_size_(size_t size) const;
/// False if sending must wait, as the radio is busy; starts loop() to watch the radio.
bool may_send_(bool busy);
/// Returns true for an empty packet, which the radios cannot send, so there is nothing to retry.
bool skip_empty_() const;
/// Warns once when a send finds the radio failed; until its setup has run, sends are refused quietly.
void radio_not_ready_(bool failed);
/// Ends the busy period when loop() finds the radio free.
void radio_free_();
void receive_(const std::vector<uint8_t> &packet, float rssi, float snr);
ListenBeforeTalk listen_before_talk_;
bool failed_warned_{false};
};
/**
* A packet interface link over a packet radio: each packet is one radio packet, with the RSSI and SNR of received
* packets in the metadata. Sending waits for the radio to finish transmitting. A radio cannot hear while it sends, so
* sending is refused while the radio is receiving a packet. A reliability layer sends the packet again shortly, and
* entity_transport after a delay; other callers drop it, logging at debug level at most. While a busy period lasts,
* loop() watches the radio and ends the period as soon as the radio is free, so a later reception starts a new one.
*
* Radio provides is_receiving(), register_listener(Listener *) and transmit_packet(const uint8_t *, size_t), which
* returns Error::NONE on success. Listener declares on_packet(packet, rssi, snr). MaxPacketSize is the radio's limit.
*/
template<typename Radio, typename Listener, typename Error, size_t MaxPacketSize>
class RadioLink : public RadioLinkBase, public Listener {
public:
RadioLink(Radio *radio, size_t max_packet_size) : RadioLinkBase(max_packet_size), radio_(radio) {}
void setup() override {
this->radio_->register_listener(this);
this->disable_loop();
}
// Runs only during a busy period; a busy flag that never clears keeps it running. A radio that failed meanwhile
// is not read again, as reading it may wait on the radio every time
void loop() override {
if (!this->radio_->is_ready() || !this->radio_->is_receiving())
this->radio_free_();
}
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override {
// The radio is set up after the layers above, which may send from their own setup
if (!this->radio_->is_ready()) {
this->radio_not_ready_(this->radio_->is_failed());
return false;
}
size_t size = data.size();
if (size == 0)
return this->skip_empty_();
if (!this->check_size_(size) || !this->may_send_(this->radio_->is_receiving()))
return false;
if (data.is_contiguous()) {
auto span = data.as_span();
return this->radio_->transmit_packet(span.data(), span.size()) == Error::NONE;
}
uint8_t buf[MaxPacketSize];
data.copy_to(buf, size);
return this->radio_->transmit_packet(buf, size) == Error::NONE;
}
void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) override {
this->receive_(packet, rssi, snr);
}
protected:
Radio *radio_;
};
} // namespace esphome::packet_interface
@@ -1,138 +0,0 @@
"""Shared code generation for the packet interface links over packet radios, such as the SX126x and SX127x."""
from collections.abc import Callable
import math
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 CORE, EsphomeError
from esphome.cpp_generator import MockObjClass
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import new_packet_interface, packet_interface_schema
from .layers import CONF_ADDRESSING, CONF_PEERS, STACK_ORDER
# The radios' payload length register is 8 bits
MAX_PACKET_SIZE = 255
# Listen before talk holds a send back for at most the airtime of the largest packet, and never less than this
MIN_MAX_BUSY_MS = 2000
# Room for the preamble detection and the radio's own delays
MARGIN = 1.25
def packet_airtime_ms(radio: ConfigType, size: int) -> float:
"""The time a radio configured as in radio takes to send a packet of size bytes.
LoRa follows the Semtech formula for an explicit header; the low data rate optimization is assumed when a symbol
takes more than 16 ms, as the drivers enable it. Other modulations count the preamble, sync word, length byte,
payload and CRC at the bitrate, treating the preamble size as bytes, which gives the longer time.
"""
if str(radio["modulation"]) == "LORA":
spreading_factor = radio["spreading_factor"]
bandwidth_hz = (
float(str(radio["bandwidth"]).removesuffix("kHz").replace("_", ".")) * 1000
)
coding_rate = int(str(radio["coding_rate"])[-1]) - 4
symbol_s = (1 << spreading_factor) / bandwidth_hz
low_data_rate = 1 if symbol_s > 0.016 else 0
crc = 16 if radio.get("crc_enable", False) else 0
payload_symbols = 8 + max(
math.ceil(
(8 * size - 4 * spreading_factor + 28 + crc)
/ (4 * (spreading_factor - 2 * low_data_rate))
)
* (coding_rate + 4),
0,
)
return (radio["preamble_size"] + 4.25 + payload_symbols) * symbol_s * 1000
bytes_sent = (
radio["preamble_size"] + len(radio.get("sync_value", [])) + 1 + size + 2
)
return bytes_sent * 8 / radio["bitrate"] * 1000
def max_busy_ms(radio: ConfigType, max_packet_size: int) -> int:
"""How long listen before talk may hold a send back."""
return max(
MIN_MAX_BUSY_MS, math.ceil(packet_airtime_ms(radio, max_packet_size) * MARGIN)
)
def radio_link_schema(
class_: MockObjClass, radio_id_key: str, radio_class: MockObjClass
) -> cv.Schema:
"""Schema for a radio link, referring to its radio by radio_id_key."""
return packet_interface_schema(class_, MAX_PACKET_SIZE, MAX_PACKET_SIZE).extend(
{cv.GenerateID(radio_id_key): cv.use_id(radio_class)}
)
def _radio_config(config: ConfigType, radio_id_key: str) -> ConfigType:
full_config = fv.full_config.get()
return full_config.get_config_for_path(
full_config.get_path_for_id(config[radio_id_key])[:-1]
)
def _two_way_layer(config: ConfigType) -> str | None:
"""The first layer on the link or in a peer that keeps state for one peer, so needs packets back from it."""
for stack in (config, *config.get(CONF_ADDRESSING, {}).get(CONF_PEERS, [])):
for kind in STACK_ORDER:
if (layer := stack.get(kind.key)) is not None and kind.get_type(
layer
).single_peer(layer):
return kind.key
return None
def radio_link_final_validate(
radio_id_key: str,
payload_length_key: str,
rx_start_key: str,
check: Callable[[ConfigType, ConfigType], None] | None = None,
) -> Callable[[ConfigType], ConfigType]:
"""Final validation for a radio link: packets vary in length, the radio listens after sending when a layer needs
replies, and check(link, radio) checks the rest.
"""
def validator(config: ConfigType) -> ConfigType:
radio = _radio_config(config, radio_id_key)
if radio[payload_length_key] != 0:
raise cv.Invalid(
f"Packets vary in length, so the radio's '{payload_length_key}' must be 0",
path=[radio_id_key],
)
if not radio[rx_start_key] and (layer := _two_way_layer(config)) is not None:
raise cv.Invalid(
f"This '{layer}' needs packets from the peer, but the radio sleeps after sending unless "
f"'{rx_start_key}' is true",
path=[radio_id_key],
)
if check is not None:
check(config, radio)
return config
return validator
async def new_radio_link(
config: ConfigType, radio_id_key: str, radio_domain: str
) -> None:
"""Create a radio link, with its listen before talk limit worked out from the radio's settings."""
radio = await cg.get_variable(config[radio_id_key])
var = await new_packet_interface(config, radio, config[CONF_MAX_PACKET_SIZE])
radio_config = next(
(
conf
for conf in CORE.config[radio_domain]
if conf[CONF_ID] == config[radio_id_key]
),
None,
)
if radio_config is None:
raise EsphomeError(f"No {radio_domain} radio with id '{config[radio_id_key]}'")
cg.add(var.set_max_busy(max_busy_ms(radio_config, config[CONF_MAX_PACKET_SIZE])))
@@ -3004,3 +3004,73 @@ register_protocol_action(
(CONF_CHANNELS, "channels", cg.uint16),
(CONF_COMMAND, "command", cg.uint8),
)
# Cardin S438
(
CardinS438Data,
CardinS438BinarySensor,
CardinS438Trigger,
_,
CardinS438Dumper,
) = declare_protocol("CardinS438")
CARDIN_S438_CHANNELS = {
"A": 0x02,
"B": 0x00,
"C": 0x03,
"D": 0x01,
}
def _validate_cardin_s438_code(code):
"""Accept the DIP switches as a 14-character binary string."""
code = cv.string_strict(code)
if len(code) != 14 or any(bit not in "01" for bit in code):
raise cv.Invalid("code must be exactly 14 binary digits, e.g. '10100110101010'")
return int(code, 2)
CARDIN_S438_SCHEMA = cv.Schema(
{
cv.Required(CONF_CODE): _validate_cardin_s438_code,
cv.Optional(CONF_CHANNEL, default="A"): cv.enum(
CARDIN_S438_CHANNELS, upper=True
),
cv.Optional(CONF_COMMAND_REPEATS, default=3): cv.int_range(min=1, max=255),
}
)
@register_binary_sensor("cardin_s438", CardinS438BinarySensor, CARDIN_S438_SCHEMA)
def cardin_s438_binary_sensor(var: MockObj, config: ConfigType) -> None:
cg.add(
var.set_data(
cg.StructInitializer(
CardinS438Data,
("code", config[CONF_CODE]),
("channel", config[CONF_CHANNEL]),
)
)
)
@register_trigger("cardin_s438", CardinS438Trigger, CardinS438Data)
def cardin_s438_trigger(var: MockObj, config: ConfigType) -> None:
"""The trigger takes no options beyond the automation."""
@register_dumper("cardin_s438", CardinS438Dumper)
def cardin_s438_dumper(var: MockObj, config: ConfigType) -> None:
"""The dumper takes no options."""
register_protocol_action(
"cardin_s438",
"CardinS438",
CARDIN_S438_SCHEMA,
(CONF_CODE, "code", cg.uint16),
(CONF_CHANNEL, "channel", cg.uint8),
(CONF_COMMAND_REPEATS, "repeats", cg.uint8),
)
@@ -0,0 +1,159 @@
#include "cardin_s438_protocol.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::remote_base {
ESPHOME_LOG_TAG(TAG, "remote.cardin_s438");
namespace {
/*
* Cardin S438 wire format
*
* Frame: 6400 us sync mark + 40 data bits (5 bytes, LSB-first).
* 0 = 1460 us space, 750 us mark
* 1 = 750 us space, 1460 us mark
*
* Bytes:
* [0..2] Encoded pin fields and seed
* [2] (seed + marker 0x87), modulo 256
* Carries propagate into bytes 1 and 0.
* [3..4] Sum of bytes [0..2], little-endian
*
* Pin code: 14 bits, MSB-first, mapped to X/Y fields:
* Y7,Y6,X7,X6,Y5,X5,Y4,X4,Y3,X3,Y2,Y1,Y0,X2
* Channels in X bits 1:0: A=10, B=00, C=11, D=01.
*
* Three frames per transmission, with 63650 us LOW gaps.
* Seed chosen randomly; no pattern found in captures.
*/
// Frame format and timing, in microseconds.
constexpr uint8_t MARKER = 0x87;
constexpr uint16_t SYNC_MARK_US = 6400;
constexpr uint32_t FRAME_GAP_US = 63650;
constexpr uint8_t WIRE_SIZE = 5;
// Each bit is a space then a mark, and both lengths carry it
constexpr BitTimings CARDIN_TIMINGS{
.one_space = 750, .zero_space = 1460, .one_mark = 1460, .zero_mark = 750, .space_first = true};
// Maps pincode bits, most-significant first, to a bit of the X or Y field: PIN_Y marks Y, the low bits are the bit
// number. In flash, so it stays out of RAM on ESP8266.
constexpr uint8_t PIN_Y = 0x08;
constexpr uint8_t PINCODE_BITS = 14;
constexpr uint8_t PINCODE_MAP[PINCODE_BITS] PROGMEM = {
PIN_Y | 7, PIN_Y | 6, 7, 6, PIN_Y | 5, 5, PIN_Y | 4, 4, PIN_Y | 3, 3, PIN_Y | 2, PIN_Y | 1, PIN_Y | 0, 2,
};
// Convert the pincode and channel to the two decoded pin fields.
void encode_pin_fields(uint16_t pincode, uint8_t channel, uint8_t &plain0, uint8_t &plain1) {
uint8_t x = channel;
uint8_t y = 0;
for (uint8_t i = 0; i < PINCODE_BITS; ++i) {
if (((pincode >> (PINCODE_BITS - 1 - i)) & 1U) == 0)
continue;
const uint8_t entry = progmem_read_byte(&PINCODE_MAP[i]);
(entry & PIN_Y ? y : x) |= 1U << (entry & 0x07);
}
plain1 = ~y;
plain0 = plain1 - x + 1;
}
// Construct the wire bytes for one seed.
void build_wire(uint8_t wire[WIRE_SIZE], uint8_t seed, uint8_t plain0, uint8_t plain1) {
uint16_t sum = MARKER + seed;
wire[2] = sum;
uint8_t key = seed + (sum >> 8);
sum = plain1 + key;
wire[1] = sum;
key += sum >> 8;
wire[0] = plain0 + key;
// checksum over the first three bytes, little-endian.
const uint16_t checksum = sum16_bytes(wire, 3);
wire[3] = checksum;
wire[4] = checksum >> 8;
}
} // namespace
void CardinS438Protocol::encode(RemoteTransmitData *dst, const CardinS438Data &data) {
uint8_t plain0;
uint8_t plain1;
encode_pin_fields(data.code & 0x3FFF, data.channel & 0x03, plain0, plain1);
for (uint8_t frame = 0; frame < data.repeats; ++frame) {
uint8_t wire[WIRE_SIZE];
uint8_t seed = static_cast<uint8_t>(random_uint32() & 0xFF);
build_wire(wire, seed, plain0, plain1);
dst->mark(SYNC_MARK_US);
dst->item_bytes<CARDIN_TIMINGS>(wire, WIRE_SIZE);
dst->space(FRAME_GAP_US);
}
}
optional<CardinS438Data> CardinS438Protocol::decode(RemoteReceiveData src) {
while (src.is_valid()) {
// Find the frame's long sync mark.
if (!src.expect_mark(SYNC_MARK_US)) {
src.advance();
continue;
}
uint8_t wire[WIRE_SIZE];
if (!src.expect_bytes<CARDIN_TIMINGS>(wire, WIRE_SIZE) ||
encode_value_le<uint16_t>(wire + 3) != sum16_bytes(wire, 3)) {
continue;
}
// Recover the seed and undo the encoder's carry transform.
uint8_t seed = wire[2] - MARKER;
uint8_t key = seed;
key += (seed + MARKER) >> 8;
uint8_t plain1 = wire[1] - key;
key += (plain1 + key) >> 8;
uint8_t plain0 = wire[0] - key;
// Recover the original X and Y fields.
uint8_t x = plain1 - plain0 + 1;
uint8_t y = ~plain1;
// Reconstruct the 14-bit code in transmission order.
uint16_t code = 0;
for (const uint8_t &slot : PINCODE_MAP) {
const uint8_t entry = progmem_read_byte(&slot);
code = (code << 1) | (((entry & PIN_Y ? y : x) >> (entry & 0x07)) & 1U);
}
return CardinS438Data{
.code = code,
.channel = static_cast<uint8_t>(x & 0x03),
.seed = seed,
};
}
return {};
}
void CardinS438Protocol::dump(const CardinS438Data &data) {
// The DIP switches as the config takes them; channels as CARDIN_S438_CHANNELS in __init__.py maps them
char code[PINCODE_BITS + 1];
for (uint8_t i = 0; i < PINCODE_BITS; ++i)
code[i] = (data.code >> (PINCODE_BITS - 1 - i)) & 1U ? '1' : '0';
code[PINCODE_BITS] = '\0';
ESP_LOGI(TAG, "Received Cardin S438: code=%s channel=%c seed=0x%02X", code, "BDAC"[data.channel & 0x03], data.seed);
}
} // namespace esphome::remote_base
@@ -0,0 +1,25 @@
#pragma once
#include "esphome/components/remote_base/remote_base.h"
namespace esphome::remote_base {
struct CardinS438Data {
uint16_t code{0};
uint8_t channel{0};
uint8_t seed{0};
uint8_t repeats{3};
bool operator==(const CardinS438Data &rhs) const { return code == rhs.code && channel == rhs.channel; }
};
class CardinS438Protocol : public RemoteProtocol<CardinS438Data> {
public:
void encode(RemoteTransmitData *dst, const CardinS438Data &data);
optional<CardinS438Data> decode(RemoteReceiveData src);
void dump(const CardinS438Data &data);
};
DECLARE_REMOTE_PROTOCOL(CardinS438)
} // namespace esphome::remote_base
@@ -137,7 +137,7 @@ void ResamplerMicrophone::process_audio_(const std::vector<uint8_t> &data) {
}
if (this->resampler_ == nullptr) {
this->data_callbacks_.call(data);
this->publish_audio_(data);
return;
}
@@ -162,7 +162,7 @@ void ResamplerMicrophone::process_audio_(const std::vector<uint8_t> &data) {
if (results.frames_generated > 0) {
this->output_buffer_.resize(this->audio_stream_info_.frames_to_bytes(results.frames_generated));
this->data_callbacks_.call(this->output_buffer_);
this->publish_audio_(this->output_buffer_);
} else if (results.frames_used == 0) {
break; // No progress; drop the rest of the chunk instead of spinning
}
@@ -19,6 +19,7 @@ AUTO_LOAD = ["image"]
CODEOWNERS = ["@guillempages", "@clydebarrow", "@kahrendt"]
DOMAIN = "runtime_image"
CONF_DOUBLE_BUFFER = "double_buffer"
CONF_PLACEHOLDER = "placeholder"
CONF_TRANSPARENCY = "transparency"
@@ -212,6 +213,7 @@ class RuntimeImageSettings:
transparent: cg.MockObj
byte_order_big_endian: bool
placeholder: cg.MockObj | None
double_buffer: bool
async def process_runtime_image_config(config: dict) -> RuntimeImageSettings:
@@ -256,4 +258,5 @@ async def process_runtime_image_config(config: dict) -> RuntimeImageSettings:
transparent=transparent,
byte_order_big_endian=byte_order_big_endian,
placeholder=placeholder,
double_buffer=config.get(CONF_DOUBLE_BUFFER, False),
)
@@ -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};
@@ -3,6 +3,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <utility>
#include <cstdint>
#include <cstring>
#include <limits>
@@ -48,7 +49,14 @@ RuntimeImage::RuntimeImage(ImageFormat format, image::ImageType type, image::Tra
fixed_height_(fixed_height),
placeholder_(placeholder) {}
RuntimeImage::~RuntimeImage() { this->release(); }
RuntimeImage::~RuntimeImage() {
#ifdef USE_LVGL_IMAGE
for (lv_obj_t *obj : this->lvgl_widgets_)
lv_obj_remove_event_cb_with_user_data(obj, lvgl_widget_deleted, this);
this->lvgl_widgets_.clear();
#endif
this->release();
}
int RuntimeImage::resize(int width, int height) {
// Use fixed dimensions if specified (0 means auto-resize)
@@ -69,6 +77,7 @@ int RuntimeImage::resize(int width, int height) {
this->width_ = this->buffer_width_;
this->height_ = this->buffer_height_;
this->data_start_ = this->buffer_;
this->refresh_lvgl_();
}
return result;
}
@@ -176,6 +185,52 @@ void RuntimeImage::map_chroma_key(Color &color) {
}
}
#ifdef USE_LVGL
lv_image_dsc_t *RuntimeImage::get_lv_image_dsc() {
if (this->data_start_ == nullptr && this->placeholder_ != nullptr) {
this->dsc_ = *this->placeholder_->get_lv_image_dsc();
return &this->dsc_;
}
return Image::get_lv_image_dsc();
}
#endif
#ifdef USE_LVGL_IMAGE
void RuntimeImage::add_lvgl_widget(lv_obj_t *obj) {
if (std::find(this->lvgl_widgets_.begin(), this->lvgl_widgets_.end(), obj) != this->lvgl_widgets_.end())
return;
this->lvgl_widgets_.push_back(obj);
lv_obj_add_event_cb(obj, lvgl_widget_deleted, LV_EVENT_DELETE, this);
}
void RuntimeImage::lvgl_widget_deleted(lv_event_t *event) {
auto *image = static_cast<RuntimeImage *>(lv_event_get_user_data(event));
auto &widgets = image->lvgl_widgets_;
widgets.erase(std::remove(widgets.begin(), widgets.end(), lv_event_get_target_obj(event)), widgets.end());
}
#endif
void RuntimeImage::refresh_lvgl_() {
#ifdef USE_LVGL
// Not a virtual call: this also runs from the destructor
lv_image_dsc_t *dsc = this->RuntimeImage::get_lv_image_dsc();
#ifdef USE_LVGL_IMAGE
auto &widgets = this->lvgl_widgets_;
for (size_t i = 0; i < widgets.size();) {
lv_obj_t *obj = widgets[i];
if (lv_image_get_src(obj) == dsc) {
// Setting the same source makes the widget read the size again, and redraws it
::lv_image_set_src(obj, dsc);
i++;
} else {
lv_obj_remove_event_cb_with_user_data(obj, lvgl_widget_deleted, this);
widgets.erase(widgets.begin() + i);
}
}
#endif
#endif
}
void RuntimeImage::draw(int x, int y, display::Display *display, Color color_on, Color color_off) {
if (this->data_start_) {
// If we have a complete image, use the base class draw method
@@ -263,11 +318,21 @@ bool RuntimeImage::end_decode() {
}
// Finalize the image for display
if (!this->progressive_display_) {
if (this->is_double_buffered_()) {
if (this->buffer_ != nullptr) {
// Show the new image, and decode into the old one's buffer next time
std::swap(this->front_buffer_, this->buffer_);
std::swap(this->width_, this->buffer_width_);
std::swap(this->height_, this->buffer_height_);
this->data_start_ = this->front_buffer_;
this->refresh_lvgl_();
}
} else if (!this->progressive_display_) {
// Only now make the image visible
this->width_ = this->buffer_width_;
this->height_ = this->buffer_height_;
this->data_start_ = this->buffer_;
this->refresh_lvgl_();
}
// End the session; the decoder object stays warm so the next decode can
@@ -284,6 +349,15 @@ bool RuntimeImage::decoder_knows_end() const { return this->is_decoding() && thi
void RuntimeImage::release() {
this->release_buffer_();
if (this->front_buffer_) {
RAMAllocator<uint8_t> allocator;
allocator.deallocate(this->front_buffer_, this->get_buffer_size(this->width_, this->height_));
this->front_buffer_ = nullptr;
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->refresh_lvgl_();
}
// End any active decode session; decoders free the format-specific working buffers
// they can (PNG), while the decoder object itself is kept warm for the next decode.
if (this->decoder_) {
@@ -291,6 +365,16 @@ void RuntimeImage::release() {
}
}
void RuntimeImage::abort_decode() {
if (!this->is_double_buffered_()) {
this->release();
return;
}
if (this->decoder_) {
this->decoder_->reset();
}
}
void RuntimeImage::release_buffer_() {
if (this->buffer_) {
if (this->external_buffer_) {
@@ -302,19 +386,25 @@ void RuntimeImage::release_buffer_() {
RAMAllocator<uint8_t> allocator;
allocator.deallocate(this->buffer_, this->get_buffer_size(this->buffer_width_, this->buffer_height_));
}
const bool shown = this->data_start_ == this->buffer_;
this->buffer_ = nullptr;
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->buffer_width_ = 0;
this->buffer_height_ = 0;
#ifdef USE_LVGL
memset(&this->dsc_, 0, sizeof(this->dsc_));
#endif
// With double buffering the buffer decoded into is not the one on show
if (shown) {
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->refresh_lvgl_();
}
}
}
bool RuntimeImage::set_external_buffer(uint8_t *buffer, int width, int height) {
if (this->is_double_buffered_()) {
ESP_LOGE(TAG, "An external buffer cannot be double buffered");
return false;
}
this->release_buffer_();
if (buffer == nullptr || this->get_buffer_size(width, height) == 0) {
// Keep the released state rather than remembering a buffer that cannot be decoded into: an
@@ -3,6 +3,8 @@
#include "esphome/components/image/image.h"
#include "esphome/core/helpers.h"
#include <vector>
#include "image_decoder.h"
#include "image_format.h"
@@ -54,6 +56,13 @@ class RuntimeImage : public image::Image {
void map_chroma_key(Color &color);
int get_buffer_width() const { return this->buffer_width_; }
int get_buffer_height() const { return this->buffer_height_; }
#ifdef USE_LVGL
/// Until an image is loaded, the placeholder's descriptor if there is one
lv_image_dsc_t *get_lv_image_dsc() override;
#endif
#ifdef USE_LVGL_IMAGE
void add_lvgl_widget(lv_obj_t *obj) override;
#endif
// Image drawing interface
void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override;
@@ -118,7 +127,9 @@ class RuntimeImage : public image::Image {
/**
* @brief Check if an image is currently loaded.
*/
bool is_loaded() const { return this->buffer_ != nullptr; }
bool is_loaded() const {
return this->is_double_buffered_() ? this->front_buffer_ != nullptr : this->buffer_ != nullptr;
}
/**
* @brief Get the image format.
@@ -134,6 +145,14 @@ class RuntimeImage : public image::Image {
*/
void release();
/**
* @brief End a decode that will not be finished.
*
* With double buffering the image on show stays and the buffer decoded into is kept for the next
* decode. Otherwise the same as release(), since the image on show was being decoded over.
*/
void abort_decode();
/**
* @brief Decode into a buffer the caller owns, instead of one allocated here.
*
@@ -157,6 +176,8 @@ class RuntimeImage : public image::Image {
* A null buffer or dimensions the image cannot decode at are refused, leaving the image with
* no buffer at all.
*
* Refused when double buffering is on.
*
* @param buffer Memory for a picture of the given size, at least get_buffer_size() bytes.
* @param width Width of the buffer in pixels.
* @param height Height of the buffer in pixels.
@@ -179,6 +200,13 @@ class RuntimeImage : public image::Image {
*/
void set_progressive_display(bool progressive) { this->progressive_display_ = progressive; }
/**
* @brief Set whether to decode into a second buffer, keeping the image on show until the new one is complete.
*
* Both buffers are kept, so a new image of the same size allocates nothing. Ignored with progressive display.
*/
void set_double_buffer(bool double_buffer) { this->double_buffer_ = double_buffer; }
protected:
/**
* @brief Resize the image buffer to the requested dimensions.
@@ -196,6 +224,11 @@ class RuntimeImage : public image::Image {
*/
void release_buffer_();
bool is_double_buffered_() const { return this->double_buffer_ && !this->progressive_display_; }
/// Bring the LVGL descriptor up to date after the image shown changed, and give it again to the widgets showing it
void refresh_lvgl_();
/**
* @brief Get the position in the buffer for a pixel.
*/
@@ -210,6 +243,8 @@ class RuntimeImage : public image::Image {
// Memory management
uint8_t *buffer_{nullptr};
/// With double buffering, the buffer on show, width_ by height_. buffer_ is then the one decoded into
uint8_t *front_buffer_{nullptr};
// Decoder management
std::unique_ptr<ImageDecoder> decoder_{nullptr};
@@ -246,8 +281,14 @@ class RuntimeImage : public image::Image {
// Configuration
bool progressive_display_{false};
bool double_buffer_{false};
/** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */
bool external_buffer_{false};
#ifdef USE_LVGL_IMAGE
static void lvgl_widget_deleted(lv_event_t *event);
/// Widgets given this image. One moved to another image is dropped at the next refresh
std::vector<lv_obj_t *> lvgl_widgets_;
#endif
friend class ImageDecoder;
};
+230 -5
View File
@@ -4,16 +4,29 @@ from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32, mdns, network, psram, socket, wifi
from esphome.components import (
audio,
esp32,
mdns,
microphone,
network,
psram,
socket,
wifi,
)
from esphome.components.const import CONF_ENABLED, CONF_MANUFACTURER
from esphome.components.mdns import DOMAIN as MDNS_DOMAIN
import esphome.config_validation as cv
from esphome.const import (
CONF_BITS_PER_SAMPLE,
CONF_BUFFER_DURATION,
CONF_BUFFER_SIZE,
CONF_CHANNELS,
CONF_ESPHOME,
CONF_FORMAT,
CONF_HEIGHT,
CONF_ID,
CONF_MICROPHONE,
CONF_MODEL,
CONF_NAME,
CONF_PROJECT,
@@ -25,6 +38,7 @@ from esphome.const import (
)
from esphome.core import CORE
from esphome.cpp_generator import MockObj
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -59,6 +73,14 @@ CONF_ON_CLEAR_PAIRING_CODE = "on_clear_pairing_code"
CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded"
CONF_ON_PAIRING_FAILED = "on_pairing_failed"
CONF_CODEC = "codec"
CONF_CHUNK_DURATION = "chunk_duration"
CONF_OPUS_BITRATE = "opus_bitrate"
CONF_OPUS_COMPLEXITY = "opus_complexity"
CONF_ON_STREAMING_STARTED = "on_streaming_started"
CONF_ON_STREAMING_STOPPED = "on_streaming_stopped"
CONF_PRESENT = "present"
# A static pairing code is exactly 8 decimal digits.
STATIC_PAIRING_CODE_DIGITS = 8
@@ -104,6 +126,15 @@ CODECS = {
DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM]
OPUS_SAMPLE_RATE = 48000
SOURCE_CODECS = {
CODEC_OPUS: CODEC_FORMAT_OPUS,
CODEC_PCM: CODEC_FORMAT_PCM,
}
# The source role's limits, matching SourceRoleConfig in sendspin-cpp.
SOURCE_BITS_PER_SAMPLE = (16, 24, 32)
SOURCE_OPUS_SAMPLE_RATES = (8000, 12000, 16000, 24000, 48000)
SOURCE_OPUS_CHUNK_DURATIONS_MS = (5, 10, 20, 40, 60)
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
@@ -117,6 +148,7 @@ AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObj
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig")
ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference")
SourceRoleConfig = sendspin_library_ns.struct("SourceRoleConfig")
# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement
# preference for the player role's transfer buffers.
@@ -176,6 +208,11 @@ class SendspinConfiguration:
visualizer_support: bool = False
pairing_code_display_support: bool = False
switch_types: set[str] = field(default_factory=set)
# Set by the sendspin.source actions and conditions, which need a source block.
source_referenced: bool = False
source_line_sense: bool = False
# The microphone's sample rate, read during final validation.
source_sample_rate: int | None = None
artwork_preferences: list[ConfigType] = field(default_factory=list)
player_config: ConfigType | None = None
@@ -270,6 +307,82 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType:
return config
_SOURCE_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_STREAMING_STARTED, "add_on_source_streaming_started_callback"
),
automation.CallbackAutomation(
CONF_ON_STREAMING_STOPPED, "add_on_source_streaming_stopped_callback"
),
)
def _validate_source_format(config: ConfigType) -> ConfigType:
bits_per_sample = config[CONF_MICROPHONE][CONF_BITS_PER_SAMPLE]
if bits_per_sample not in SOURCE_BITS_PER_SAMPLE:
raise cv.Invalid(
f"{CONF_BITS_PER_SAMPLE} must be one of {SOURCE_BITS_PER_SAMPLE}",
path=[CONF_MICROPHONE, CONF_BITS_PER_SAMPLE],
)
if config[CONF_CODEC] == CODEC_OPUS:
if bits_per_sample != 16:
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires a {CONF_BITS_PER_SAMPLE} of 16",
path=[CONF_MICROPHONE, CONF_BITS_PER_SAMPLE],
)
if (
config[CONF_CHUNK_DURATION].total_milliseconds
not in SOURCE_OPUS_CHUNK_DURATIONS_MS
):
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires {CONF_CHUNK_DURATION} to be one of "
f"{', '.join(f'{ms}ms' for ms in SOURCE_OPUS_CHUNK_DURATIONS_MS)}",
path=[CONF_CHUNK_DURATION],
)
return config
SOURCE_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_MICROPHONE): microphone.microphone_source_schema(
min_bits_per_sample=16,
max_bits_per_sample=32,
min_channels=1,
max_channels=2,
),
cv.Optional(CONF_CODEC, default=CODEC_PCM): cv.one_of(
*SOURCE_CODECS, lower=True
),
cv.Optional(CONF_CHUNK_DURATION, default="20ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(
min=cv.TimePeriod(milliseconds=5),
max=cv.TimePeriod(milliseconds=150),
),
),
# sendspin-cpp refuses any single write longer than 5/8 of the buffer, so a short buffer
# would reject every microphone read.
cv.Optional(CONF_BUFFER_DURATION, default="500ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(milliseconds=100)),
),
cv.Optional(audio.CONF_BUFFER_MEMORY): cv.one_of(
*MEMORY_LOCATIONS, lower=True
),
cv.Optional(CONF_OPUS_BITRATE, default="64kbps"): cv.All(
cv.bps, cv.int_, cv.int_range(min=500, max=512000)
),
cv.Optional(CONF_OPUS_COMPLEXITY, default=2): cv.int_range(min=0, max=10),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_ON_STREAMING_STARTED): automation.validate_automation({}),
cv.Optional(CONF_ON_STREAMING_STOPPED): automation.validate_automation({}),
}
),
_validate_source_format,
)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -295,6 +408,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ON_CLEAR_PAIRING_CODE): automation.validate_automation({}),
cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}),
cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}),
cv.Optional(CONF_SOURCE): SOURCE_SCHEMA,
}
),
cv.only_on_esp32,
@@ -335,9 +449,40 @@ def _final_validate(config: ConfigType) -> ConfigType:
CONF_UNPAIRED_ACCESS,
CONF_STATIC_PAIRING_CODE,
)
_final_validate_source(config)
return config
def _final_validate_source(config: ConfigType) -> None:
data = _get_data()
if (source := config.get(CONF_SOURCE)) is None:
if data.source_referenced:
raise cv.Invalid(
f"The sendspin.source actions and conditions require a '{CONF_SOURCE}' "
"block on the sendspin component"
)
return
mic_source = source[CONF_MICROPHONE]
microphone.final_validate_microphone_source_schema(DOMAIN)(mic_source)
# MicrophoneSource cannot resample, so the stream takes the microphone's own rate.
mic_config = fv.get_declaring_config(mic_source[CONF_MICROPHONE])
sample_rate = mic_config.get(audio.CONF_MAX_SAMPLE_RATE)
if sample_rate is None or mic_config.get(audio.CONF_MIN_SAMPLE_RATE) != sample_rate:
raise cv.Invalid(
"The microphone used by the sendspin source must have a fixed sample rate",
path=[CONF_SOURCE, CONF_MICROPHONE],
)
if source[CONF_CODEC] == CODEC_OPUS and sample_rate not in SOURCE_OPUS_SAMPLE_RATES:
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires the microphone {CONF_SAMPLE_RATE} to be one of "
f"{', '.join(str(rate) for rate in SOURCE_OPUS_SAMPLE_RATES)}",
path=[CONF_SOURCE, CONF_CODEC],
)
data.source_sample_rate = sample_rate
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -347,7 +492,8 @@ def _request_controller_role(config: ConfigType) -> ConfigType:
return config
# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role.
# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role. The
# source action and condition also use it, and need a source block.
SENDSPIN_HUB_ACTION_SCHEMA = automation.maybe_simple_id(
cv.Schema(
{
@@ -381,6 +527,41 @@ automation.register_apply_action(
)
def _request_source(config: ConfigType) -> ConfigType:
"""Mark that a source action or condition is used, so a missing source block is reported."""
_get_data().source_referenced = True
return config
def _request_source_line_sense(config: ConfigType) -> ConfigType:
"""Advertise line sense for the sendspin.source.set_signal action."""
_get_data().source_line_sense = True
return _request_source(config)
automation.register_apply_condition(
"sendspin.source.is_streaming",
cv.All(SENDSPIN_HUB_ACTION_SCHEMA, _request_source),
"is_source_streaming()",
)
# Using the action advertises line sense, so the server knows to expect signal reports.
automation.register_apply_action(
"sendspin.source.set_signal",
cv.All(
cv.Schema(
{
cv.GenerateID(): cv.use_id(SendspinHub),
cv.Required(CONF_PRESENT): cv.templatable(cv.boolean),
}
),
_request_source_line_sense,
),
automation.ApplyField(CONF_PRESENT, "set_source_signal", cg.bool_),
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -433,9 +614,8 @@ async def to_code(config: ConfigType) -> None:
data = _get_data()
# The color and source roles are not yet wired up in ESPHome; disable them in the library for now.
# The color role is not yet wired up in ESPHome; disable it in the library for now.
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False)
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False)
# Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*)
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
@@ -525,7 +705,52 @@ async def to_code(config: ConfigType) -> None:
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False)
if not data.player_support or CODEC_OPUS not in data.player_config[CONF_CODECS]:
if (source := config.get(CONF_SOURCE)) is not None:
cg.add_define("USE_SENDSPIN_SOURCE", True)
mic_source = await microphone.microphone_source_to_code(source[CONF_MICROPHONE])
cg.add(var.set_microphone_source(mic_source))
source_psram_stack = source.get(CONF_TASK_STACK_IN_PSRAM, False)
if source_psram_stack:
psram.request_external_task_stack()
# Designated initializers, so the fields follow SourceRoleConfig's declaration order.
source_struct_fields = [
("sample_rate", data.source_sample_rate),
("chunk_duration_ms", source[CONF_CHUNK_DURATION]),
("capture_buffer_ms", source[CONF_BUFFER_DURATION]),
("opus_bitrate", source[CONF_OPUS_BITRATE]),
]
if (buffer_memory := source.get(audio.CONF_BUFFER_MEMORY)) is not None:
source_struct_fields.append(
("buffer_location", MEMORY_LOCATION_ENUM[buffer_memory])
)
source_struct_fields += [
("codec", SOURCE_CODECS[source[CONF_CODEC]]),
("opus_complexity", source[CONF_OPUS_COMPLEXITY]),
("channels", len(source[CONF_MICROPHONE][CONF_CHANNELS])),
("bit_depth", source[CONF_MICROPHONE][CONF_BITS_PER_SAMPLE]),
("line_sense", data.source_line_sense),
("psram_stack", source_psram_stack),
]
cg.add(
var.set_source_config(
cg.StructInitializer(SourceRoleConfig, *source_struct_fields)
)
)
await automation.build_callback_automations(
var, source, _SOURCE_CALLBACK_AUTOMATIONS
)
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False)
player_uses_opus = (
data.player_support and CODEC_OPUS in data.player_config[CONF_CODECS]
)
source_uses_opus = source is not None and source[CONF_CODEC] == CODEC_OPUS
if not player_uses_opus and not source_uses_opus:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS", False)
if data.visualizer_support:
@@ -158,6 +158,16 @@ void SendspinHub::setup() {
this->client_->add_player(this->player_config_).set_listener(this->player_listener_);
#endif
#ifdef USE_SENDSPIN_SOURCE
this->source_role_ = &this->client_->add_source(this->source_config_);
this->source_role_->set_listener(this);
// THREAD CONTEXT: Microphone task, the role's single producer. A capture time of 0 (unknown) makes the library
// stamp the buffer as ending now.
this->microphone_source_->add_data_callback([this](const std::vector<uint8_t> &data, int64_t capture_time_us) {
this->source_role_->write_audio(data.data(), data.size(), capture_time_us);
});
#endif
// Set before setup() by codegen; an unpaired access switch sets it later, from its own setup().
if (this->unpaired_access_.has_value()) {
this->client_->set_unpaired_access_enabled(*this->unpaired_access_);
@@ -223,6 +233,23 @@ void SendspinHub::dump_config() {
preference.display_offset_ms);
}
#endif
#ifdef USE_SENDSPIN_SOURCE
ESP_LOGCONFIG(TAG,
" Source:\n"
" Codec: %s\n"
" Sample rate: %" PRIu32 " Hz\n"
" Channels: %u\n"
" Bits per sample: %u\n"
" Chunk duration: %" PRIu32 " ms\n"
" Buffer duration: %" PRIu32 " ms\n"
" Line sense: %s",
this->source_config_.codec == sendspin::SendspinCodecFormat::OPUS ? LOG_STR_LITERAL("Opus")
: LOG_STR_LITERAL("PCM"),
this->source_config_.sample_rate, this->source_config_.channels, this->source_config_.bit_depth,
this->source_config_.chunk_duration_ms, this->source_config_.capture_buffer_ms,
YESNO(this->source_config_.line_sense));
#endif
}
// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components)
@@ -564,6 +591,27 @@ sendspin::PlayerRole *SendspinHub::get_player_role() {
}
#endif
#ifdef USE_SENDSPIN_SOURCE
// THREAD CONTEXT: Main loop (invoked from the sendspin.source.set_signal action)
void SendspinHub::set_source_signal(bool present) {
if (this->source_role_ != nullptr) {
this->source_role_->set_signal(present ? sendspin::SourceSignal::PRESENT : sendspin::SourceSignal::ABSENT);
}
}
// THREAD CONTEXT: Main loop (SourceRoleListener override, fired from client_->loop())
void SendspinHub::on_streaming_started() {
this->microphone_source_->start();
this->source_streaming_started_callbacks_.call();
}
// THREAD CONTEXT: Main loop (SourceRoleListener override, fired from client_->loop())
void SendspinHub::on_streaming_stopped() {
this->microphone_source_->stop();
this->source_streaming_stopped_callbacks_.call();
}
#endif
} // namespace esphome::sendspin_
#endif // USE_ESP_IDF
@@ -31,6 +31,11 @@
#ifdef USE_SENDSPIN_PLAYER
#include <sendspin/player_role.h>
#endif
#ifdef USE_SENDSPIN_SOURCE
#include "esphome/components/microphone/microphone_source.h"
#include <sendspin/source_role.h>
#endif
#include <array>
#include <cstddef>
@@ -75,6 +80,7 @@ enum SendspinFeature : uint32_t {
/// - Handles Sendspin roles that apply to multiple child components (artwork, controller, metadata) so their events
/// can be fanned out. Roles specific to a single component (player) are configured by the hub but owned by the
/// child thereafter, since no fan-out is needed.
/// - The source role streams a microphone. Its data callback runs on the microphone's task.
///
/// The sendspin-cpp library follows this design:
/// - Core and role configuration are passed at client/role construction time as structs. Built in our `setup()`.
@@ -91,6 +97,9 @@ class SendspinHub final : public Component,
#endif
#ifdef USE_SENDSPIN_METADATA
public sendspin::MetadataRoleListener,
#endif
#ifdef USE_SENDSPIN_SOURCE
public sendspin::SourceRoleListener,
#endif
public sendspin::SendspinClientListener,
public sendspin::SendspinNetworkProvider,
@@ -285,6 +294,29 @@ class SendspinHub final : public Component,
sendspin::PlayerRole *get_player_role();
#endif
#ifdef USE_SENDSPIN_SOURCE
void set_source_config(const sendspin::SourceRoleConfig &config) { this->source_config_ = config; }
void set_microphone_source(microphone::MicrophoneSource *microphone_source) {
this->microphone_source_ = microphone_source;
}
/// @brief Reports whether the capture input has a signal; exposed as the sendspin.source.set_signal action.
///
/// Using the action enables line sense in the source config. Ignored before setup(). Must be called from the main
/// loop thread.
void set_source_signal(bool present);
/// @brief Returns whether the source is streaming microphone audio to the server.
bool is_source_streaming() const { return this->source_role_ != nullptr && this->source_role_->is_streaming(); }
template<typename F> void add_on_source_streaming_started_callback(F &&callback) {
this->source_streaming_started_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_source_streaming_stopped_callback(F &&callback) {
this->source_streaming_stopped_callbacks_.add(std::forward<F>(callback));
}
#endif
protected:
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
sendspin::SendspinClientConfig build_client_config_();
@@ -383,6 +415,20 @@ class SendspinHub final : public Component,
sendspin::PlayerRoleConfig player_config_{};
#endif
#ifdef USE_SENDSPIN_SOURCE
// The server opens and closes the stream; the microphone runs only while it is open.
void on_streaming_started() override;
void on_streaming_stopped() override;
sendspin::SourceRoleConfig source_config_{};
sendspin::SourceRole *source_role_{nullptr};
microphone::MicrophoneSource *microphone_source_{nullptr};
// Lazy: each is fed by an optional YAML automation.
LazyCallbackManager<void()> source_streaming_started_callbacks_{};
LazyCallbackManager<void()> source_streaming_stopped_callbacks_{};
#endif
// --- Core member variables ---
// Built once in setup(): make_preference() allocates a backend that is never freed.
+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_
@@ -1,50 +0,0 @@
from esphome.components import packet_interface
from esphome.components.packet_interface.radio_link import (
new_radio_link,
radio_link_final_validate,
radio_link_schema,
)
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
CONF_MODULATION,
CONF_PAYLOAD_LENGTH,
CONF_RX_START,
CONF_SX126X_ID,
CONF_SYNC_VALUE,
DOMAIN,
SX126x,
SX126xListener,
sx126x_ns,
)
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["sx126x"]
SX126xPacketInterface = sx126x_ns.class_(
"SX126xPacketInterface", packet_interface.PacketInterface, SX126xListener
)
CONFIG_SCHEMA = radio_link_schema(SX126xPacketInterface, CONF_SX126X_ID, SX126x)
def _check_fsk(config: ConfigType, radio: ConfigType) -> None:
"""Without LoRa, the radio can tell that a packet is arriving only from its sync word, which listen before talk
needs.
"""
if radio[CONF_MODULATION] != "LORA" and not radio[CONF_SYNC_VALUE]:
raise cv.Invalid(
f"Without LoRa modulation the radio needs a '{CONF_SYNC_VALUE}', so that the link can tell when a packet "
"is arriving and not send over it",
path=[CONF_SX126X_ID],
)
FINAL_VALIDATE_SCHEMA = radio_link_final_validate(
CONF_SX126X_ID, CONF_PAYLOAD_LENGTH, CONF_RX_START, _check_fsk
)
async def to_code(config: ConfigType) -> None:
await new_radio_link(config, CONF_SX126X_ID, DOMAIN)
@@ -1,10 +0,0 @@
#pragma once
#include "esphome/components/packet_interface/radio_link.h"
#include "esphome/components/sx126x/sx126x.h"
namespace esphome::sx126x {
using SX126xPacketInterface = packet_interface::RadioLink<SX126x, SX126xListener, SX126xError, SX126X_MAX_PACKET_SIZE>;
} // namespace esphome::sx126x
+3 -14
View File
@@ -1,7 +1,6 @@
#include "sx126x.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::sx126x {
@@ -403,15 +402,6 @@ void SX126x::loop() {
}
}
bool SX126x::is_receiving() {
// DIO1 signals a received packet, which sending would overwrite in the radio's buffer
if (this->dio1_pin_->digital_read())
return true;
uint8_t buf[2];
this->read_opcode_(RADIO_GET_IRQSTATUS, buf, 2);
return (encode_uint16(buf[0], buf[1]) & (IRQ_HEADER_VALID | IRQ_SYNCWORD_VALID)) != 0;
}
void SX126x::run_image_cal() {
// the following values were taken from section 9.2.1 table 9-2
// in rev 2.1 of the datasheet
@@ -440,11 +430,10 @@ void SX126x::run_image_cal() {
void SX126x::set_mode_rx() {
uint8_t buf[8];
// configure irq params; a valid header or sync word is flagged for is_receiving(), but not signalled on DIO1
// configure irq params
uint16_t irq = IRQ_RX_DONE | IRQ_RX_TX_TIMEOUT | IRQ_CRC_ERROR;
uint16_t flags = irq | IRQ_HEADER_VALID | IRQ_SYNCWORD_VALID;
buf[0] = (flags >> 8) & 0xFF;
buf[1] = (flags >> 0) & 0xFF;
buf[0] = (irq >> 8) & 0xFF;
buf[1] = (irq >> 0) & 0xFF;
buf[2] = (irq >> 8) & 0xFF;
buf[3] = (irq >> 0) & 0xFF;
buf[4] = (IRQ_RADIO_NONE >> 8) & 0xFF;
-2
View File
@@ -104,8 +104,6 @@ class SX126x final : public Component,
return this->transmit_packet(packet.data(), packet.size());
}
void register_listener(SX126xListener *listener) { this->listeners_.push_back(listener); }
/// True while a packet is being received, or has been received and not yet read; sending would lose it.
bool is_receiving();
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
protected:
@@ -1,68 +0,0 @@
from esphome.components import packet_interface
from esphome.components.packet_interface import CONF_MAX_PACKET_SIZE
from esphome.components.packet_interface.radio_link import (
new_radio_link,
radio_link_final_validate,
radio_link_schema,
)
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
CONF_MODULATION,
CONF_PACKET_MODE,
CONF_PAYLOAD_LENGTH,
CONF_RX_START,
CONF_SX127X_ID,
CONF_SYNC_VALUE,
DOMAIN,
SX127x,
SX127xListener,
sx127x_ns,
)
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["sx127x"]
# The FIFO limit outside LoRa mode
FSK_MAX_PACKET_SIZE = 64
SX127xPacketInterface = sx127x_ns.class_(
"SX127xPacketInterface", packet_interface.PacketInterface, SX127xListener
)
CONFIG_SCHEMA = radio_link_schema(SX127xPacketInterface, CONF_SX127X_ID, SX127x)
def _check_fsk(config: ConfigType, radio: ConfigType) -> None:
"""Without LoRa, the radio must send packets, not a continuous bit stream, and holds fewer bytes.
It can tell that a packet is arriving only from its sync word, which listen before talk needs.
"""
if radio[CONF_MODULATION] == "LORA":
return
if not radio.get(CONF_PACKET_MODE, False):
raise cv.Invalid(
f"Without LoRa modulation the radio's '{CONF_PACKET_MODE}' must be true, as a link sends packets",
path=[CONF_SX127X_ID],
)
if not radio.get(CONF_SYNC_VALUE):
raise cv.Invalid(
f"Without LoRa modulation the radio needs a '{CONF_SYNC_VALUE}', so that the link can tell when a "
"packet is arriving and not send over it",
path=[CONF_SX127X_ID],
)
if config[CONF_MAX_PACKET_SIZE] > FSK_MAX_PACKET_SIZE:
raise cv.Invalid(
f"Without LoRa modulation the radio sends at most {FSK_MAX_PACKET_SIZE} bytes",
path=[CONF_MAX_PACKET_SIZE],
)
FINAL_VALIDATE_SCHEMA = radio_link_final_validate(
CONF_SX127X_ID, CONF_PAYLOAD_LENGTH, CONF_RX_START, _check_fsk
)
async def to_code(config: ConfigType) -> None:
await new_radio_link(config, CONF_SX127X_ID, DOMAIN)
@@ -1,10 +0,0 @@
#pragma once
#include "esphome/components/packet_interface/radio_link.h"
#include "esphome/components/sx127x/sx127x.h"
namespace esphome::sx127x {
using SX127xPacketInterface = packet_interface::RadioLink<SX127x, SX127xListener, SX127xError, SX127X_MAX_PACKET_SIZE>;
} // namespace esphome::sx127x
-12
View File
@@ -354,18 +354,6 @@ void SX127x::loop() {
}
}
bool SX127x::is_receiving() {
// In receive mode DIO0 signals a received packet; the transmit FIFO would overwrite it
if (this->dio0_pin_ != nullptr && this->dio0_pin_->digital_read())
return true;
if (this->modulation_ != MOD_LORA) {
// Set from the sync word until the packet has been read; the preamble flag stays set when no sync word follows
return (this->read_register_(REG_IRQ_FLAGS_1) & IRQ_FLAGS_1_SYNC_ADDRESS_MATCH) != 0;
}
return (this->read_register_(REG_MODEM_STAT) &
(MODEM_STAT_SIGNAL_DETECTED | MODEM_STAT_SIGNAL_SYNCHRONIZED | MODEM_STAT_HEADER_VALID)) != 0;
}
void SX127x::run_image_cal() {
if (this->modulation_ == MOD_LORA) {
this->set_mode_(MOD_FSK, MODE_SLEEP);
-2
View File
@@ -88,8 +88,6 @@ class SX127x final : public Component,
return this->transmit_packet(packet.data(), packet.size());
}
void register_listener(SX127xListener *listener) { this->listeners_.push_back(listener); }
/// True while a packet is being received, or has been received and not yet read; sending would lose it.
bool is_receiving();
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
protected:
-12
View File
@@ -45,7 +45,6 @@ enum SX127xReg : uint8_t {
REG_PAYLOAD_LENGTH_LSB = 0x32,
REG_FIFO_THRESH = 0x35,
REG_IMAGE_CAL = 0x3B,
REG_IRQ_FLAGS_1 = 0x3E,
// LoRa registers
REG_FIFO_ADDR_PTR = 0x0D,
REG_FIFO_TX_BASE_ADDR = 0x0E,
@@ -260,17 +259,6 @@ enum SX127xIrqFlags : uint8_t {
CAD_DETECTED = 0x01,
};
enum SX127xIrqFlags1 : uint8_t {
IRQ_FLAGS_1_PREAMBLE_DETECT = 0x02,
IRQ_FLAGS_1_SYNC_ADDRESS_MATCH = 0x01,
};
enum SX127xModemStat : uint8_t {
MODEM_STAT_SIGNAL_DETECTED = 0x01,
MODEM_STAT_SIGNAL_SYNCHRONIZED = 0x02,
MODEM_STAT_HEADER_VALID = 0x08,
};
enum SX127xModemCfg1 : uint8_t {
BW_7_8 = 0x00,
BW_10_4 = 0x10,
-4
View File
@@ -1,4 +0,0 @@
"""TCP links. The packet_interface platform carries packets over a TCP connection."""
CODEOWNERS = ["@clydebarrow"]
DOMAIN = "tcp"
@@ -1,134 +0,0 @@
import esphome.codegen as cg
from esphome.components import binary_sensor, packet_interface, socket
from esphome.components.const import (
CONF_ALLOWED_IPS,
CONF_CONNECTED,
CONF_HOST,
CONF_RECONNECT_INTERVAL,
CONF_ROLE,
)
from esphome.components.packet_interface import (
CONF_MAX_PACKET_SIZE,
new_packet_interface,
packet_interface_schema,
)
from esphome.components.packet_interface.layers import (
CONF_ADDRESSING,
CONF_RELIABILITY,
CONF_SESSION,
)
from esphome.components.packet_interface.layers.basic_session import CONF_KEEP_ALIVE
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_PORT,
CONF_TIMEOUT,
DEVICE_CLASS_CONNECTIVITY,
ENTITY_CATEGORY_DIAGNOSTIC,
)
from esphome.types import ConfigType
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["network"]
def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
"""The binary_sensor component only for the connected sensor; without a config, as tooling calls it, both."""
if not config or CONF_CONNECTED in config:
return ["socket", "binary_sensor"]
return ["socket"]
# Each packet and its 2 byte length must fit the socket link's 1024 byte send buffer
MAX_PACKET_SIZE = 1022
tcp_ns = cg.esphome_ns.namespace("tcp")
TcpPacketInterface = tcp_ns.class_(
"TcpPacketInterface", packet_interface.PacketInterface
)
def _timeout(config: ConfigType) -> ConfigType:
"""Default the timeout to three keep-alive intervals, and require it to be longer than one."""
keep_alive = config[CONF_KEEP_ALIVE].total_milliseconds
if (timeout := config.get(CONF_TIMEOUT)) is None:
config[CONF_TIMEOUT] = cv.positive_time_period_milliseconds(
f"{keep_alive * 3}ms"
)
elif timeout.total_milliseconds <= keep_alive:
raise cv.Invalid("Must be longer than keep_alive", path=[CONF_TIMEOUT])
return config
BASE_SCHEMA = packet_interface_schema(
TcpPacketInterface,
MAX_PACKET_SIZE,
MAX_PACKET_SIZE,
# TCP resends what is lost, joins exactly two devices, and its connection state is the session
provides=(CONF_ADDRESSING, CONF_RELIABILITY, CONF_SESSION),
).extend(
{
cv.Required(CONF_PORT): cv.port,
cv.Optional(
CONF_RECONNECT_INTERVAL, default="5s"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_KEEP_ALIVE, default="10s"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(seconds=1), max=cv.TimePeriod(hours=1)),
),
# Nothing received for this long closes the connection; it must be longer than the peer's keep_alive
cv.Optional(CONF_TIMEOUT): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=cv.TimePeriod(hours=3)),
),
cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_CONNECTIVITY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
CONFIG_SCHEMA = cv.All(
cv.typed_schema(
{
"client": cv.All(
BASE_SCHEMA.extend({cv.Required(CONF_HOST): socket.ipv4_host}),
_timeout,
),
"server": cv.All(
BASE_SCHEMA.extend(
{cv.Optional(CONF_ALLOWED_IPS): socket.IPV4_ALLOW_SCHEMA}
),
_timeout,
),
},
key=CONF_ROLE,
default_type="client",
),
# A client uses one socket; a server one listener and one accepted socket
socket.consume_role_sockets("tcp.packet_interface"),
)
async def to_code(config: ConfigType) -> None:
# The receive buffer's size is known here, so it is a static array rather than allocated at run time
rx_buffer = f"{config[CONF_ID].id}__rx_buffer"
cg.add_global(
cg.RawStatement(f"static uint8_t {rx_buffer}[{config[CONF_MAX_PACKET_SIZE]}];")
)
var = await new_packet_interface(config, cg.RawExpression(rx_buffer))
if config[CONF_ROLE] == "server":
socket.require_tcp_listener()
cg.add(var.set_server(True))
socket.add_ipv4_allow(
var.set_allow, config.get(CONF_ALLOWED_IPS), config[CONF_ID]
)
else:
socket.require_tcp_client_link()
cg.add(var.set_host(config[CONF_HOST]))
cg.add(var.set_port(config[CONF_PORT]))
cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL]))
cg.add(var.set_keep_alive(config[CONF_KEEP_ALIVE]))
cg.add(var.set_receive_timeout(config[CONF_TIMEOUT]))
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_CONNECTED, var.set_connected_sensor)
@@ -1,169 +0,0 @@
#include "tcp_packet_interface.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::tcp {
using packet_interface::PacketBuffer;
using packet_interface::PacketMetaData;
ESPHOME_LOG_TAG(TAG, "tcp.packet_interface");
// The packet length before each packet
static constexpr size_t LENGTH_SIZE = 2;
TcpPacketInterface::TcpPacketInterface(std::span<uint8_t> rx_buffer)
: packet_interface::PacketInterface(rx_buffer.size()), rx_buffer_(rx_buffer) {
this->capabilities_ = packet_interface::CAPABILITY_RELIABILITY | packet_interface::CAPABILITY_SESSION |
packet_interface::CAPABILITY_ADDRESSING;
}
void TcpPacketInterface::setup() {
this->link_.begin(TAG);
#ifdef USE_SOCKET_TCP_LISTENER
this->listener_.begin(TAG);
#endif
#ifdef USE_BINARY_SENSOR
if (this->connected_sensor_ != nullptr)
this->connected_sensor_->publish_initial_state(false);
#endif
}
void TcpPacketInterface::dump_config() {
bool server = false;
#ifdef USE_SOCKET_TCP_LISTENER
server = this->server_;
#endif
ESP_LOGCONFIG(TAG,
"TCP Packet Interface '%s':\n"
" Role: %s\n"
" Address: %s:%u\n"
" Reconnect Interval: %" PRIu32 "ms\n"
" Keep Alive: %" PRIu32 "ms\n"
" Timeout: %" PRIu32 "ms\n"
" Max Packet Size: %zu",
this->name_, server ? LOG_STR_LITERAL("server") : LOG_STR_LITERAL("client"),
server ? LOG_STR_LITERAL("*") : this->link_.host(), this->link_.port(),
this->link_.reconnect_interval(), this->keep_alive_ms_, this->timeout_ms_, this->max_packet_size_);
#ifdef USE_SOCKET_TCP_LISTENER
this->listener_.dump_config();
#endif
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_);
#endif
}
void TcpPacketInterface::on_shutdown() {
this->link_.close();
#ifdef USE_SOCKET_TCP_LISTENER
this->listener_.close();
#endif
}
void TcpPacketInterface::loop() {
#ifdef USE_SOCKET_TCP_LISTENER
if (this->server_) {
// A new client is accepted only once the drop of the last has been reported
this->listener_.poll(this->link_, !this->connected_);
} else {
this->link_.poll();
}
#else
this->link_.poll();
#endif
if (this->link_.connected() != this->connected_)
this->on_connection_(this->link_.connected());
if (!this->connected_)
return;
this->read_();
if (!this->connected_)
return;
uint32_t now = App.get_loop_component_start_time();
if (now - this->last_rx_ms_ > this->timeout_ms_) {
ESP_LOGW(TAG, "%s: Nothing received for %" PRIu32 "ms, closing the connection", this->name_, this->timeout_ms_);
this->link_.close();
this->link_.note_attempt();
this->on_connection_(false);
return;
}
if (now - this->last_tx_ms_ >= this->keep_alive_ms_)
this->queue_frame_(nullptr, 0);
this->link_.flush_tx();
}
void TcpPacketInterface::on_connection_(bool connected) {
ESP_LOGD(TAG, "%s: Connection %s", this->name_, connected ? LOG_STR_LITERAL("up") : LOG_STR_LITERAL("down"));
this->connected_ = connected;
this->rx_have_ = 0;
this->last_rx_ms_ = this->last_tx_ms_ = App.get_loop_component_start_time();
#ifdef USE_BINARY_SENSOR
if (this->connected_sensor_ != nullptr)
this->connected_sensor_->publish_state(connected);
#endif
this->session_callback_.call(connected);
}
void TcpPacketInterface::read_() {
for (;;) {
ssize_t count;
if (this->rx_have_ < LENGTH_SIZE) {
count = this->link_.read(this->rx_header_ + this->rx_have_, LENGTH_SIZE - this->rx_have_);
} else {
count = this->link_.read(this->rx_buffer_.data() + this->rx_have_ - LENGTH_SIZE,
this->rx_length_ + LENGTH_SIZE - this->rx_have_);
}
// Nothing more for now; a dropped link is seen on the next loop
if (count <= 0)
return;
this->last_rx_ms_ = App.get_loop_component_start_time();
this->rx_have_ += count;
if (this->rx_have_ < LENGTH_SIZE)
continue;
if (this->rx_have_ == LENGTH_SIZE) {
this->rx_length_ = encode_uint16(this->rx_header_[1], this->rx_header_[0]);
if (this->rx_length_ > this->max_packet_size_) {
// The stream cannot be resynchronised, so start a new connection
ESP_LOGW(TAG, "%s: Packet of %u bytes is larger than %zu, closing the connection", this->name_,
this->rx_length_, this->max_packet_size_);
this->link_.close();
this->link_.note_attempt();
this->on_connection_(false);
return;
}
}
if (this->rx_have_ == this->rx_length_ + LENGTH_SIZE) {
this->rx_have_ = 0;
// A zero length packet is a keep-alive
if (this->rx_length_ != 0)
this->on_receive_from_interface_(PacketBuffer(this->rx_buffer_.data(), this->rx_length_), {});
}
}
}
bool TcpPacketInterface::queue_frame_(const PacketBuffer *data, size_t size) {
if (this->link_.tx_free() < size + LENGTH_SIZE)
return false;
uint8_t *tail = this->link_.tx_tail();
tail[0] = static_cast<uint8_t>(size);
tail[1] = static_cast<uint8_t>(size >> 8);
if (data != nullptr)
data->copy_to(tail + LENGTH_SIZE, size);
this->link_.tx_commit(size + LENGTH_SIZE);
this->last_tx_ms_ = App.get_loop_component_start_time();
return true;
}
bool TcpPacketInterface::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
size_t size = data.size();
if (size > this->max_packet_size_) {
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
return false;
}
if (!this->connected_ || !this->queue_frame_(&data, size))
return false;
this->link_.flush_tx();
return true;
}
} // namespace esphome::tcp
@@ -1,87 +0,0 @@
#pragma once
#include <cstdint>
#include <span>
#include "esphome/components/packet_interface/packet_interface.h"
#include "esphome/components/socket/tcp_client_link.h"
#ifdef USE_SOCKET_TCP_LISTENER
#include "esphome/components/socket/tcp_listener.h"
#endif
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::tcp {
/**
* A packet interface link over one TCP connection, as a client that connects to a host, or a server that accepts
* one client at a time.
*
* Each packet is sent as a 2 byte length, little endian, then the packet. TCP delivers the stream in order and
* resends what is lost, and the connection joins exactly two devices, so the link provides the reliability,
* addressing and session capabilities itself. Its connection state is the session state.
*
* Each packet is written to the socket as it is sent, so the link takes packets as fast as the socket does and keeps
* the default, unlimited, send capacity; a packet is refused only while the connection is down or the socket is full.
*
* A zero length packet is a keep-alive, sent when nothing else has been sent for keep_alive. TCP can take minutes to
* notice a peer that has gone without closing the connection, so the link closes it, and reconnects or accepts again,
* when nothing has been received for the timeout, which must be longer than the peer's keep-alive interval.
*/
class TcpPacketInterface : public packet_interface::PacketInterface {
public:
/// @param rx_buffer Holds the packet being received; its size is the largest packet, and it must outlive the link
explicit TcpPacketInterface(std::span<uint8_t> rx_buffer);
void setup() override;
void loop() override;
void dump_config() override;
void on_shutdown() override;
bool send_to_interface(const packet_interface::PacketBuffer &data,
const packet_interface::PacketMetaData &meta_data) override;
void set_host(const char *host) { this->link_.set_host(host); }
void set_port(uint16_t port) { this->link_.set_port(port); }
void set_reconnect_interval(uint32_t interval_ms) { this->link_.set_reconnect_interval(interval_ms); }
void set_keep_alive(uint32_t keep_alive_ms) { this->keep_alive_ms_ = keep_alive_ms; }
/// The connection is closed when nothing has been received for this long; the peer's keep_alive sets the pace.
void set_receive_timeout(uint32_t timeout_ms) { this->timeout_ms_ = timeout_ms; }
#ifdef USE_SOCKET_TCP_LISTENER
void set_server(bool server) { this->server_ = server; }
#ifdef USE_SOCKET_IPV4_ALLOW
void set_allow(const socket::Ipv4AllowEntry *entries, size_t count) { this->listener_.set_allow(entries, count); }
#endif
#endif
#ifdef USE_BINARY_SENSOR
void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; }
#endif
bool is_connected() const { return this->connected_; }
protected:
void on_connection_(bool connected);
void read_();
bool queue_frame_(const packet_interface::PacketBuffer *data, size_t size);
socket::TcpClientLink link_;
#ifdef USE_SOCKET_TCP_LISTENER
socket::TcpListener listener_;
bool server_{false};
#endif
#ifdef USE_BINARY_SENSOR
binary_sensor::BinarySensor *connected_sensor_{nullptr};
#endif
const std::span<uint8_t> rx_buffer_;
uint32_t keep_alive_ms_{10000};
uint32_t timeout_ms_{30000};
uint32_t last_rx_ms_{0};
uint32_t last_tx_ms_{0};
uint16_t rx_length_{0};
// Bytes of the current frame received so far, counting the 2 byte length
uint16_t rx_have_{0};
uint8_t rx_header_[2]{};
bool connected_{false};
};
} // namespace esphome::tcp
@@ -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())
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff. Show more