mirror of
https://github.com/esphome/esphome.git
synced 2026-10-23 06:15:58 +00:00
Compare commits
73
Commits
No files matched your search
+5
-7
@@ -175,11 +175,6 @@ esphome/components/ds2484/* @mrk-its
|
||||
esphome/components/ds248x/* @tomwellnitz
|
||||
esphome/components/ds3231/* @linkedupbits
|
||||
esphome/components/dsmr/* @glmnet @PolarGoose
|
||||
esphome/components/duco/* @kokx
|
||||
esphome/components/duco/number/* @kokx
|
||||
esphome/components/duco/select/* @kokx
|
||||
esphome/components/duco/sensor/* @kokx
|
||||
esphome/components/duco/text_sensor/* @kokx
|
||||
esphome/components/duty_time/* @dudanov
|
||||
esphome/components/ecocomfort2/* @gledian
|
||||
esphome/components/ee895/* @Stock-M
|
||||
@@ -192,6 +187,7 @@ 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/es7243e/* @kbx81
|
||||
@@ -379,8 +375,7 @@ esphome/components/m5stack_8angle/* @rnauber
|
||||
esphome/components/mapping/* @clydebarrow
|
||||
esphome/components/matrix_keypad/* @ssieb
|
||||
esphome/components/max17043/* @blacknell
|
||||
esphome/components/max17048/* @B48D81EFCC @ryansch
|
||||
esphome/components/max170xx/* @ryansch
|
||||
esphome/components/max17048/* @B48D81EFCC
|
||||
esphome/components/max31865/* @DAVe3283
|
||||
esphome/components/max31888/* @JiriPrchal
|
||||
esphome/components/max44009/* @berfenger
|
||||
@@ -635,7 +630,9 @@ 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
|
||||
@@ -695,6 +692,7 @@ esphome/components/uart/packet_transport/* @clydebarrow
|
||||
esphome/components/uart_mux/* @kbx81
|
||||
esphome/components/uart_tcp/* @Bascht74
|
||||
esphome/components/udp/* @clydebarrow
|
||||
esphome/components/udp/packet_interface/* @clydebarrow
|
||||
esphome/components/ufire_ec/* @pvizeli
|
||||
esphome/components/ufire_ise/* @pvizeli
|
||||
esphome/components/ufm01/* @ljungqvist
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "ch422g.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ch422g {
|
||||
@@ -60,7 +59,11 @@ bool CH422GComponent::digital_read(uint8_t pin) {
|
||||
}
|
||||
|
||||
void CH422GComponent::digital_write(uint8_t pin, bool value) {
|
||||
assign_bits(this->output_bits_, 1 << pin, value);
|
||||
if (value) {
|
||||
this->output_bits_ |= (1 << pin);
|
||||
} else {
|
||||
this->output_bits_ &= ~(1 << pin);
|
||||
}
|
||||
this->write_outputs_();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "ch423.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
@@ -63,7 +62,11 @@ bool CH423Component::digital_read(uint8_t pin) {
|
||||
}
|
||||
|
||||
void CH423Component::digital_write(uint8_t pin, bool value) {
|
||||
assign_bits(this->output_bits_, 1 << pin, value);
|
||||
if (value) {
|
||||
this->output_bits_ |= (1 << pin);
|
||||
} else {
|
||||
this->output_bits_ &= ~(1 << pin);
|
||||
}
|
||||
this->write_outputs_();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import time, uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DISCOVERY, CONF_ID, CONF_TIME, CONF_TIME_ID
|
||||
from esphome.core import ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@kokx"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
DOMAIN = "duco"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_DISABLE_CRC = "disable_crc"
|
||||
CONF_DUCO_ID = "duco_id"
|
||||
|
||||
duco_ns = cg.esphome_ns.namespace("duco")
|
||||
Duco = duco_ns.class_("Duco", cg.Component, uart.UARTDevice)
|
||||
# Base of every class that sends requests through the hub
|
||||
DucoDevice = duco_ns.class_(
|
||||
"DucoDevice", cg.PollingComponent, cg.Parented.template(Duco)
|
||||
)
|
||||
DucoDiscovery = duco_ns.class_("DucoDiscovery", DucoDevice)
|
||||
DucoTime = duco_ns.class_("DucoTime", cg.PollingComponent, cg.Parented.template(Duco))
|
||||
|
||||
# Entities of the platforms take the hub with this
|
||||
DUCO_COMPONENT_SCHEMA = cv.Schema({cv.GenerateID(CONF_DUCO_ID): cv.use_id(Duco)})
|
||||
|
||||
DISCOVERY_SCHEMA = cv.Schema({cv.GenerateID(): cv.declare_id(DucoDiscovery)}).extend(
|
||||
cv.COMPONENT_SCHEMA
|
||||
)
|
||||
|
||||
TIME_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(DucoTime),
|
||||
cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
|
||||
}
|
||||
).extend(cv.polling_component_schema("15min"))
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(Duco),
|
||||
cv.Optional(CONF_DISABLE_CRC, default=False): cv.boolean,
|
||||
cv.Optional(CONF_DISCOVERY): DISCOVERY_SCHEMA,
|
||||
cv.Optional(CONF_TIME): TIME_SCHEMA,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
DOMAIN,
|
||||
baud_rate=57600,
|
||||
require_tx=True,
|
||||
require_rx=True,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
_request_device_slot = cg.slot_counter("DUCO_DEVICE_COUNT")
|
||||
|
||||
|
||||
async def register_duco_device(var: cg.MockObj, config: ConfigType, hub_id: ID) -> None:
|
||||
"""Register a device that sends requests, and give it an open request slot on its hub."""
|
||||
await cg.register_component(var, config)
|
||||
hub = await cg.get_variable(hub_id)
|
||||
await cg.register_parented(var, hub)
|
||||
_request_device_slot(str(hub_id))
|
||||
cg.add(hub.register_device(var))
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await uart.new_uart_component(config)
|
||||
cg.add(var.set_disable_crc(config[CONF_DISABLE_CRC]))
|
||||
|
||||
if (discovery := config.get(CONF_DISCOVERY)) is not None:
|
||||
discovery_var = cg.new_Pvariable(discovery[CONF_ID])
|
||||
await register_duco_device(discovery_var, discovery, config[CONF_ID])
|
||||
|
||||
if (time_config := config.get(CONF_TIME)) is not None:
|
||||
clock = await cg.get_variable(time_config[CONF_TIME_ID])
|
||||
time_var = cg.new_Pvariable(time_config[CONF_ID], clock)
|
||||
await cg.register_component(time_var, time_config)
|
||||
await cg.register_parented(time_var, var)
|
||||
@@ -1,273 +0,0 @@
|
||||
#include "duco.h"
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "duco");
|
||||
|
||||
static constexpr uint8_t HEADER_START = 0xAA;
|
||||
static constexpr uint8_t HEADER_END = 0x55;
|
||||
static constexpr uint8_t STUFFING = 0x01; // follows an 0xAA inside a message, not part of it
|
||||
static constexpr uint32_t FRAME_GAP_MS = 50;
|
||||
|
||||
static constexpr uint32_t DISCOVERY_START_ID = 0;
|
||||
static constexpr uint32_t DISCOVERY_STEP_ID = 1;
|
||||
static constexpr uint32_t DISCOVERY_START_MS = 16000;
|
||||
static constexpr uint32_t DISCOVERY_STEP_MS = 1600;
|
||||
static constexpr uint8_t DISCOVERY_MAX_ATTEMPTS = 3;
|
||||
|
||||
void Duco::loop() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
// Read first: after a slow loop pass the rest of a message is already waiting, which is not silence
|
||||
this->read_available([this, now](uint8_t byte) {
|
||||
if (this->parse_byte_(byte)) {
|
||||
this->last_byte_ = now;
|
||||
} else {
|
||||
this->reset_frame_();
|
||||
}
|
||||
});
|
||||
|
||||
// The last message of a burst has no header after it, so it ends on silence
|
||||
if (this->rx_size_ != 0 && now - this->last_byte_ > FRAME_GAP_MS) {
|
||||
this->finalize_message_();
|
||||
this->reset_frame_();
|
||||
}
|
||||
}
|
||||
|
||||
void Duco::reset_frame_() {
|
||||
this->rx_size_ = 0;
|
||||
this->escaped_ = false;
|
||||
}
|
||||
|
||||
void Duco::close_request(DucoDevice *device) {
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
for (auto &pending : this->pending_) {
|
||||
if (pending.device == device) {
|
||||
pending.function = 0;
|
||||
pending.missed = false;
|
||||
this->update_warning_();
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool Duco::parse_byte_(uint8_t byte) {
|
||||
const size_t at = this->rx_size_;
|
||||
if (at >= MAX_FRAME_SIZE) {
|
||||
ESP_LOGV(TAG, "Message longer than %u bytes dropped", static_cast<unsigned>(MAX_FRAME_SIZE));
|
||||
return false;
|
||||
}
|
||||
const uint8_t prev = at > 0 ? this->rx_buffer_[at - 1] : 0;
|
||||
|
||||
if (at == 1 && prev == HEADER_START && byte == HEADER_END)
|
||||
return false; // header of the next message
|
||||
|
||||
if (at >= 2) {
|
||||
const bool escaped = this->escaped_;
|
||||
this->escaped_ = false;
|
||||
if (prev == HEADER_START && !escaped) {
|
||||
if (byte == STUFFING) {
|
||||
this->escaped_ = true;
|
||||
return true;
|
||||
}
|
||||
if (at >= 3 && byte == HEADER_END) {
|
||||
this->rx_size_--; // drop the 0xAA of the header
|
||||
this->finalize_message_();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this->rx_buffer_[this->rx_size_++] = byte;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Duco::finalize_message_() {
|
||||
const uint8_t *raw = this->rx_buffer_.data();
|
||||
// length (function, id and data), function, id, data, CRC low, CRC high
|
||||
if (this->rx_size_ < 5 || raw[0] < 2 || this->rx_size_ < raw[0] + 3u) {
|
||||
ESP_LOGV(TAG, "Malformed message dropped: %u bytes, length byte %u", this->rx_size_, raw[0]);
|
||||
return;
|
||||
}
|
||||
const uint8_t length = raw[0];
|
||||
|
||||
const uint16_t computed_crc = crc16(raw, length + 1);
|
||||
const uint16_t remote_crc = encode_uint16(raw[length + 2], raw[length + 1]);
|
||||
if (computed_crc != remote_crc) {
|
||||
if (!this->disable_crc_) {
|
||||
ESP_LOGV(TAG, "CRC check failed: %04X != %04X", computed_crc, remote_crc);
|
||||
return;
|
||||
}
|
||||
ESP_LOGV(TAG, "CRC check failed, ignored: %04X != %04X", computed_crc, remote_crc);
|
||||
}
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
// The box first acknowledges a request with function + 1, then answers with function + 2
|
||||
const DucoMessage message{raw[2], raw + 3, static_cast<uint8_t>(length - 2)};
|
||||
for (auto &pending : this->pending_) {
|
||||
if (pending.function != 0 && pending.id == message.id && pending.function + 2 == raw[1]) {
|
||||
pending.function = 0;
|
||||
pending.missed = false;
|
||||
this->update_warning_();
|
||||
pending.device->receive_response(message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Acknowledgements (odd function codes) are expected and never match
|
||||
if ((raw[1] & 1) == 0) {
|
||||
ESP_LOGV(TAG, "Answer %02X with id %u matches no open request", raw[1], message.id);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
void Duco::update_warning_() {
|
||||
for (const auto &pending : this->pending_) {
|
||||
if (pending.missed) {
|
||||
this->status_set_warning(LOG_STR("No answer from the box"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->status_clear_warning();
|
||||
}
|
||||
#endif
|
||||
|
||||
void Duco::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Duco:\n"
|
||||
" CRC Disabled: %s",
|
||||
YESNO(this->disable_crc_));
|
||||
}
|
||||
|
||||
uint8_t Duco::next_id_() {
|
||||
do {
|
||||
this->last_id_++;
|
||||
} while (this->last_id_ == HEADER_START || this->last_id_ == HEADER_END);
|
||||
return this->last_id_;
|
||||
}
|
||||
|
||||
void Duco::send(DucoDevice *device, uint8_t function, std::initializer_list<uint8_t> data) {
|
||||
if (data.size() > MAX_REQUEST_DATA) {
|
||||
ESP_LOGE(TAG, "Request of %u bytes is too long", static_cast<unsigned>(data.size()));
|
||||
return;
|
||||
}
|
||||
// Not byte stuffed, as in the author's original and no capture shows a request with 0xAA. An 0xAA followed by any
|
||||
// byte but 0x01 or 0x55 reads the same with or without unstuffing on the box, so only those rare pairs are
|
||||
// unverified.
|
||||
const size_t size = data.size();
|
||||
const uint8_t id = this->next_id_();
|
||||
uint8_t frame[5 + MAX_REQUEST_DATA + 2] = {HEADER_START, HEADER_END, static_cast<uint8_t>(size + 2), function, id};
|
||||
std::copy_n(data.begin(), size, frame + 5);
|
||||
const uint16_t crc = crc16(frame + 2, size + 3);
|
||||
frame[size + 5] = crc & 0xFF;
|
||||
frame[size + 6] = crc >> 8;
|
||||
this->write_array(frame, size + 7);
|
||||
this->flush();
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
for (auto &pending : this->pending_) {
|
||||
if (pending.device == device) {
|
||||
pending.missed = pending.function != 0;
|
||||
if (pending.missed) {
|
||||
ESP_LOGV(TAG, "No answer to request %02X with id %u", pending.function, pending.id);
|
||||
this->update_warning_();
|
||||
}
|
||||
pending.id = id;
|
||||
pending.function = function;
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
ESP_LOGV_HEX(TAG, frame + 2, size + 5, sizeof(frame), "Sent %s");
|
||||
}
|
||||
|
||||
static const LogString *node_type_name(uint8_t type) {
|
||||
switch (type) {
|
||||
case 8:
|
||||
return LOG_STR("UCBAT");
|
||||
case 9:
|
||||
return LOG_STR("UC");
|
||||
case 10:
|
||||
return LOG_STR("UCRH");
|
||||
case 12:
|
||||
return LOG_STR("UCCO2");
|
||||
case 13:
|
||||
return LOG_STR("VLV");
|
||||
case 17:
|
||||
return LOG_STR("BOX");
|
||||
case 18:
|
||||
return LOG_STR("SWITCH");
|
||||
default:
|
||||
return LOG_STR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
|
||||
void DucoDiscovery::setup() {
|
||||
// Give the box time to start before walking its node addresses
|
||||
this->set_timeout(DISCOVERY_START_ID, DISCOVERY_START_MS, [this]() {
|
||||
this->set_interval(DISCOVERY_STEP_ID, DISCOVERY_STEP_MS, [this]() { this->update(); });
|
||||
});
|
||||
}
|
||||
|
||||
void DucoDiscovery::update() {
|
||||
if (this->attempts_ == DISCOVERY_MAX_ATTEMPTS) {
|
||||
ESP_LOGV(TAG, "No answer for node %u", this->next_node_);
|
||||
this->next_node_++;
|
||||
this->attempts_ = 0;
|
||||
}
|
||||
if (this->next_node_ >= NODE_COUNT) {
|
||||
this->cancel_interval(DISCOVERY_STEP_ID);
|
||||
this->parent_->close_request(this);
|
||||
return;
|
||||
}
|
||||
// Without an answer by the next step the same node is asked again; a retry is not a missed answer
|
||||
if (this->attempts_ > 0)
|
||||
this->parent_->close_request(this);
|
||||
ESP_LOGV(TAG, "Discovering node %u", this->next_node_);
|
||||
this->attempts_++;
|
||||
this->parent_->send(this, FUNCTION_NODE, {0x01, this->next_node_});
|
||||
}
|
||||
|
||||
void DucoDiscovery::receive_response(const DucoMessage &message) {
|
||||
// Only the latest attempt is open, so a late answer to an earlier one never arrives here
|
||||
if (message.size >= 1 && this->next_node_ < NODE_COUNT) {
|
||||
if (message.data[0] != 0) {
|
||||
ESP_LOGV(TAG, "Found node %u: type %u", this->next_node_, message.data[0]);
|
||||
}
|
||||
this->node_types_[this->next_node_++] = message.data[0];
|
||||
this->attempts_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void DucoDiscovery::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Discovered nodes:");
|
||||
for (uint8_t node = 0; node < NODE_COUNT; node++) {
|
||||
const uint8_t type = this->node_types_[node];
|
||||
if (type != 0) {
|
||||
ESP_LOGCONFIG(TAG, " Node %u: type %u (%s)", node, type, LOG_STR_ARG(node_type_name(type)));
|
||||
}
|
||||
}
|
||||
if (this->next_node_ < NODE_COUNT) {
|
||||
ESP_LOGCONFIG(TAG, " Still discovering, at node %u", this->next_node_);
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_TIME
|
||||
void DucoTime::update() {
|
||||
const ESPTime now = this->clock_->now();
|
||||
if (!now.is_valid()) {
|
||||
ESP_LOGV(TAG, "Time is not valid yet");
|
||||
return;
|
||||
}
|
||||
const uint32_t ts = now.timestamp;
|
||||
this->parent_->send(nullptr, FUNCTION_PARAMETER,
|
||||
{0x05, static_cast<uint8_t>(ts), static_cast<uint8_t>(ts >> 8), static_cast<uint8_t>(ts >> 16),
|
||||
static_cast<uint8_t>(ts >> 24), 0x00, 0x00, 0x00, 0x00});
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,113 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <initializer_list>
|
||||
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_TIME
|
||||
#include "esphome/components/time/real_time_clock.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
// Function codes of the requests; the box answers a request with function + 2
|
||||
static constexpr uint8_t FUNCTION_NODE = 0x0c; // discovery, status and mode of a node
|
||||
static constexpr uint8_t FUNCTION_NODE_DATA = 0x10; // a block of a node's data (sensors, serial number)
|
||||
static constexpr uint8_t FUNCTION_PARAMETER = 0x24; // read or write a box parameter, set the clock
|
||||
|
||||
/// A received message; data points into the receive buffer and is valid only during the callback.
|
||||
struct DucoMessage {
|
||||
uint8_t id;
|
||||
const uint8_t *data;
|
||||
uint8_t size;
|
||||
};
|
||||
|
||||
class DucoDevice;
|
||||
|
||||
/// The open request of one device: the answer to it is function + 2 with the same message id.
|
||||
struct DucoPendingRequest {
|
||||
DucoDevice *device;
|
||||
uint8_t id;
|
||||
uint8_t function; // 0 while nothing is open
|
||||
bool missed; // the request before this one got no answer; sits in padding
|
||||
};
|
||||
|
||||
class Duco : public uart::UARTDevice, public Component {
|
||||
public:
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_disable_crc(bool disable_crc) { this->disable_crc_ = disable_crc; }
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
void register_device(DucoDevice *device) { this->pending_.push_back({device, 0, 0, false}); }
|
||||
#endif
|
||||
|
||||
/// Forget device's open request, for a device that will not send again.
|
||||
void close_request(DucoDevice *device);
|
||||
|
||||
/// Send a request; its answer goes to device, or nowhere if nullptr. A device's new request replaces its
|
||||
/// open one, so an answer is never lost to another device's traffic.
|
||||
void send(DucoDevice *device, uint8_t function, std::initializer_list<uint8_t> data);
|
||||
|
||||
protected:
|
||||
// The longest answer to a request sent here is 25 bytes (node status), the longest request 9
|
||||
static constexpr size_t MAX_FRAME_SIZE = 32;
|
||||
static constexpr size_t MAX_REQUEST_DATA = 9;
|
||||
|
||||
bool parse_byte_(uint8_t byte);
|
||||
void reset_frame_();
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
void update_warning_(); // warn while any device's previous request went unanswered
|
||||
#endif
|
||||
void finalize_message_();
|
||||
uint8_t next_id_();
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
StaticVector<DucoPendingRequest, DUCO_DEVICE_COUNT> pending_;
|
||||
#endif
|
||||
std::array<uint8_t, MAX_FRAME_SIZE> rx_buffer_{};
|
||||
uint32_t last_byte_{0};
|
||||
uint8_t rx_size_{0};
|
||||
uint8_t last_id_{10};
|
||||
bool escaped_{false}; // the last 0xAA was followed by a stuffing byte, so it starts no header
|
||||
bool disable_crc_{false};
|
||||
};
|
||||
|
||||
/// Every entity polls the box; deriving from PollingComponent shares its vtable.
|
||||
class DucoDevice : public PollingComponent, public Parented<Duco> {
|
||||
public:
|
||||
virtual void receive_response(const DucoMessage &message) = 0;
|
||||
};
|
||||
|
||||
/// Walks the node addresses once after boot; the nodes found are listed in the config dump.
|
||||
class DucoDiscovery : public DucoDevice {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void update() override; // one discovery step, run by the step interval
|
||||
void receive_response(const DucoMessage &message) override;
|
||||
|
||||
protected:
|
||||
static constexpr uint8_t NODE_COUNT = 0x45;
|
||||
|
||||
std::array<uint8_t, NODE_COUNT> node_types_{}; // 0 when no node answered at that address
|
||||
uint8_t next_node_{0};
|
||||
uint8_t attempts_{0};
|
||||
};
|
||||
|
||||
#ifdef USE_TIME
|
||||
class DucoTime : public PollingComponent, public Parented<Duco> {
|
||||
public:
|
||||
explicit DucoTime(time::RealTimeClock *clock) : clock_(clock) {}
|
||||
void update() override;
|
||||
|
||||
protected:
|
||||
time::RealTimeClock *clock_;
|
||||
};
|
||||
#endif
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,34 +0,0 @@
|
||||
from esphome.components import number
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_DUCO_ID,
|
||||
DUCO_COMPONENT_SCHEMA,
|
||||
DucoDevice,
|
||||
duco_ns,
|
||||
register_duco_device,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["duco"]
|
||||
CODEOWNERS = ["@kokx"]
|
||||
|
||||
DucoComfortTemperature = duco_ns.class_(
|
||||
"DucoComfortTemperature", DucoDevice, number.Number
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
number.number_schema(
|
||||
DucoComfortTemperature,
|
||||
unit_of_measurement=UNIT_CELSIUS,
|
||||
device_class=DEVICE_CLASS_TEMPERATURE,
|
||||
)
|
||||
.extend(DUCO_COMPONENT_SCHEMA)
|
||||
.extend(cv.polling_component_schema("60s"))
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await number.new_number(config, min_value=10.0, max_value=25.5, step=0.1)
|
||||
await register_duco_device(var, config, config[CONF_DUCO_ID])
|
||||
@@ -1,26 +0,0 @@
|
||||
#include "number.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
static constexpr uint8_t PARAMETER_COMFORT_TEMPERATURE = 0x12;
|
||||
static constexpr uint8_t GROUP_COMFORT_TEMPERATURE = 0x0a;
|
||||
|
||||
void DucoComfortTemperature::update() {
|
||||
this->parent_->send(this, FUNCTION_PARAMETER, {0x00, PARAMETER_COMFORT_TEMPERATURE, GROUP_COMFORT_TEMPERATURE});
|
||||
}
|
||||
|
||||
void DucoComfortTemperature::receive_response(const DucoMessage &message) {
|
||||
// Reads and writes get the same answer: one byte in tenths of a degree, below 10.0 is invalid
|
||||
if (message.size >= 4 && message.data[3] >= 100)
|
||||
this->publish_state(message.data[3] / 10.0f);
|
||||
}
|
||||
|
||||
void DucoComfortTemperature::control(float value) {
|
||||
const auto tenths = static_cast<uint8_t>(lroundf(value * 10.0f));
|
||||
this->parent_->send(this, FUNCTION_PARAMETER,
|
||||
{0x01, PARAMETER_COMFORT_TEMPERATURE, GROUP_COMFORT_TEMPERATURE, tenths, 0x00, 0x00, 0x00});
|
||||
}
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,18 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/number/number.h"
|
||||
|
||||
#include "../duco.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
class DucoComfortTemperature : public DucoDevice, public number::Number {
|
||||
public:
|
||||
void update() override;
|
||||
void receive_response(const DucoMessage &message) override;
|
||||
|
||||
protected:
|
||||
void control(float value) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,45 +0,0 @@
|
||||
from esphome.components import select
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_DUCO_ID,
|
||||
DUCO_COMPONENT_SCHEMA,
|
||||
DucoDevice,
|
||||
duco_ns,
|
||||
register_duco_device,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["duco"]
|
||||
CODEOWNERS = ["@kokx"]
|
||||
|
||||
# Same order as MODE_CODES in select.cpp
|
||||
DUCO_MODE_OPTIONS = [
|
||||
"AUTO",
|
||||
"MAN1",
|
||||
"MAN2",
|
||||
"MAN3",
|
||||
"EMPT",
|
||||
"CNT1",
|
||||
"CNT2",
|
||||
"CNT3",
|
||||
"MAN1x2",
|
||||
"MAN2x2",
|
||||
"MAN3x2",
|
||||
"MAN1x3",
|
||||
"MAN2x3",
|
||||
"MAN3x3",
|
||||
]
|
||||
|
||||
DucoSelect = duco_ns.class_("DucoSelect", DucoDevice, select.Select)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
select.select_schema(DucoSelect)
|
||||
.extend(cv.polling_component_schema("3s"))
|
||||
.extend(DUCO_COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await select.new_select(config, options=DUCO_MODE_OPTIONS)
|
||||
await register_duco_device(var, config, config[CONF_DUCO_ID])
|
||||
@@ -1,47 +0,0 @@
|
||||
#include "select.h"
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "duco.select");
|
||||
|
||||
// Box code of each option, in the order of DUCO_MODE_OPTIONS in __init__.py
|
||||
static constexpr uint8_t MODE_CODES[] PROGMEM = {
|
||||
0x00, // AUTO
|
||||
0x04, // MAN1
|
||||
0x05, // MAN2
|
||||
0x06, // MAN3
|
||||
0x07, // EMPT
|
||||
0x08, // CNT1
|
||||
0x09, // CNT2
|
||||
0x0a, // CNT3
|
||||
0x84, // MAN1x2
|
||||
0x85, // MAN2x2
|
||||
0x86, // MAN3x2
|
||||
0xc4, // MAN1x3
|
||||
0xc5, // MAN2x3
|
||||
0xc6, // MAN3x3
|
||||
};
|
||||
|
||||
void DucoSelect::update() { this->parent_->send(this, FUNCTION_NODE, {0x02, 0x01}); }
|
||||
|
||||
void DucoSelect::receive_response(const DucoMessage &message) {
|
||||
if (message.size < 1)
|
||||
return;
|
||||
for (size_t i = 0; i < sizeof(MODE_CODES); i++) {
|
||||
if (progmem_read_byte(&MODE_CODES[i]) == message.data[0]) {
|
||||
this->publish_state(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGV(TAG, "Unknown mode %02X", message.data[0]);
|
||||
}
|
||||
|
||||
void DucoSelect::control(size_t index) {
|
||||
// The next update reads the new mode back
|
||||
this->parent_->send(nullptr, FUNCTION_NODE, {0x04, 0x01, progmem_read_byte(&MODE_CODES[index])});
|
||||
}
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,18 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/select/select.h"
|
||||
|
||||
#include "../duco.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
class DucoSelect : public DucoDevice, public select::Select {
|
||||
public:
|
||||
void update() override;
|
||||
void receive_response(const DucoMessage &message) override;
|
||||
|
||||
protected:
|
||||
void control(size_t index) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,156 +0,0 @@
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_CO2,
|
||||
CONF_HUMIDITY,
|
||||
CONF_TEMPERATURE,
|
||||
DEVICE_CLASS_CARBON_DIOXIDE,
|
||||
DEVICE_CLASS_DURATION,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_PARTS_PER_MILLION,
|
||||
UNIT_PERCENT,
|
||||
UNIT_SECOND,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_DUCO_ID,
|
||||
DUCO_COMPONENT_SCHEMA,
|
||||
DucoDevice,
|
||||
duco_ns,
|
||||
register_duco_device,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["duco"]
|
||||
CODEOWNERS = ["@kokx"]
|
||||
|
||||
UNIT_DAYS = "d"
|
||||
|
||||
CONF_BYPASS = "bypass"
|
||||
CONF_FILTER_REMAINING = "filter_remaining"
|
||||
CONF_FLOW_LEVEL = "flow_level"
|
||||
CONF_TEMPERATURE_EHA = "temperature_eha"
|
||||
CONF_TEMPERATURE_ETA = "temperature_eta"
|
||||
CONF_TEMPERATURE_ODA = "temperature_oda"
|
||||
CONF_TEMPERATURE_SUP = "temperature_sup"
|
||||
CONF_TIME_REMAINING = "time_remaining"
|
||||
|
||||
DucoSensor = duco_ns.class_("DucoSensor", DucoDevice, sensor.Sensor)
|
||||
DucoReading = duco_ns.enum("DucoReading", is_class=True)
|
||||
|
||||
BOX_NODE = 1
|
||||
|
||||
|
||||
def _schema(update_interval: str = "60s", **kwargs) -> cv.Schema:
|
||||
return sensor.sensor_schema(
|
||||
DucoSensor, state_class=STATE_CLASS_MEASUREMENT, **kwargs
|
||||
).extend(cv.polling_component_schema(update_interval))
|
||||
|
||||
|
||||
def _node_schema(**kwargs) -> cv.All:
|
||||
"""A list of sensors, one per node address."""
|
||||
return cv.ensure_list(
|
||||
_schema(**kwargs).extend({cv.Required(CONF_ADDRESS): cv.int_range(0, 68)})
|
||||
)
|
||||
|
||||
|
||||
_TEMPERATURE = {
|
||||
"unit_of_measurement": UNIT_CELSIUS,
|
||||
"accuracy_decimals": 1,
|
||||
"device_class": DEVICE_CLASS_TEMPERATURE,
|
||||
}
|
||||
|
||||
# key: (what is read, request argument or None for the node address, schema)
|
||||
SENSORS = {
|
||||
CONF_CO2: (
|
||||
DucoReading.DUCO_READING_CO2,
|
||||
None,
|
||||
_node_schema(
|
||||
unit_of_measurement=UNIT_PARTS_PER_MILLION,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
|
||||
),
|
||||
),
|
||||
CONF_TEMPERATURE: (
|
||||
DucoReading.DUCO_READING_NODE_TEMPERATURE,
|
||||
None,
|
||||
_node_schema(**_TEMPERATURE),
|
||||
),
|
||||
CONF_HUMIDITY: (
|
||||
DucoReading.DUCO_READING_HUMIDITY,
|
||||
None,
|
||||
_node_schema(
|
||||
unit_of_measurement=UNIT_PERCENT,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_HUMIDITY,
|
||||
),
|
||||
),
|
||||
CONF_FILTER_REMAINING: (
|
||||
DucoReading.DUCO_READING_FILTER_REMAINING,
|
||||
0x30,
|
||||
_schema(
|
||||
"1200s",
|
||||
unit_of_measurement=UNIT_DAYS,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
),
|
||||
),
|
||||
CONF_FLOW_LEVEL: (
|
||||
DucoReading.DUCO_READING_FLOW_LEVEL,
|
||||
BOX_NODE,
|
||||
_schema(unit_of_measurement=UNIT_PERCENT, accuracy_decimals=0),
|
||||
),
|
||||
CONF_TIME_REMAINING: (
|
||||
DucoReading.DUCO_READING_TIME_REMAINING,
|
||||
BOX_NODE,
|
||||
_schema(
|
||||
unit_of_measurement=UNIT_SECOND,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
),
|
||||
),
|
||||
CONF_TEMPERATURE_ODA: (
|
||||
DucoReading.DUCO_READING_BOX_TEMPERATURE,
|
||||
0x00,
|
||||
_schema(**_TEMPERATURE),
|
||||
),
|
||||
CONF_TEMPERATURE_SUP: (
|
||||
DucoReading.DUCO_READING_BOX_TEMPERATURE,
|
||||
0x01,
|
||||
_schema(**_TEMPERATURE),
|
||||
),
|
||||
CONF_TEMPERATURE_ETA: (
|
||||
DucoReading.DUCO_READING_BOX_TEMPERATURE,
|
||||
0x02,
|
||||
_schema(**_TEMPERATURE),
|
||||
),
|
||||
CONF_TEMPERATURE_EHA: (
|
||||
DucoReading.DUCO_READING_BOX_TEMPERATURE,
|
||||
0x03,
|
||||
_schema(**_TEMPERATURE),
|
||||
),
|
||||
CONF_BYPASS: (
|
||||
DucoReading.DUCO_READING_BYPASS,
|
||||
0x10,
|
||||
_schema(unit_of_measurement=UNIT_PERCENT, accuracy_decimals=0),
|
||||
),
|
||||
}
|
||||
|
||||
CONFIG_SCHEMA = DUCO_COMPONENT_SCHEMA.extend(
|
||||
{cv.Optional(key): schema for key, (_, _, schema) in SENSORS.items()}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
for key, (reading, argument, _) in SENSORS.items():
|
||||
if (confs := config.get(key)) is None:
|
||||
continue
|
||||
for conf in confs if isinstance(confs, list) else [confs]:
|
||||
var = await sensor.new_sensor(
|
||||
conf, reading, conf.get(CONF_ADDRESS, argument)
|
||||
)
|
||||
await register_duco_device(var, conf, config[CONF_DUCO_ID])
|
||||
@@ -1,78 +0,0 @@
|
||||
#include "sensor.h"
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "duco.sensor");
|
||||
|
||||
enum class DucoValueFormat : uint8_t {
|
||||
DUCO_VALUE_FORMAT_UINT8,
|
||||
DUCO_VALUE_FORMAT_UINT16,
|
||||
DUCO_VALUE_FORMAT_INT16,
|
||||
};
|
||||
|
||||
/// A little endian field at offset in the answer. Values outside [minimum, maximum] are dropped;
|
||||
/// the box reports those for sensors that are missing or still starting.
|
||||
struct ReadingLayout {
|
||||
uint8_t function;
|
||||
uint8_t offset;
|
||||
DucoValueFormat format;
|
||||
uint8_t divisor;
|
||||
int16_t minimum;
|
||||
uint16_t maximum;
|
||||
};
|
||||
|
||||
// Indexed by DucoReading
|
||||
static constexpr ReadingLayout READINGS[] PROGMEM = {
|
||||
{FUNCTION_NODE_DATA, 4, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 1, 300, 10000}, // CO2
|
||||
{FUNCTION_NODE_DATA, 2, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 10, 0, 1000}, // node temperature
|
||||
{FUNCTION_NODE_DATA, 6, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 100, 0, 10000}, // humidity
|
||||
{FUNCTION_PARAMETER, 3, DucoValueFormat::DUCO_VALUE_FORMAT_UINT8, 1, 0, 255}, // filter remaining
|
||||
{FUNCTION_NODE, 2, DucoValueFormat::DUCO_VALUE_FORMAT_UINT8, 1, 0, 255}, // flow level
|
||||
{FUNCTION_NODE, 12, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 1, 0, 65535}, // time remaining
|
||||
{FUNCTION_PARAMETER, 3, DucoValueFormat::DUCO_VALUE_FORMAT_INT16, 10, -999, 1000}, // box temperature
|
||||
{FUNCTION_PARAMETER, 3, DucoValueFormat::DUCO_VALUE_FORMAT_UINT8, 1, 0, 100}, // bypass
|
||||
};
|
||||
|
||||
static ReadingLayout layout_of(DucoReading reading) {
|
||||
ReadingLayout layout;
|
||||
progmem_memcpy(&layout, &READINGS[static_cast<uint8_t>(reading)], sizeof(layout));
|
||||
return layout;
|
||||
}
|
||||
|
||||
void DucoSensor::update() {
|
||||
switch (layout_of(this->reading_).function) {
|
||||
case FUNCTION_NODE_DATA:
|
||||
this->parent_->send(this, FUNCTION_NODE_DATA, {0x01, this->argument_, 0x00, 0x49, 0x04});
|
||||
break;
|
||||
case FUNCTION_PARAMETER:
|
||||
this->parent_->send(this, FUNCTION_PARAMETER, {0x00, this->argument_, 0x09});
|
||||
break;
|
||||
case FUNCTION_NODE:
|
||||
this->parent_->send(this, FUNCTION_NODE, {0x02, this->argument_});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void DucoSensor::receive_response(const DucoMessage &message) {
|
||||
const ReadingLayout layout = layout_of(this->reading_);
|
||||
const bool wide = layout.format != DucoValueFormat::DUCO_VALUE_FORMAT_UINT8;
|
||||
if (message.size < layout.offset + (wide ? 2 : 1)) {
|
||||
ESP_LOGV(TAG, "Answer of %u bytes is too short", message.size);
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t *field = message.data + layout.offset;
|
||||
int32_t value = field[0];
|
||||
if (layout.format == DucoValueFormat::DUCO_VALUE_FORMAT_UINT16) {
|
||||
value = encode_value_le<uint16_t>(field);
|
||||
} else if (layout.format == DucoValueFormat::DUCO_VALUE_FORMAT_INT16) {
|
||||
value = encode_value_le<int16_t>(field);
|
||||
}
|
||||
if (value >= layout.minimum && value <= layout.maximum)
|
||||
this->publish_state(static_cast<float>(value) / layout.divisor);
|
||||
}
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,34 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
|
||||
#include "../duco.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
/// What a sensor reads; the request and where the value is in the answer are in READINGS in sensor.cpp.
|
||||
enum class DucoReading : uint8_t {
|
||||
DUCO_READING_CO2,
|
||||
DUCO_READING_NODE_TEMPERATURE,
|
||||
DUCO_READING_HUMIDITY,
|
||||
DUCO_READING_FILTER_REMAINING,
|
||||
DUCO_READING_FLOW_LEVEL,
|
||||
DUCO_READING_TIME_REMAINING,
|
||||
DUCO_READING_BOX_TEMPERATURE,
|
||||
DUCO_READING_BYPASS,
|
||||
};
|
||||
|
||||
class DucoSensor : public DucoDevice, public sensor::Sensor {
|
||||
public:
|
||||
/// argument is the node address, or the parameter number for a box parameter.
|
||||
DucoSensor(DucoReading reading, uint8_t argument) : reading_(reading), argument_(argument) {}
|
||||
|
||||
void update() override;
|
||||
void receive_response(const DucoMessage &message) override;
|
||||
|
||||
protected:
|
||||
DucoReading reading_;
|
||||
uint8_t argument_;
|
||||
};
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,27 +0,0 @@
|
||||
from esphome.components import text_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_DUCO_ID,
|
||||
DUCO_COMPONENT_SCHEMA,
|
||||
DucoDevice,
|
||||
duco_ns,
|
||||
register_duco_device,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["duco"]
|
||||
CODEOWNERS = ["@kokx"]
|
||||
|
||||
DucoSerial = duco_ns.class_("DucoSerial", DucoDevice, text_sensor.TextSensor)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
text_sensor.text_sensor_schema(DucoSerial)
|
||||
.extend(cv.polling_component_schema("300s"))
|
||||
.extend(DUCO_COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await text_sensor.new_text_sensor(config)
|
||||
await register_duco_device(var, config, config[CONF_DUCO_ID])
|
||||
@@ -1,17 +0,0 @@
|
||||
#include "serial.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
void DucoSerial::update() { this->parent_->send(this, FUNCTION_NODE_DATA, {0x01, 0x01, 0x00, 0x1a, 0x10}); }
|
||||
|
||||
void DucoSerial::receive_response(const DucoMessage &message) {
|
||||
if (message.size > 2) {
|
||||
// The serial number starts at byte 2 and is padded with zeros
|
||||
const char *serial = reinterpret_cast<const char *>(message.data + 2);
|
||||
this->publish_state(serial, strnlen(serial, message.size - 2));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,15 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
|
||||
#include "../duco.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
class DucoSerial : public DucoDevice, public text_sensor::TextSensor {
|
||||
public:
|
||||
void update() override;
|
||||
void receive_response(const DucoMessage &message) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -0,0 +1,175 @@
|
||||
"""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_layers,
|
||||
)
|
||||
from esphome.components.packet_interface.layers import CONF_SESSION
|
||||
from esphome.components.sensor import Sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_ID,
|
||||
CONF_SENSORS,
|
||||
CONF_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
DEPENDENCIES = ["packet_interface"]
|
||||
DOMAIN = "entity_transport"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_BROADCAST_ID = "broadcast_id"
|
||||
CONF_ENTITY_TRANSPORT_ID = "entity_transport_id"
|
||||
|
||||
entity_transport_ns = cg.esphome_ns.namespace("entity_transport")
|
||||
EntityTransport = entity_transport_ns.class_("EntityTransport", cg.Component)
|
||||
|
||||
_request_remote_sensor_slot = cg.slot_counter("ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT")
|
||||
_request_remote_binary_sensor_slot = cg.slot_counter(
|
||||
"ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT"
|
||||
)
|
||||
|
||||
|
||||
def _advertised_id(conf: ConfigType) -> str:
|
||||
return conf.get(CONF_BROADCAST_ID, conf[CONF_ID].id)
|
||||
|
||||
|
||||
def _unique_advertised_ids(entities: list[ConfigType]) -> list[ConfigType]:
|
||||
"""A receiver tells entities of one type apart by their advertised ids, so each must be different."""
|
||||
seen: set[str] = set()
|
||||
for index, conf in enumerate(entities):
|
||||
if (advertised := _advertised_id(conf)) in seen:
|
||||
raise cv.Invalid(
|
||||
f"The id '{advertised}' is sent for two entities", path=[index]
|
||||
)
|
||||
seen.add(advertised)
|
||||
return entities
|
||||
|
||||
|
||||
def _local_entity(cls: MockObjClass) -> Callable[[Any], Any]:
|
||||
return cv.maybe_simple_value(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(cls),
|
||||
cv.Optional(CONF_BROADCAST_ID): cv.All(
|
||||
cv.validate_id_name, cv.Length(max=255)
|
||||
),
|
||||
}
|
||||
),
|
||||
key=CONF_ID,
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(EntityTransport),
|
||||
cv.Required(CONF_PACKET_INTERFACE): cv.use_id(packet_interface.PacketInterface),
|
||||
# Defaults to a periodic resend unless the interface has session and reliability layers
|
||||
cv.Optional(CONF_UPDATE_INTERVAL): cv.All(
|
||||
cv.update_interval, cv.Range(min=cv.TimePeriod(seconds=1))
|
||||
),
|
||||
# Remote entities lose their state when none arrives for this long
|
||||
cv.Optional(CONF_TIMEOUT): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(min=cv.TimePeriod(seconds=1)),
|
||||
),
|
||||
# Entities are numbered with one byte
|
||||
cv.Optional(CONF_SENSORS): cv.All(
|
||||
cv.ensure_list(_local_entity(Sensor)),
|
||||
cv.Length(max=255),
|
||||
_unique_advertised_ids,
|
||||
),
|
||||
cv.Optional(CONF_BINARY_SENSORS): cv.All(
|
||||
cv.ensure_list(_local_entity(BinarySensor)),
|
||||
cv.Length(max=255),
|
||||
_unique_advertised_ids,
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
"""With a session layer, which clears the states when the peer is lost, nothing would refresh an unchanged state."""
|
||||
layers = interface_layers(config[CONF_PACKET_INTERFACE])
|
||||
if CONF_TIMEOUT in config and layers is not None and CONF_SESSION in layers:
|
||||
raise cv.Invalid(
|
||||
f"Not allowed with a '{CONF_SESSION}' layer, which already clears the states when the other device "
|
||||
"cannot be reached; unchanged states may never be sent again, so they would time out on a working link",
|
||||
path=[CONF_TIMEOUT],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = 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))
|
||||
@@ -0,0 +1,11 @@
|
||||
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)
|
||||
@@ -0,0 +1,683 @@
|
||||
#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
|
||||
@@ -0,0 +1,171 @@
|
||||
#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
|
||||
@@ -0,0 +1,11 @@
|
||||
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,5 +1,4 @@
|
||||
#include "epaper_spi.h"
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
@@ -38,42 +37,6 @@ bool EPaperBase::init_buffer_(size_t buffer_length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EPaperBase::init_sent_frame_(size_t length) {
|
||||
if (!this->is_using_partial_update_())
|
||||
return false;
|
||||
if (this->sent_.init(length))
|
||||
return true;
|
||||
ESP_LOGW(TAG, "No memory for the previous frame; partial updates will send the whole drawn area");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t EPaperBase::buffer_hash_() const {
|
||||
uint32_t hash = FNV1_OFFSET_BASIS;
|
||||
size_t index = 0;
|
||||
while (index != this->buffer_.size()) {
|
||||
size_t length;
|
||||
const uint8_t *data = this->buffer_.get_span(index, length);
|
||||
for (size_t i = 0; i != length; i++)
|
||||
hash = fnv1a_hash_extend(hash, data[i]);
|
||||
index += length;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
void EPaperBase::set_full_bounds_() {
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
}
|
||||
|
||||
void EPaperBase::reset_bounds_() {
|
||||
this->x_low_ = this->width_;
|
||||
this->x_high_ = 0;
|
||||
this->y_low_ = this->height_;
|
||||
this->y_high_ = 0;
|
||||
}
|
||||
|
||||
void EPaperBase::setup_pins_() const {
|
||||
for (auto *pin : this->enable_pins_) {
|
||||
pin->setup();
|
||||
@@ -270,28 +233,17 @@ void EPaperBase::process_state_() {
|
||||
// Refresh the whole panel even if nothing was drawn
|
||||
this->full_update_requested_ = false;
|
||||
this->update_count_ = 0;
|
||||
this->set_full_bounds_();
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
}
|
||||
if (this->x_high_ < this->x_low_ || this->y_high_ < this->y_low_) {
|
||||
this->set_state_(EPaperState::IDLE);
|
||||
return;
|
||||
}
|
||||
if (this->is_using_partial_update_()) {
|
||||
const uint32_t frame_hash = this->buffer_hash_();
|
||||
if (this->update_count_ != 0 && this->shown_hash_valid_ && frame_hash == this->shown_hash_) {
|
||||
ESP_LOGD(TAG, "Frame unchanged, refresh skipped");
|
||||
this->reset_bounds_();
|
||||
this->set_state_(EPaperState::IDLE);
|
||||
return;
|
||||
}
|
||||
this->shown_hash_ = frame_hash;
|
||||
}
|
||||
this->drawn_since_hash_ = false;
|
||||
// Valid again only once this frame is on the panel, so an abandoned update leaves nothing to match
|
||||
this->shown_hash_valid_ = false;
|
||||
// A full update resets update_count_ to zero, so a non-zero count marks a partial one
|
||||
this->partial_ = this->update_count_ != 0;
|
||||
this->sent_count_ = 0;
|
||||
this->set_state_(EPaperState::RESET);
|
||||
break;
|
||||
case EPaperState::INITIALISE:
|
||||
@@ -304,15 +256,10 @@ void EPaperBase::process_state_() {
|
||||
if (!this->transfer_data()) {
|
||||
return; // Not done yet, come back next loop
|
||||
}
|
||||
if (this->sent_.is_valid() && this->sent_count_ == this->sent_.size())
|
||||
this->sent_valid_ = true;
|
||||
// A draw during the chunked push may not have reached the panel, so the next update sends the whole frame
|
||||
this->sent_hash_intact_ = this->is_using_partial_update_() && !this->drawn_since_hash_;
|
||||
if (this->drawn_since_hash_) {
|
||||
this->set_full_bounds_();
|
||||
} else {
|
||||
this->reset_bounds_();
|
||||
}
|
||||
this->x_low_ = this->width_;
|
||||
this->x_high_ = 0;
|
||||
this->y_low_ = this->height_;
|
||||
this->y_high_ = 0;
|
||||
this->set_state_(EPaperState::POWER_ON);
|
||||
break;
|
||||
case EPaperState::POWER_ON:
|
||||
@@ -333,7 +280,6 @@ void EPaperBase::process_state_() {
|
||||
break;
|
||||
case EPaperState::DEEP_SLEEP:
|
||||
this->deep_sleep();
|
||||
this->shown_hash_valid_ = this->sent_hash_intact_;
|
||||
this->status_clear_warning();
|
||||
this->set_state_(EPaperState::IDLE);
|
||||
ESP_LOGD(TAG, "Display update took %" PRIu32 " ms", millis() - this->update_start_time_);
|
||||
@@ -419,7 +365,6 @@ bool EPaperBase::rotate_coordinates_(int &x, int &y) {
|
||||
this->x_high_ = clamp_at_least(this->x_high_, x + 1);
|
||||
this->y_low_ = clamp_at_most(this->y_low_, y);
|
||||
this->y_high_ = clamp_at_least(this->y_high_, y + 1);
|
||||
this->drawn_since_hash_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -541,7 +486,6 @@ void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_star
|
||||
this->x_high_ = clamp_at_least(this->x_high_, x_high);
|
||||
this->y_low_ = clamp_at_most(this->y_low_, y_low);
|
||||
this->y_high_ = clamp_at_least(this->y_high_, y_high);
|
||||
this->drawn_since_hash_ = true;
|
||||
}
|
||||
|
||||
// Writes an RGB565 block of pixels, as LVGL sends them, straight into a 1 bit per pixel buffer that is one block (the
|
||||
|
||||
@@ -99,7 +99,10 @@ class EPaperBase : public Display,
|
||||
|
||||
// We store 8 pixels per byte
|
||||
this->buffer_.fill(pixel_color);
|
||||
this->mark_all_drawn_();
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
}
|
||||
|
||||
void clear() override {
|
||||
@@ -136,25 +139,6 @@ class EPaperBase : public Display,
|
||||
void wait_for_idle_(bool should_wait);
|
||||
void abandon_update_();
|
||||
bool init_buffer_(size_t buffer_length);
|
||||
// Allocates the copy of the previous frame; only drivers that diff against it (needs_previous_frame) call this
|
||||
bool init_sent_frame_(size_t length);
|
||||
void record_sent_(size_t index, const uint8_t *data, size_t length) {
|
||||
if (this->sent_.is_valid()) {
|
||||
this->sent_.write(index, data, length);
|
||||
this->sent_count_ += length;
|
||||
}
|
||||
}
|
||||
// Whether the driver reads the previous frame (sent_) for its differential refresh
|
||||
virtual bool needs_previous_frame() const { return false; }
|
||||
// FNV-1a hash of the current frame buffer, to detect an unchanged frame without a full copy
|
||||
uint32_t buffer_hash_() const;
|
||||
void reset_bounds_();
|
||||
void set_full_bounds_();
|
||||
// For a draw that writes the whole buffer without going through rotate_coordinates_()
|
||||
void mark_all_drawn_() {
|
||||
this->set_full_bounds_();
|
||||
this->drawn_since_hash_ = true;
|
||||
}
|
||||
void update_effective_transform_();
|
||||
bool rotate_coordinates_(int &x, int &y);
|
||||
void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, bool big_endian,
|
||||
@@ -216,13 +200,6 @@ class EPaperBase : public Display,
|
||||
size_t buffer_length_;
|
||||
size_t current_data_index_{}; // used by data transfer to track progress
|
||||
split_buffer::SplitBuffer buffer_{};
|
||||
split_buffer::SplitBuffer sent_{}; // the previous frame, kept only by drivers that diff against it
|
||||
bool sent_valid_{}; // sent_ holds a whole frame
|
||||
size_t sent_count_{}; // bytes recorded into sent_ by the update in progress
|
||||
uint32_t shown_hash_{}; // hash of the frame on the panel, to skip an unchanged update
|
||||
bool shown_hash_valid_{}; // shown_hash_ holds the hash of a frame whose refresh completed
|
||||
bool sent_hash_intact_{}; // the buffer was unchanged by the end of the push of shown_hash_
|
||||
bool drawn_since_hash_{}; // something was drawn after shown_hash_ was taken
|
||||
GPIOPin *dc_pin_{};
|
||||
GPIOPin *busy_pin_{};
|
||||
GPIOPin *reset_pin_{};
|
||||
|
||||
@@ -18,7 +18,11 @@ void EPaper4bpp::fill(Color color) {
|
||||
// We store 2 pixels per byte
|
||||
this->buffer_.fill(pixel_color + (pixel_color << 4));
|
||||
|
||||
this->mark_all_drawn_();
|
||||
// Whole buffer just changed; mark the entire canvas dirty.
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
}
|
||||
|
||||
void EPaper4bpp::clear() {
|
||||
|
||||
@@ -71,7 +71,10 @@ void EPaperInkplate2::fill(Color color) {
|
||||
for (size_t i = half_buffer; i < this->buffer_length_; i++)
|
||||
this->buffer_[i] = red_byte;
|
||||
|
||||
this->mark_all_drawn_();
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
}
|
||||
|
||||
void EPaperInkplate2::clear() { this->fill(COLOR_ON); }
|
||||
|
||||
@@ -30,7 +30,6 @@ void EPaperJD79660::fill(Color color) {
|
||||
|
||||
// We store 4 pixels per byte
|
||||
this->buffer_.fill(pixel_color | (pixel_color << 2) | (pixel_color << 4) | (pixel_color << 6));
|
||||
this->drawn_since_hash_ = true;
|
||||
}
|
||||
|
||||
void HOT EPaperJD79660::draw_pixel_at(int x, int y, Color color) {
|
||||
|
||||
@@ -7,12 +7,6 @@
|
||||
namespace esphome::epaper_spi {
|
||||
ESPHOME_LOG_TAG(TAG, "epaper_spi.mono");
|
||||
|
||||
void EPaperMono::setup() {
|
||||
EPaperBase::setup();
|
||||
if (!this->is_failed() && this->needs_previous_frame())
|
||||
this->init_sent_frame_(this->sent_frame_length());
|
||||
}
|
||||
|
||||
void EPaperMono::refresh_screen(bool partial) {
|
||||
ESP_LOGV(TAG, "Refresh screen");
|
||||
this->cmd_data(0x22, {partial ? (uint8_t) 0xFF : (uint8_t) 0xF7});
|
||||
@@ -72,8 +66,7 @@ bool HOT EPaperMono::transfer_data() {
|
||||
ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", row_length, this->current_data_index_, (unsigned) millis());
|
||||
this->start_data_();
|
||||
while (this->current_data_index_ != this->y_high_) {
|
||||
const size_t row_start = this->current_data_index_ * this->row_width_ + this->x_low_ / 8;
|
||||
size_t data_idx = row_start;
|
||||
size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_ / 8;
|
||||
for (size_t i = 0; i != row_length; i++) {
|
||||
bytes_to_send[i] = this->send_red_ && !this->send_red_as_image_ ? 0 : this->buffer_[data_idx++];
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ class EPaperMono : public EPaperBase {
|
||||
size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY, uint8_t bits_per_pixel = 1)
|
||||
: EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type, bits_per_pixel) {}
|
||||
|
||||
void setup() override;
|
||||
|
||||
protected:
|
||||
void refresh_screen(bool partial) override;
|
||||
void power_on() override {}
|
||||
@@ -21,8 +19,6 @@ class EPaperMono : public EPaperBase {
|
||||
void deep_sleep() override;
|
||||
bool reset() override;
|
||||
virtual void set_window();
|
||||
// Bytes in the comparison frame; the buffer's size unless a subclass sends the frame in another layout
|
||||
virtual size_t sent_frame_length() const { return this->buffer_length_; }
|
||||
bool transfer_data() override;
|
||||
bool send_red_{true};
|
||||
bool software_reset_{true}; // send 0x12 after the hardware reset
|
||||
|
||||
@@ -6,6 +6,21 @@
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::epaper_spi {
|
||||
ESPHOME_LOG_TAG(TAG, "epaper_spi.ssd1677");
|
||||
|
||||
void EPaperSSD1677::setup() {
|
||||
EPaperMono::setup();
|
||||
if (!this->is_failed())
|
||||
this->init_comparison_frame_();
|
||||
}
|
||||
|
||||
void EPaperSSD1677::init_comparison_frame_() {
|
||||
if (!this->is_using_partial_update_())
|
||||
return;
|
||||
if (!this->sent_.init(this->plane_row_length_() * this->height_)) {
|
||||
ESP_LOGW(TAG, "No memory for the comparison frame; partial updates will degrade unchanged areas");
|
||||
}
|
||||
}
|
||||
|
||||
void EPaperSSD1677::plane_row(size_t y, uint8_t *out) {
|
||||
const size_t row_length = this->plane_row_length_();
|
||||
@@ -67,7 +82,7 @@ bool HOT EPaperSSD1677::transfer_data() {
|
||||
}
|
||||
this->write_array(data, length);
|
||||
if (this->plane_ == 1)
|
||||
this->record_sent_(this->current_data_index_, data, length);
|
||||
this->sent_.write(this->current_data_index_, data, length);
|
||||
this->current_data_index_ += length;
|
||||
if (this->current_data_index_ != plane_length && millis() - start_time > MAX_TRANSFER_TIME) {
|
||||
// Let the main loop run and come back next loop
|
||||
|
||||
@@ -26,12 +26,12 @@ class EPaperSSD1677 : public EPaperMono {
|
||||
size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY, uint8_t bits_per_pixel = 1)
|
||||
: EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type, bits_per_pixel) {}
|
||||
|
||||
void setup() override;
|
||||
|
||||
protected:
|
||||
// Reads the previous frame to copy it into the other RAM bank on a full refresh
|
||||
bool needs_previous_frame() const override { return true; }
|
||||
// Allocates the comparison frame as setup() would, for tests that run without a bus.
|
||||
void init_comparison_frame_() { this->init_sent_frame_(this->sent_frame_length()); }
|
||||
size_t sent_frame_length() const override { return this->plane_row_length_() * this->height_; }
|
||||
// Allocates the comparison frame when partial updates are enabled. Separate from setup() so it
|
||||
// can run without a bus.
|
||||
void init_comparison_frame_();
|
||||
// Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds.
|
||||
size_t plane_row_length_() const { return (this->width_ + 7) / 8; }
|
||||
// Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white.
|
||||
@@ -39,7 +39,8 @@ class EPaperSSD1677 : public EPaperMono {
|
||||
bool reset() override;
|
||||
bool transfer_data() override;
|
||||
|
||||
uint8_t plane_{0}; // 0 while sending 0x26, 1 while sending 0x24
|
||||
split_buffer::SplitBuffer sent_{}; // the frame last sent to 0x24, i.e. what the panel shows
|
||||
uint8_t plane_{0}; // 0 while sending 0x26, 1 while sending 0x24
|
||||
};
|
||||
|
||||
} // namespace esphome::epaper_spi
|
||||
@@ -29,7 +29,10 @@ void EPaperSSD1677Gray4::fill(Color color) {
|
||||
}
|
||||
const uint8_t level = color_to_gray4(color);
|
||||
this->buffer_.fill((uint8_t) (level | (level << 2) | (level << 4) | (level << 6)));
|
||||
this->mark_all_drawn_();
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
}
|
||||
|
||||
void HOT EPaperSSD1677Gray4::draw_pixel_at(int x, int y, Color color) {
|
||||
@@ -80,14 +83,14 @@ bool HOT EPaperSSD1677Gray4::transfer_data() {
|
||||
this->start_data_();
|
||||
while (this->current_data_index_ != this->y_high_) {
|
||||
const size_t src_row = this->current_data_index_ * this->row_width_ + this->x_low_ / 4;
|
||||
for (size_t i = 0; i != plane_row_length; i++)
|
||||
bytes_to_send[i] = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], first_pass);
|
||||
// What the next partial update compares against: the high bits, as a black-and-white frame.
|
||||
if (first_pass)
|
||||
this->record_sent_(this->current_data_index_ * plane_row_length, bytes_to_send, plane_row_length);
|
||||
// The OTP grayscale waveform treats data as inverted relative to monochrome
|
||||
for (size_t i = 0; i != plane_row_length; i++)
|
||||
bytes_to_send[i] = (uint8_t) ~bytes_to_send[i];
|
||||
for (size_t i = 0; i != plane_row_length; i++) {
|
||||
const uint8_t plane = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], first_pass);
|
||||
// The OTP grayscale waveform treats data as inverted relative to monochrome
|
||||
bytes_to_send[i] = (uint8_t) ~plane;
|
||||
// What the next partial update compares against: the high bits, as a black-and-white frame.
|
||||
if (first_pass && this->sent_.is_valid())
|
||||
this->sent_[this->current_data_index_ * plane_row_length + i] = plane;
|
||||
}
|
||||
++this->current_data_index_;
|
||||
this->write_array(bytes_to_send, plane_row_length);
|
||||
if (millis() - start_time > MAX_TRANSFER_TIME) {
|
||||
|
||||
@@ -1,50 +1,10 @@
|
||||
#include "epaper_spi_ssd1681.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::epaper_spi {
|
||||
ESPHOME_LOG_TAG(TAG, "epaper_spi.ssd1681");
|
||||
|
||||
void EPaperSSD1681::bounds_from_changes_() {
|
||||
if (!this->sent_valid_ || this->sent_.size() != this->buffer_.size())
|
||||
return;
|
||||
bool changed = false;
|
||||
uint16_t row_low = this->height_, row_high = 0, col_low = this->row_width_, col_high = 0;
|
||||
for (uint16_t row = 0; row != this->height_; row++) {
|
||||
const size_t base = row * this->row_width_;
|
||||
const size_t end = base + this->row_width_;
|
||||
size_t index = base;
|
||||
while (index != end) {
|
||||
size_t new_length, old_length;
|
||||
const uint8_t *new_bytes = this->buffer_.get_span(index, new_length);
|
||||
const uint8_t *old_bytes = this->sent_.get_span(index, old_length);
|
||||
const size_t length = std::min({new_length, old_length, end - index});
|
||||
if (std::memcmp(new_bytes, old_bytes, length) != 0) {
|
||||
for (size_t i = 0; i != length; i++) {
|
||||
if (new_bytes[i] == old_bytes[i])
|
||||
continue;
|
||||
const auto col = static_cast<uint16_t>(index + i - base);
|
||||
changed = true;
|
||||
row_low = std::min(row_low, row);
|
||||
row_high = std::max<uint16_t>(row_high, row + 1);
|
||||
col_low = std::min(col_low, col);
|
||||
col_high = std::max<uint16_t>(col_high, col + 1);
|
||||
}
|
||||
}
|
||||
index += length;
|
||||
}
|
||||
}
|
||||
if (!changed)
|
||||
return;
|
||||
this->x_low_ = col_low * 8;
|
||||
this->x_high_ = std::min<uint16_t>(col_high * 8, this->width_);
|
||||
this->y_low_ = row_low;
|
||||
this->y_high_ = row_high;
|
||||
}
|
||||
|
||||
bool HOT EPaperSSD1681::transfer_data() {
|
||||
if (this->current_data_index_ == 0 && this->send_red_) {
|
||||
if (!this->partial_) {
|
||||
@@ -55,7 +15,6 @@ bool HOT EPaperSSD1681::transfer_data() {
|
||||
this->y_high_ = this->height_;
|
||||
} else {
|
||||
// 0x26 already holds the image on the panel: the controller copies 0x24 into it after each refresh
|
||||
this->bounds_from_changes_();
|
||||
this->set_window();
|
||||
this->send_red_ = false;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,6 @@ class EPaperSSD1681 : public EPaperSSD1683 {
|
||||
}
|
||||
|
||||
protected:
|
||||
// Reads the previous frame to compute the changed window for a partial refresh
|
||||
bool needs_previous_frame() const override { return true; }
|
||||
// Shrinks the update bounds to the bytes that differ from the frame last sent
|
||||
void bounds_from_changes_();
|
||||
bool transfer_data() override;
|
||||
void refresh_screen(bool partial) override;
|
||||
};
|
||||
|
||||
@@ -71,15 +71,12 @@ bool HOT EPaperSSD1683::transfer_data() {
|
||||
ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", row_length, this->current_data_index_, (unsigned) millis());
|
||||
this->start_data_();
|
||||
while (this->current_data_index_ != this->y_high_) {
|
||||
const size_t row_start = this->current_data_index_ * this->row_width_ + this->x_low_;
|
||||
size_t data_idx = row_start;
|
||||
size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_;
|
||||
for (size_t i = 0; i != row_length; i++) {
|
||||
bytes_to_send[i] = this->buffer_[data_idx++];
|
||||
}
|
||||
++this->current_data_index_;
|
||||
this->write_array(&bytes_to_send.front(), row_length); // NOLINT
|
||||
if (!this->send_red_)
|
||||
this->record_sent_(row_start, &bytes_to_send.front(), row_length);
|
||||
if (millis() - start_time > MAX_TRANSFER_TIME) {
|
||||
// Let the main loop run and come back next loop
|
||||
this->disable();
|
||||
|
||||
@@ -232,7 +232,6 @@ void EPaperT133A01::fill(Color color) {
|
||||
}
|
||||
auto pixel_color = color_to_index(color);
|
||||
this->buffer_.fill(pixel_color + (pixel_color << 4));
|
||||
this->drawn_since_hash_ = true;
|
||||
}
|
||||
|
||||
void EPaperT133A01::draw_pixel_at(int x, int y, Color color) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "epaper_waveshare_bwr.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
@@ -37,9 +36,17 @@ void EPaperWaveshareBWR::draw_pixel_at(int x, int y, Color color) {
|
||||
|
||||
const auto bwr = color_to_bwr(color);
|
||||
|
||||
assign_bits(this->buffer_[pos], bit, bwr == BwrState::BWR_BLACK);
|
||||
if (bwr == BwrState::BWR_BLACK) {
|
||||
this->buffer_[pos] |= bit;
|
||||
} else {
|
||||
this->buffer_[pos] &= ~bit;
|
||||
}
|
||||
|
||||
assign_bits(this->buffer_[red_offset + pos], bit, bwr == BwrState::BWR_RED);
|
||||
if (bwr == BwrState::BWR_RED) {
|
||||
this->buffer_[red_offset + pos] |= bit;
|
||||
} else {
|
||||
this->buffer_[red_offset + pos] &= ~bit;
|
||||
}
|
||||
}
|
||||
|
||||
void EPaperWaveshareBWR::fill(Color color) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "es8156.h"
|
||||
#include "es8156_const.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cinttypes>
|
||||
|
||||
@@ -101,7 +100,11 @@ bool ES8156::set_mute_state_(bool mute_state) {
|
||||
|
||||
ESP_LOGV(TAG, "Read ES8156_REG13_DAC_MUTE: %u", reg13);
|
||||
|
||||
assign_bits(reg13, BIT(1) | BIT(2), mute_state);
|
||||
if (mute_state) {
|
||||
reg13 |= BIT(1) | BIT(2);
|
||||
} else {
|
||||
reg13 &= ~(BIT(1) | BIT(2));
|
||||
}
|
||||
|
||||
ESP_LOGV(TAG, "Setting ES8156_REG13_DAC_MUTE to %u (muted: %s)", reg13, YESNO(mute_state));
|
||||
return this->write_byte(ES8156_REG13_DAC_MUTE, reg13);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "es8311.h"
|
||||
#include "es8311_const.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cinttypes>
|
||||
|
||||
@@ -155,7 +154,11 @@ bool ES8311::configure_clock_() {
|
||||
// Register 0x06
|
||||
uint8_t reg06;
|
||||
ES8311_ERROR_CHECK(this->read_byte(ES8311_REG06_CLK_MANAGER, ®06));
|
||||
assign_bits(reg06, BIT(5), this->sclk_inverted_);
|
||||
if (this->sclk_inverted_) {
|
||||
reg06 |= BIT(5);
|
||||
} else {
|
||||
reg06 &= ~BIT(5);
|
||||
}
|
||||
reg06 &= 0xE0;
|
||||
if (coefficient->bclk_div < 19) {
|
||||
reg06 |= (coefficient->bclk_div - 1) << 0;
|
||||
@@ -220,7 +223,11 @@ bool ES8311::set_mute_state_(bool mute_state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
assign_bits(reg31, BIT(6) | BIT(5), mute_state);
|
||||
if (mute_state) {
|
||||
reg31 |= BIT(6) | BIT(5);
|
||||
} else {
|
||||
reg31 &= ~(BIT(6) | BIT(5));
|
||||
}
|
||||
|
||||
return this->write_byte(ES8311_REG31_DAC, reg31);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include <cinttypes>
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::es8388 {
|
||||
@@ -185,7 +184,11 @@ bool ES8388::set_mute_state_(bool mute_state) {
|
||||
// DAC settings that must be preserved. Previously muting overwrote the whole
|
||||
// register with 0x3C and unmuting never cleared the bit, so once muted the DAC
|
||||
// could not be unmuted again.
|
||||
assign_bits(value, ES8388_DACCONTROL3_DAC_MUTE, mute_state);
|
||||
if (mute_state) {
|
||||
value |= ES8388_DACCONTROL3_DAC_MUTE;
|
||||
} else {
|
||||
value &= ~ES8388_DACCONTROL3_DAC_MUTE;
|
||||
}
|
||||
|
||||
ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state));
|
||||
return this->write_byte(ES8388_DACCONTROL3, value);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "gree.h"
|
||||
#include "esphome/components/remote_base/remote_base.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::gree {
|
||||
|
||||
@@ -25,7 +24,11 @@ void GreeClimate::set_model(Model model) {
|
||||
}
|
||||
|
||||
void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
|
||||
assign_bits(this->mode_bits_, bit_mask, enabled);
|
||||
if (enabled) {
|
||||
this->mode_bits_ |= bit_mask;
|
||||
} else {
|
||||
this->mode_bits_ &= ~bit_mask;
|
||||
}
|
||||
this->transmit_state();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "hitachi_ac344.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::hitachi_ac344 {
|
||||
|
||||
@@ -17,6 +16,15 @@ void set_bits(uint8_t *const dst, const uint8_t offset, const uint8_t nbits, con
|
||||
*dst |= ((data & mask) << offset);
|
||||
}
|
||||
|
||||
void set_bit(uint8_t *const data, const uint8_t position, const bool on) {
|
||||
uint8_t mask = 1 << position;
|
||||
if (on) {
|
||||
*data |= mask;
|
||||
} else {
|
||||
*data &= ~mask;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t *invert_byte_pairs(uint8_t *ptr, const uint16_t length) {
|
||||
for (uint16_t i = 1; i < length; i += 2) {
|
||||
// Code done this way to avoid a compiler warning bug.
|
||||
@@ -116,7 +124,7 @@ bool HitachiClimate::get_swing_v_toggle_() { return get_button_() == HITACHI_AC3
|
||||
|
||||
void HitachiClimate::set_swing_v_(bool on) {
|
||||
set_swing_v_toggle_(on); // Set the button value.
|
||||
assign_bits(this->remote_state_[HITACHI_AC344_SWINGV_BYTE], 1 << HITACHI_AC344_SWINGV_OFFSET, on);
|
||||
set_bit(&remote_state_[HITACHI_AC344_SWINGV_BYTE], HITACHI_AC344_SWINGV_OFFSET, on);
|
||||
}
|
||||
|
||||
bool HitachiClimate::get_swing_v_() {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "hitachi_ac424.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::hitachi_ac424 {
|
||||
|
||||
@@ -17,6 +16,15 @@ void set_bits(uint8_t *const dst, const uint8_t offset, const uint8_t nbits, con
|
||||
*dst |= ((data & mask) << offset);
|
||||
}
|
||||
|
||||
void set_bit(uint8_t *const data, const uint8_t position, const bool on) {
|
||||
uint8_t mask = 1 << position;
|
||||
if (on) {
|
||||
*data |= mask;
|
||||
} else {
|
||||
*data &= ~mask;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t *invert_byte_pairs(uint8_t *ptr, const uint16_t length) {
|
||||
for (uint16_t i = 1; i < length; i += 2) {
|
||||
// Code done this way to avoid a compiler warning bug.
|
||||
@@ -116,7 +124,7 @@ bool HitachiClimate::get_swing_v_toggle_() { return get_button_() == HITACHI_AC4
|
||||
|
||||
void HitachiClimate::set_swing_v_(bool on) {
|
||||
set_swing_v_toggle_(on); // Set the button value.
|
||||
assign_bits(this->remote_state_[HITACHI_AC424_SWINGV_BYTE], 1 << HITACHI_AC424_SWINGV_OFFSET, on);
|
||||
set_bit(&remote_state_[HITACHI_AC424_SWINGV_BYTE], HITACHI_AC424_SWINGV_OFFSET, on);
|
||||
}
|
||||
|
||||
bool HitachiClimate::get_swing_v_() {
|
||||
|
||||
@@ -3,36 +3,67 @@
|
||||
|
||||
namespace esphome::max17043 {
|
||||
|
||||
// MAX17043 is a 1-Cell Fuel Gauge with ModelGauge and Low-Battery Alert
|
||||
// MAX174043 is a 1-Cell Fuel Gauge with ModelGauge and Low-Battery Alert
|
||||
// Consult the datasheet at https://www.analog.com/en/products/max17043.html
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "max17043");
|
||||
|
||||
void MAX17043Component::update() {
|
||||
if (!this->ready_for_update_())
|
||||
return;
|
||||
static const uint8_t MAX17043_VCELL = 0x02;
|
||||
static const uint8_t MAX17043_SOC = 0x04;
|
||||
static const uint8_t MAX17043_CONFIG = 0x0c;
|
||||
|
||||
uint16_t raw;
|
||||
bool ok = true;
|
||||
static const uint16_t MAX17043_CONFIG_POWER_UP_DEFAULT = 0x971C;
|
||||
static const uint16_t MAX17043_CONFIG_SAFE_MASK = 0xFF1F; // mask out sleep bit (7), unused bit (6) and alert bit (4)
|
||||
static const uint16_t MAX17043_CONFIG_SLEEP_MASK = 0x0080;
|
||||
|
||||
void MAX17043Component::update() {
|
||||
uint16_t raw_voltage, raw_percent;
|
||||
|
||||
if (this->voltage_sensor_ != nullptr) {
|
||||
if (this->read_byte_16(max170xx::REG_VCELL, &raw)) {
|
||||
// 12 bits in the top of the word at 1.25 mV
|
||||
this->voltage_sensor_->publish_state((raw >> 4) * 1.25e-3f);
|
||||
if (!this->read_byte_16(MAX17043_VCELL, &raw_voltage)) {
|
||||
this->status_set_warning(LOG_STR("Unable to read MAX17043_VCELL"));
|
||||
} else {
|
||||
ok = false;
|
||||
float voltage = (1.25f * (float) (raw_voltage >> 4)) / 1000.0f;
|
||||
this->voltage_sensor_->publish_state(voltage);
|
||||
this->status_clear_warning();
|
||||
}
|
||||
}
|
||||
if (this->battery_remaining_sensor_ != nullptr) {
|
||||
if (this->read_byte_16(max170xx::REG_SOC, &raw)) {
|
||||
float percent = (float) ((raw >> 8) + 0.003906f * (raw & 0x00ff));
|
||||
this->battery_remaining_sensor_->publish_state(percent);
|
||||
if (!this->read_byte_16(MAX17043_SOC, &raw_percent)) {
|
||||
this->status_set_warning(LOG_STR("Unable to read MAX17043_SOC"));
|
||||
} else {
|
||||
ok = false;
|
||||
float percent = (float) ((raw_percent >> 8) + 0.003906f * (raw_percent & 0x00ff));
|
||||
this->battery_remaining_sensor_->publish_state(percent);
|
||||
this->status_clear_warning();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this->status_comm_result(ok, LOG_STR("Register read failed"));
|
||||
void MAX17043Component::setup() {
|
||||
uint16_t config_reg;
|
||||
if (this->write(&MAX17043_CONFIG, 1) != i2c::ERROR_OK) {
|
||||
this->status_set_warning();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->read(reinterpret_cast<uint8_t *>(&config_reg), 2) != i2c::ERROR_OK) {
|
||||
this->status_set_warning();
|
||||
return;
|
||||
}
|
||||
|
||||
config_reg = i2c::i2ctohs(config_reg) & MAX17043_CONFIG_SAFE_MASK;
|
||||
ESP_LOGV(TAG, "MAX17043 CONFIG register reads 0x%X", config_reg);
|
||||
|
||||
if (config_reg != MAX17043_CONFIG_POWER_UP_DEFAULT) {
|
||||
this->mark_failed(LOG_STR("Device does not appear to be a MAX17043"));
|
||||
return;
|
||||
}
|
||||
|
||||
// need to write back to config register to reset the sleep bit
|
||||
if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT)) {
|
||||
this->mark_failed(LOG_STR("sleep reset failed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void MAX17043Component::dump_config() {
|
||||
@@ -43,4 +74,13 @@ void MAX17043Component::dump_config() {
|
||||
LOG_SENSOR(" ", "Battery Level", this->battery_remaining_sensor_);
|
||||
}
|
||||
|
||||
void MAX17043Component::sleep_mode() {
|
||||
if (!this->is_failed()) {
|
||||
if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT | MAX17043_CONFIG_SLEEP_MASK)) {
|
||||
ESP_LOGW(TAG, "Unable to write the sleep bit to config register");
|
||||
this->status_set_warning();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::max17043
|
||||
@@ -1,18 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/max170xx/max170xx.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
|
||||
namespace esphome::max17043 {
|
||||
|
||||
class MAX17043Component final : public max170xx::MAX170xxComponent<max170xx::Model::MODEL_MAX17043> {
|
||||
class MAX17043Component final : public PollingComponent, public i2c::I2CDevice {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void update() override;
|
||||
void sleep_mode();
|
||||
|
||||
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; }
|
||||
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; }
|
||||
void set_battery_remaining_sensor(sensor::Sensor *battery_remaining_sensor) {
|
||||
this->battery_remaining_sensor_ = battery_remaining_sensor;
|
||||
battery_remaining_sensor_ = battery_remaining_sensor;
|
||||
}
|
||||
|
||||
protected:
|
||||
|
||||
@@ -16,7 +16,6 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["max170xx"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
|
||||
max17043_ns = cg.esphome_ns.namespace("max17043")
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
CODEOWNERS = ["@B48D81EFCC", "@ryansch"]
|
||||
CODEOWNERS = ["@B48D81EFCC"]
|
||||
DOMAIN = "max17048"
|
||||
@@ -8,27 +8,41 @@ namespace esphome::max17048 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "max17048");
|
||||
|
||||
static constexpr uint8_t REG_CRATE = 0x16; // charge rate, signed
|
||||
static constexpr uint8_t REG_VCELL = 0x02; // cell voltage
|
||||
static constexpr uint8_t REG_SOC = 0x04; // state of charge
|
||||
static constexpr uint8_t REG_VERSION = 0x08; // 0x001x
|
||||
static constexpr uint8_t REG_CRATE = 0x16; // charge rate, signed
|
||||
|
||||
static constexpr uint16_t VERSION_MASK = 0xFFF0;
|
||||
static constexpr uint16_t VERSION_FAMILY = 0x0010;
|
||||
|
||||
static constexpr float VCELL_VOLTS_PER_LSB = 78.125e-6f;
|
||||
static constexpr float SOC_PERCENT_PER_LSB = 1.0f / 256.0f;
|
||||
static constexpr float CRATE_PERCENT_PER_HOUR_PER_LSB = 0.208f;
|
||||
|
||||
void MAX17048Component::update() {
|
||||
if (!this->ready_for_update_())
|
||||
void MAX17048Component::setup() {
|
||||
uint16_t version;
|
||||
if (!this->read_byte_16(REG_VERSION, &version)) {
|
||||
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
if ((version & VERSION_MASK) != VERSION_FAMILY) {
|
||||
ESP_LOGW(TAG, "Unexpected version 0x%04X", version);
|
||||
}
|
||||
}
|
||||
|
||||
void MAX17048Component::update() {
|
||||
bool ok = true;
|
||||
uint16_t raw;
|
||||
if (this->battery_voltage_sensor_ != nullptr) {
|
||||
if (this->read_byte_16(max170xx::REG_VCELL, &raw)) {
|
||||
if (this->read_byte_16(REG_VCELL, &raw)) {
|
||||
this->battery_voltage_sensor_->publish_state(raw * VCELL_VOLTS_PER_LSB);
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (this->battery_level_sensor_ != nullptr) {
|
||||
if (this->read_byte_16(max170xx::REG_SOC, &raw)) {
|
||||
if (this->read_byte_16(REG_SOC, &raw)) {
|
||||
this->battery_level_sensor_->publish_state(raw * SOC_PERCENT_PER_LSB);
|
||||
} else {
|
||||
ok = false;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/max170xx/max170xx.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
|
||||
namespace esphome::max17048 {
|
||||
|
||||
class MAX17048Component final : public max170xx::MAX170xxComponent<max170xx::Model::MODEL_MAX17048> {
|
||||
class MAX17048Component final : public PollingComponent, public i2c::I2CDevice {
|
||||
SUB_SENSOR(battery_voltage)
|
||||
SUB_SENSOR(battery_level)
|
||||
SUB_SENSOR(rate)
|
||||
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void update() override;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
from esphome import automation
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c, sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BATTERY_LEVEL,
|
||||
CONF_BATTERY_VOLTAGE,
|
||||
CONF_ID,
|
||||
CONF_RATE,
|
||||
DEVICE_CLASS_BATTERY,
|
||||
DEVICE_CLASS_VOLTAGE,
|
||||
@@ -17,7 +14,6 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["max170xx"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
|
||||
UNIT_PERCENT_PER_HOUR = "%/h"
|
||||
@@ -65,15 +61,3 @@ async def to_code(config: ConfigType) -> None:
|
||||
await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage_sensor)
|
||||
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor)
|
||||
await sensors(CONF_RATE, var.set_rate_sensor)
|
||||
|
||||
|
||||
MAX17048_ACTION_SCHEMA = maybe_simple_id(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(MAX17048Component),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
"max17048.sleep_mode", MAX17048_ACTION_SCHEMA, automation.ApplyCall("sleep_mode()")
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
CODEOWNERS = ["@ryansch"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
DOMAIN = "max170xx"
|
||||
@@ -1,87 +0,0 @@
|
||||
#include "max170xx.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::max170xx {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "max170xx");
|
||||
|
||||
template<Model MODEL> void MAX170xxComponent<MODEL>::setup() {
|
||||
// A silent bus is retried from update(): some boards gate the I2C rail until after setup.
|
||||
// An early on_boot may already have configured the gauge and put it to sleep, so this must not configure again.
|
||||
this->ready_for_update_();
|
||||
}
|
||||
|
||||
template<Model MODEL> bool MAX170xxComponent<MODEL>::ready_for_update_() {
|
||||
if (this->configured_ || this->configure_())
|
||||
return true;
|
||||
this->status_set_warning(LOG_STR("Gauge did not answer"));
|
||||
return false;
|
||||
}
|
||||
|
||||
template<Model MODEL> bool MAX170xxComponent<MODEL>::configure_() {
|
||||
uint16_t config;
|
||||
if (!this->read_byte_16(REG_CONFIG, &config))
|
||||
return false;
|
||||
|
||||
const bool was_asleep = (config & CONFIG_SLEEP) != 0;
|
||||
// RCOMP and ATHD survive on the battery, so an earlier firmware's values are reset here, not treated as a fault
|
||||
if ((config & CONFIG_SAFE_MASK) != CONFIG_POWER_UP_DEFAULT) {
|
||||
ESP_LOGW(TAG, "CONFIG reads 0x%04X, expected 0x%04X; resetting it", config & CONFIG_SAFE_MASK,
|
||||
CONFIG_POWER_UP_DEFAULT);
|
||||
}
|
||||
|
||||
uint16_t version;
|
||||
if (this->read_byte_16(REG_VERSION, &version)) {
|
||||
ESP_LOGCONFIG(TAG, "IC version: 0x%04X", version);
|
||||
}
|
||||
|
||||
// Write back the power-up value to clear a stale sleep bit; a failed write is retried like a silent bus
|
||||
if (!this->write_byte_16(REG_CONFIG, CONFIG_POWER_UP_DEFAULT))
|
||||
return false;
|
||||
|
||||
this->configured_ = true;
|
||||
// Waking from sleep raises a false low-battery alert; a real one at an ordinary boot is left alone
|
||||
if (was_asleep)
|
||||
this->set_timeout(WAKE_ALERT_CLEAR_TIMEOUT_ID, WAKE_ALERT_CLEAR_DELAY_MS, [this]() { this->clear_wake_alert_(); });
|
||||
return true;
|
||||
}
|
||||
|
||||
template<Model MODEL> void MAX170xxComponent<MODEL>::clear_wake_alert_() {
|
||||
uint16_t config;
|
||||
if (!this->read_byte_16(REG_CONFIG, &config) || !this->write_byte_16(REG_CONFIG, config & ~CONFIG_ALRT)) {
|
||||
this->status_set_warning(LOG_STR("Wake alert clear failed"));
|
||||
return;
|
||||
}
|
||||
if constexpr (MODEL == Model::MODEL_MAX17048) {
|
||||
uint16_t status;
|
||||
if (!this->read_byte_16(REG_STATUS, &status) || !this->write_byte_16(REG_STATUS, status & ~STATUS_HD))
|
||||
this->status_set_warning(LOG_STR("Wake alert clear failed"));
|
||||
}
|
||||
}
|
||||
|
||||
template<Model MODEL> void MAX170xxComponent<MODEL>::sleep_mode() {
|
||||
if (this->is_failed())
|
||||
return;
|
||||
// The deferred configuration clears the sleep bit, so it has to run before the gauge is put to sleep
|
||||
if (!this->ready_for_update_())
|
||||
return;
|
||||
|
||||
// A clear still pending would otherwise run against a sleeping gauge
|
||||
if (this->cancel_timeout(WAKE_ALERT_CLEAR_TIMEOUT_ID))
|
||||
this->clear_wake_alert_();
|
||||
|
||||
if constexpr (MODEL == Model::MODEL_MAX17048) {
|
||||
if (!this->write_byte_16(REG_MODE, MODE_EN_SLEEP)) {
|
||||
this->status_set_warning(LOG_STR("Unable to enable sleep mode in the mode register"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this->write_byte_16(REG_CONFIG, CONFIG_POWER_UP_DEFAULT | CONFIG_SLEEP))
|
||||
this->status_set_warning(LOG_STR("Unable to write the sleep bit to config register"));
|
||||
}
|
||||
|
||||
template class MAX170xxComponent<Model::MODEL_MAX17043>;
|
||||
template class MAX170xxComponent<Model::MODEL_MAX17048>;
|
||||
|
||||
} // namespace esphome::max170xx
|
||||
@@ -1,61 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
|
||||
namespace esphome::max170xx {
|
||||
|
||||
// Shared base for the ModelGauge fuel gauges.
|
||||
// MAX17043: https://www.analog.com/en/products/max17043.html
|
||||
// MAX17048: https://www.analog.com/en/products/max17048.html
|
||||
// The MAX17048 is register-compatible with the MAX17043, and adds a STATUS register and a MODE.EnSleep gate in
|
||||
// front of CONFIG.SLEEP.
|
||||
|
||||
static constexpr uint8_t REG_VCELL = 0x02;
|
||||
static constexpr uint8_t REG_SOC = 0x04;
|
||||
static constexpr uint8_t REG_MODE = 0x06;
|
||||
static constexpr uint8_t REG_VERSION = 0x08;
|
||||
static constexpr uint8_t REG_CONFIG = 0x0C;
|
||||
static constexpr uint8_t REG_STATUS = 0x1A;
|
||||
|
||||
static constexpr uint16_t CONFIG_POWER_UP_DEFAULT = 0x971C;
|
||||
// Mask out SLEEP (7), ALRT (5), and bit 6, which is a don't-care on the MAX17043 but ALSC on the MAX17048.
|
||||
static constexpr uint16_t CONFIG_SAFE_MASK = 0xFF1F;
|
||||
static constexpr uint16_t CONFIG_SLEEP = 0x0080;
|
||||
static constexpr uint16_t CONFIG_ALRT = 0x0020;
|
||||
|
||||
// CONFIG.SLEEP is ignored unless MODE.EnSleep (bit 13) is set first.
|
||||
static constexpr uint16_t MODE_EN_SLEEP = 0x2000;
|
||||
static constexpr uint16_t STATUS_HD = 0x1000;
|
||||
|
||||
static constexpr uint32_t WAKE_ALERT_CLEAR_TIMEOUT_ID = 0;
|
||||
// The gauge raises its false wake alert some time after the wake write; on hardware it rose 0.8 to 1.0 s later
|
||||
static constexpr uint32_t WAKE_ALERT_CLEAR_DELAY_MS = 2000;
|
||||
|
||||
enum class Model : uint8_t {
|
||||
MODEL_MAX17043,
|
||||
MODEL_MAX17048,
|
||||
};
|
||||
|
||||
/// Defined in max170xx.cpp, which instantiates both models.
|
||||
template<Model MODEL> class MAX170xxComponent : public PollingComponent, public i2c::I2CDevice {
|
||||
public:
|
||||
void setup() override;
|
||||
void sleep_mode();
|
||||
|
||||
protected:
|
||||
/// True once the gauge is configured; otherwise retries the configuration and warns if the gauge did not answer.
|
||||
/// Call first in update().
|
||||
bool ready_for_update_();
|
||||
/// Verify and wake the gauge; false if it did not answer.
|
||||
bool configure_();
|
||||
/// Clear the false alert raised on waking, leaving every other CONFIG and STATUS bit as it is.
|
||||
void clear_wake_alert_();
|
||||
|
||||
bool configured_{false};
|
||||
};
|
||||
|
||||
extern template class MAX170xxComponent<Model::MODEL_MAX17043>;
|
||||
extern template class MAX170xxComponent<Model::MODEL_MAX17048>;
|
||||
|
||||
} // namespace esphome::max170xx
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "mcp23016.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cstdio>
|
||||
|
||||
@@ -80,7 +79,11 @@ void MCP23016::update_reg_(uint8_t pin, bool pin_value, uint8_t reg_addr) {
|
||||
this->read_reg_(reg_addr, ®_value);
|
||||
}
|
||||
|
||||
assign_bits(reg_value, 1 << pin, pin_value);
|
||||
if (pin_value) {
|
||||
reg_value |= 1 << pin;
|
||||
} else {
|
||||
reg_value &= ~(1 << pin);
|
||||
}
|
||||
|
||||
this->write_reg_(reg_addr, reg_value);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "mcp23x08_base.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::mcp23x08_base {
|
||||
@@ -79,7 +78,11 @@ void MCP23X08Base::update_reg(uint8_t pin, bool pin_value, uint8_t reg_addr) {
|
||||
this->read_reg(reg_addr, ®_value);
|
||||
}
|
||||
|
||||
assign_bits(reg_value, 1 << bit, pin_value);
|
||||
if (pin_value) {
|
||||
reg_value |= 1 << bit;
|
||||
} else {
|
||||
reg_value &= ~(1 << bit);
|
||||
}
|
||||
|
||||
this->write_reg(reg_addr, reg_value);
|
||||
|
||||
|
||||
@@ -92,7 +92,11 @@ void MCP23X17Base::update_reg(uint8_t pin, bool pin_value, uint8_t reg_addr) {
|
||||
this->read_reg(reg_addr, ®_value);
|
||||
}
|
||||
|
||||
assign_bits(reg_value, 1 << bit, pin_value);
|
||||
if (pin_value) {
|
||||
reg_value |= 1 << bit;
|
||||
} else {
|
||||
reg_value &= ~(1 << bit);
|
||||
}
|
||||
|
||||
this->write_reg(reg_addr, reg_value);
|
||||
|
||||
|
||||
@@ -33,22 +33,22 @@ uint16_t parse_u8_hb_60(OpenthermData &data) { return data.valueHB * 60; }
|
||||
int16_t parse_s16(OpenthermData &data) { return data.get_s16(); }
|
||||
float parse_f88(OpenthermData &data) { return data.get_f88(); }
|
||||
|
||||
void write_flag8_lb_0(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 0, value); }
|
||||
void write_flag8_lb_1(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 1, value); }
|
||||
void write_flag8_lb_2(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 2, value); }
|
||||
void write_flag8_lb_3(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 3, value); }
|
||||
void write_flag8_lb_4(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 4, value); }
|
||||
void write_flag8_lb_5(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 5, value); }
|
||||
void write_flag8_lb_6(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 6, value); }
|
||||
void write_flag8_lb_7(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 7, value); }
|
||||
void write_flag8_hb_0(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 0, value); }
|
||||
void write_flag8_hb_1(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 1, value); }
|
||||
void write_flag8_hb_2(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 2, value); }
|
||||
void write_flag8_hb_3(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 3, value); }
|
||||
void write_flag8_hb_4(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 4, value); }
|
||||
void write_flag8_hb_5(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 5, value); }
|
||||
void write_flag8_hb_6(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 6, value); }
|
||||
void write_flag8_hb_7(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 7, value); }
|
||||
void write_flag8_lb_0(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 0, value); }
|
||||
void write_flag8_lb_1(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 1, value); }
|
||||
void write_flag8_lb_2(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 2, value); }
|
||||
void write_flag8_lb_3(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 3, value); }
|
||||
void write_flag8_lb_4(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 4, value); }
|
||||
void write_flag8_lb_5(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 5, value); }
|
||||
void write_flag8_lb_6(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 6, value); }
|
||||
void write_flag8_lb_7(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 7, value); }
|
||||
void write_flag8_hb_0(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 0, value); }
|
||||
void write_flag8_hb_1(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 1, value); }
|
||||
void write_flag8_hb_2(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 2, value); }
|
||||
void write_flag8_hb_3(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 3, value); }
|
||||
void write_flag8_hb_4(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 4, value); }
|
||||
void write_flag8_hb_5(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 5, value); }
|
||||
void write_flag8_hb_6(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 6, value); }
|
||||
void write_flag8_hb_7(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 7, value); }
|
||||
void write_u8_lb(const uint8_t value, OpenthermData &data) { data.valueLB = value; }
|
||||
void write_u8_hb(const uint8_t value, OpenthermData &data) { data.valueHB = value; }
|
||||
void write_s8_lb(const int8_t value, OpenthermData &data) { data.valueLB = (uint8_t) value; }
|
||||
|
||||
@@ -20,6 +20,14 @@ namespace esphome::opentherm {
|
||||
|
||||
template<class T> constexpr T read_bit(T value, uint8_t bit) { return (value >> bit) & 0x01; }
|
||||
|
||||
template<class T> constexpr T set_bit(T value, uint8_t bit) { return value |= (1UL << bit); }
|
||||
|
||||
template<class T> constexpr T clear_bit(T value, uint8_t bit) { return value &= ~(1UL << bit); }
|
||||
|
||||
template<class T> constexpr T write_bit(T value, uint8_t bit, uint8_t bit_value) {
|
||||
return bit_value ? set_bit(value, bit) : clear_bit(value, bit);
|
||||
}
|
||||
|
||||
enum OperationMode {
|
||||
IDLE = 0, // no operation
|
||||
|
||||
|
||||
@@ -1,56 +1,175 @@
|
||||
from collections.abc import Callable
|
||||
import importlib
|
||||
import pkgutil
|
||||
from typing import Any
|
||||
|
||||
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,
|
||||
STACK_ORDER,
|
||||
PacketInterface,
|
||||
build_stack,
|
||||
check_packet_sizes,
|
||||
check_session,
|
||||
check_single_peer,
|
||||
packet_interface_ns,
|
||||
stack_auto_load,
|
||||
stack_schema,
|
||||
)
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
DOMAIN = "packet_interface"
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
|
||||
CONF_PACKET_INTERFACE = "packet_interface"
|
||||
|
||||
packet_interface_ns = cg.esphome_ns.namespace("packet_interface")
|
||||
PacketInterface = packet_interface_ns.class_("PacketInterface", cg.Component)
|
||||
# Each module in layers registers a layer type; import them all before building the schemas from the registry
|
||||
for module_info in pkgutil.iter_modules(layers.__path__):
|
||||
importlib.import_module(f".layers.{module_info.name}", package=__package__)
|
||||
|
||||
LINK_LAYER_SCHEMAS = {
|
||||
cv.Optional(CONF_ADDRESSING): ADDRESSING.schema(),
|
||||
**stack_schema(),
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"CONF_MAX_PACKET_SIZE",
|
||||
"CONF_PACKET_INTERFACE",
|
||||
"interface_layers",
|
||||
"PacketInterface",
|
||||
"final_validate_interface",
|
||||
"new_packet_interface",
|
||||
"packet_interface_ns",
|
||||
"packet_interface_schema",
|
||||
]
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Without a config, as tooling that lists dependencies calls it, everything a layer could load.
|
||||
"""
|
||||
if config is None:
|
||||
return ["binary_sensor", "xxtea"]
|
||||
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 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 packet_interface_schema(
|
||||
class_: MockObjClass,
|
||||
default_max_packet_size: int,
|
||||
max_packet_size_limit: int = 4096,
|
||||
) -> cv.Schema:
|
||||
"""Schema for a link platform."""
|
||||
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),
|
||||
}
|
||||
).extend(cv.COMPONENT_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,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.add_extra(_validate_layers)
|
||||
)
|
||||
|
||||
|
||||
async def new_packet_interface(config: ConfigType, *args: Any) -> MockObj:
|
||||
"""Create the link layer.
|
||||
"""Create the link layer and the layers configured on it, from the bottom up.
|
||||
|
||||
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().
|
||||
Returns the link layer; applications reach the top layer with PacketInterface::top(). With several addressing
|
||||
peers, applications use the peers' ids instead.
|
||||
"""
|
||||
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)
|
||||
return var
|
||||
@@ -0,0 +1,97 @@
|
||||
#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
|
||||
@@ -0,0 +1,69 @@
|
||||
#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
|
||||
@@ -0,0 +1,160 @@
|
||||
#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
|
||||
@@ -0,0 +1,60 @@
|
||||
#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
|
||||
@@ -0,0 +1,135 @@
|
||||
#include "basic_session.h"
|
||||
#ifdef USE_PACKET_INTERFACE_BASIC_SESSION
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "packet_interface.session");
|
||||
|
||||
static constexpr uint8_t PACKET_TYPE_DATA = 0x00;
|
||||
static constexpr uint8_t PACKET_TYPE_KEEP_ALIVE = 0x01;
|
||||
// Type byte and the 32-bit boot id, little endian
|
||||
static constexpr size_t KEEP_ALIVE_SIZE = 5;
|
||||
|
||||
BasicSession::BasicSession(PacketInterface *lower)
|
||||
: PacketInterface(remaining_packet_size(lower->get_max_packet_size(), HEADER_SIZE)), lower_(lower) {
|
||||
this->stack_on_(lower, CAPABILITY_SESSION);
|
||||
// Data lost by a reliability layer below must be sent again, as after a restart of the peer
|
||||
if (lower->has_capability(CAPABILITY_RELIABILITY)) {
|
||||
lower->add_on_session_state_callback([this](bool connected) {
|
||||
if (!connected && this->connected_)
|
||||
this->set_connected_(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void BasicSession::setup() {
|
||||
if (this->lower_->get_max_packet_size() < KEEP_ALIVE_SIZE) {
|
||||
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
|
||||
return;
|
||||
}
|
||||
this->boot_id_ = random_uint32();
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->connected_sensor_ != nullptr)
|
||||
this->connected_sensor_->publish_initial_state(false);
|
||||
#endif
|
||||
this->lower_->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
|
||||
// A reliability layer below queues this until its link is up, so the peer hears of a restart straight away
|
||||
this->send_keep_alive_();
|
||||
this->set_interval(this->keep_alive_ms_, [this] { this->on_interval_(); });
|
||||
}
|
||||
|
||||
void BasicSession::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Basic Session on '%s':\n"
|
||||
" Max Packet Size: %zu\n"
|
||||
" Keep Alive: %" PRIu32 "ms\n"
|
||||
" Timeout: %" PRIu32 "ms",
|
||||
this->name_, this->max_packet_size_, this->keep_alive_ms_, this->timeout_ms_);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_);
|
||||
#endif
|
||||
}
|
||||
|
||||
bool BasicSession::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
if (this->is_failed())
|
||||
return false;
|
||||
uint8_t header = PACKET_TYPE_DATA;
|
||||
PacketBuffer packet(&header, HEADER_SIZE, &data, nullptr, 0);
|
||||
return this->lower_->send_to_interface(packet, meta_data);
|
||||
}
|
||||
|
||||
void BasicSession::send_keep_alive_() {
|
||||
uint8_t packet[KEEP_ALIVE_SIZE] = {
|
||||
PACKET_TYPE_KEEP_ALIVE, static_cast<uint8_t>(this->boot_id_), static_cast<uint8_t>(this->boot_id_ >> 8),
|
||||
static_cast<uint8_t>(this->boot_id_ >> 16), static_cast<uint8_t>(this->boot_id_ >> 24)};
|
||||
if (!this->lower_->send_to_interface(PacketBuffer(packet, sizeof(packet)), {}))
|
||||
ESP_LOGV(TAG, "%s: Lower layer did not accept keep-alive", this->name_);
|
||||
}
|
||||
|
||||
void BasicSession::on_interval_() {
|
||||
this->send_keep_alive_();
|
||||
if (this->connected_ && App.get_loop_component_start_time() - this->last_receive_ms_ > this->timeout_ms_) {
|
||||
ESP_LOGW(TAG, "%s: Peer not heard for %" PRIu32 "ms", this->name_, this->timeout_ms_);
|
||||
this->set_connected_(false);
|
||||
}
|
||||
}
|
||||
|
||||
void BasicSession::set_connected_(bool connected) {
|
||||
this->connected_ = connected;
|
||||
ESP_LOGD(TAG, "%s: Session %s", this->name_, connected ? LOG_STR_LITERAL("connected") : LOG_STR_LITERAL("lost"));
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->connected_sensor_ != nullptr)
|
||||
this->connected_sensor_->publish_state(connected);
|
||||
#endif
|
||||
this->session_callback_.call(connected);
|
||||
}
|
||||
|
||||
void BasicSession::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->name_);
|
||||
return;
|
||||
}
|
||||
auto packet = data.as_span();
|
||||
if (packet.empty()) {
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
uint8_t type = packet[0];
|
||||
if (type == PACKET_TYPE_KEEP_ALIVE) {
|
||||
if (packet.size() != KEEP_ALIVE_SIZE) {
|
||||
ESP_LOGV(TAG, "%s: Keep-alive of %zu bytes dropped", this->name_, packet.size());
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
uint32_t boot_id = encode_uint32(packet[4], packet[3], packet[2], packet[1]);
|
||||
if (!this->peer_known_ || boot_id != this->peer_boot_id_) {
|
||||
// Data may arrive before the first keep-alive, so only a changed id means a restart
|
||||
if (this->connected_ && this->peer_known_) {
|
||||
ESP_LOGD(TAG, "%s: Peer has restarted", this->name_);
|
||||
this->set_connected_(false);
|
||||
}
|
||||
this->peer_boot_id_ = boot_id;
|
||||
this->peer_known_ = true;
|
||||
// Answer a new peer at once; it answers only if our id is new to it, so this cannot repeat
|
||||
this->send_keep_alive_();
|
||||
}
|
||||
} else if (type != PACKET_TYPE_DATA) {
|
||||
ESP_LOGV(TAG, "%s: Unknown packet type 0x%02X dropped", this->name_, type);
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
this->note_received_(true);
|
||||
this->last_receive_ms_ = App.get_loop_component_start_time();
|
||||
if (!this->connected_)
|
||||
this->set_connected_(true);
|
||||
if (type == PACKET_TYPE_DATA)
|
||||
this->on_receive_from_interface_(PacketBuffer(packet.data() + HEADER_SIZE, packet.size() - HEADER_SIZE), meta_data);
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_PACKET_INTERFACE_BASIC_SESSION
|
||||
#include "packet_interface.h"
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
/**
|
||||
* A session layer that reports whether the peer can be reached.
|
||||
*
|
||||
* Every packet starts with a type byte. A data packet carries the payload of the layer above. A keep-alive packet
|
||||
* carries a random number chosen at boot, so that a restart of the peer is noticed even if it is quicker than the
|
||||
* timeout. A keep-alive is sent every keep_alive interval, and at once when a new peer is heard. The peer is
|
||||
* considered gone when nothing has been received from it for the timeout; this is checked every keep_alive interval.
|
||||
* A restart of the peer is reported as a disconnect followed by a connect. So is a reset of a reliability layer below,
|
||||
* which discards data: the session is reported lost, and connected again when the peer is next heard.
|
||||
*/
|
||||
class BasicSession : public PacketInterface {
|
||||
public:
|
||||
/// The packet type byte.
|
||||
static constexpr size_t HEADER_SIZE = 1;
|
||||
|
||||
explicit BasicSession(PacketInterface *lower);
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
|
||||
|
||||
void set_keep_alive(uint32_t keep_alive_ms) { this->keep_alive_ms_ = keep_alive_ms; }
|
||||
void set_session_timeout(uint32_t timeout_ms) { this->timeout_ms_ = timeout_ms; }
|
||||
#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 receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
|
||||
void on_interval_();
|
||||
void send_keep_alive_();
|
||||
void set_connected_(bool connected);
|
||||
|
||||
PacketInterface *lower_;
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
binary_sensor::BinarySensor *connected_sensor_{nullptr};
|
||||
#endif
|
||||
uint32_t keep_alive_ms_{10000};
|
||||
uint32_t timeout_ms_{30000};
|
||||
uint32_t last_receive_ms_{0};
|
||||
uint32_t boot_id_{0};
|
||||
uint32_t peer_boot_id_{0};
|
||||
bool peer_known_{false};
|
||||
bool connected_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,365 @@
|
||||
#include "hdlc_reliability.h"
|
||||
#ifdef USE_PACKET_INTERFACE_HDLC
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "packet_interface.hdlc");
|
||||
|
||||
static constexpr uint32_t RETRANSMIT_TIMER_ID = 0;
|
||||
static constexpr uint32_t ACK_TIMER_ID = 1;
|
||||
static constexpr uint32_t SEND_RETRY_TIMER_ID = 2;
|
||||
// A frame or acknowledgement the lower layer refused, for example while a radio was receiving, is sent again after this
|
||||
static constexpr uint32_t SEND_RETRY_MS = 50;
|
||||
// SABM is repeated at this interval, or the timeout if longer, until the peer answers
|
||||
static constexpr uint32_t RESET_INTERVAL_MS = 1000;
|
||||
|
||||
// Control byte formats, with the poll/final bit (0x10) ignored
|
||||
static constexpr uint8_t POLL_FINAL_BIT = 0x10;
|
||||
static constexpr uint8_t SUPERVISORY_FRAME = 0x01;
|
||||
static constexpr uint8_t SUPERVISORY_RR = 0x00;
|
||||
static constexpr uint8_t SUPERVISORY_REJ = 0x02;
|
||||
static constexpr uint8_t UNNUMBERED_SABM = 0x2F;
|
||||
static constexpr uint8_t UNNUMBERED_UA = 0x63;
|
||||
|
||||
static uint16_t frame_crc(uint8_t control, const uint8_t *payload, size_t length) {
|
||||
uint16_t crc = crc16(&control, 1);
|
||||
return crc16(payload, length, crc);
|
||||
}
|
||||
|
||||
HdlcReliability::HdlcReliability(PacketInterface *lower)
|
||||
: PacketInterface(remaining_packet_size(lower->get_max_packet_size(), FRAME_OVERHEAD)), lower_(lower) {
|
||||
this->stack_on_(lower, CAPABILITY_RELIABILITY);
|
||||
}
|
||||
|
||||
void HdlcReliability::setup() {
|
||||
if (this->max_packet_size_ == 0) {
|
||||
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
|
||||
return;
|
||||
}
|
||||
RAMAllocator<uint8_t> allocator;
|
||||
this->tx_slots_ = allocator.allocate(size_t{this->max_queue_length_} * this->max_packet_size_);
|
||||
if (this->tx_slots_ == nullptr) {
|
||||
this->mark_failed(LOG_STR("Not enough memory for the send queue"));
|
||||
return;
|
||||
}
|
||||
this->lower_->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
|
||||
}
|
||||
|
||||
void HdlcReliability::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"HDLC Reliability on '%s':\n"
|
||||
" Max Packet Size: %zu\n"
|
||||
" Timeout: %" PRIu32 "ms\n"
|
||||
" Ack Delay: %" PRIu32 "ms\n"
|
||||
" Max Queue Length: %u\n"
|
||||
" Max Retries: %u",
|
||||
this->name_, this->max_packet_size_, this->timeout_ms_, this->ack_delay_ms_, this->max_queue_length_,
|
||||
this->max_retries_);
|
||||
}
|
||||
|
||||
void HdlcReliability::loop() {
|
||||
// Disabled first, as sending may deliver a reply to this layer and request the loop again
|
||||
this->disable_loop();
|
||||
if (this->state_ == LinkState::LINK_STATE_RESET_PENDING) {
|
||||
this->state_ = LinkState::LINK_STATE_RESETTING;
|
||||
this->start_timer_();
|
||||
this->send_frame_(UNNUMBERED_SABM, nullptr, 0);
|
||||
}
|
||||
if (this->state_ != LinkState::LINK_STATE_CONNECTED)
|
||||
return;
|
||||
// Queued frames go first, as each carries the acknowledgement
|
||||
if (this->send_pending_() && this->ack_due_ && !this->send_supervisory_(SUPERVISORY_RR))
|
||||
this->retry_send_();
|
||||
}
|
||||
|
||||
void HdlcReliability::retry_send_() {
|
||||
this->set_timeout(SEND_RETRY_TIMER_ID, SEND_RETRY_MS, [this]() { this->request_loop_(); });
|
||||
}
|
||||
|
||||
void HdlcReliability::owe_ack_(bool now) {
|
||||
if (now || this->ack_delay_ms_ == 0) {
|
||||
this->ack_due_ = true;
|
||||
this->request_loop_();
|
||||
} else if (!this->ack_pending_) {
|
||||
// Waiting lets one acknowledgement cover several frames, or a reply carry it
|
||||
this->set_timeout(ACK_TIMER_ID, this->ack_delay_ms_, [this]() {
|
||||
this->ack_due_ = true;
|
||||
this->request_loop_();
|
||||
});
|
||||
}
|
||||
this->ack_pending_ = true;
|
||||
}
|
||||
|
||||
void HdlcReliability::ack_sent_() {
|
||||
this->ack_pending_ = false;
|
||||
this->ack_due_ = false;
|
||||
this->cancel_timeout(ACK_TIMER_ID);
|
||||
}
|
||||
|
||||
void HdlcReliability::request_loop_() { this->enable_loop(); }
|
||||
|
||||
bool HdlcReliability::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
if (this->tx_slots_ == 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;
|
||||
}
|
||||
uint8_t outstanding = this->outstanding_();
|
||||
if (outstanding >= this->max_queue_length_) {
|
||||
if (this->state_ == LinkState::LINK_STATE_CONNECTED) {
|
||||
ESP_LOGV(TAG, "%s: Send queue full", this->name_);
|
||||
} else if (!this->reset_wait_logged_) {
|
||||
// Once for each reset; the layers above keep trying while the peer is away
|
||||
this->reset_wait_logged_ = true;
|
||||
ESP_LOGD(TAG, "%s: Send queue full, waiting for the peer to answer the link reset", this->name_);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
uint8_t seq = this->vs_;
|
||||
data.copy_to(this->slot_(seq), size);
|
||||
this->tx_lengths_[this->slot_index_(seq)] = size;
|
||||
this->vs_ = (seq + 1) & 7;
|
||||
// While the link is being reset, the frame waits in the queue and is sent when UA arrives
|
||||
if (this->state_ == LinkState::LINK_STATE_CONNECTED) {
|
||||
this->send_pending_();
|
||||
if (outstanding == 0)
|
||||
this->start_timer_();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HdlcReliability::send_frame_(uint8_t control, const uint8_t *payload, size_t length) {
|
||||
uint16_t crc = frame_crc(control, payload, length);
|
||||
uint8_t trailer[2] = {static_cast<uint8_t>(crc), static_cast<uint8_t>(crc >> 8)};
|
||||
PacketBuffer body(payload, length);
|
||||
PacketBuffer frame(&control, 1, length != 0 ? &body : nullptr, trailer, sizeof(trailer));
|
||||
if (this->lower_->send_to_interface(frame, {}))
|
||||
return true;
|
||||
ESP_LOGV(TAG, "%s: Lower layer did not accept frame 0x%02X", this->name_, control);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool HdlcReliability::send_supervisory_(uint8_t type) {
|
||||
if (!this->send_frame_(SUPERVISORY_FRAME | (type << 2) | (this->vr_ << 5), nullptr, 0))
|
||||
return false;
|
||||
this->ack_sent_();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sends the next queued frame not yet sent, and returns true if none are left. One frame per loop pass lets other
|
||||
// components run between frames: on a radio, sending blocks, and the peer's frames are received in between. If the
|
||||
// lower layer refuses the frame, it is sent again shortly.
|
||||
bool HdlcReliability::send_pending_() {
|
||||
if (this->vn_ == this->vs_)
|
||||
return true;
|
||||
uint8_t control = (this->vn_ << 1) | (this->vr_ << 5);
|
||||
if (!this->send_frame_(control, this->slot_(this->vn_), this->tx_lengths_[this->slot_index_(this->vn_)])) {
|
||||
this->retry_send_();
|
||||
return false;
|
||||
}
|
||||
// Every I-frame carries an acknowledgement, so a pending RR is no longer needed
|
||||
this->ack_sent_();
|
||||
this->vn_ = (this->vn_ + 1) & 7;
|
||||
if (this->vn_ == this->vs_)
|
||||
return true;
|
||||
this->request_loop_();
|
||||
return false;
|
||||
}
|
||||
|
||||
void HdlcReliability::resend_outstanding_() {
|
||||
this->vn_ = this->va_;
|
||||
this->send_pending_();
|
||||
}
|
||||
|
||||
void HdlcReliability::start_timer_() {
|
||||
uint32_t interval = this->timeout_ms_;
|
||||
if (this->state_ != LinkState::LINK_STATE_CONNECTED && interval < RESET_INTERVAL_MS)
|
||||
interval = RESET_INTERVAL_MS;
|
||||
// Up to a quarter longer at random, so devices whose frames collided on a shared channel do not resend together
|
||||
interval += random_uint32() % (interval / 4 + 1);
|
||||
this->set_timeout(RETRANSMIT_TIMER_ID, interval, [this]() { this->on_timeout_(); });
|
||||
}
|
||||
|
||||
void HdlcReliability::on_timeout_() {
|
||||
if (this->state_ != LinkState::LINK_STATE_CONNECTED) {
|
||||
// Counted up to max_retries_ only, so the warning is logged once until the link comes up
|
||||
if (this->retries_ < this->max_retries_) {
|
||||
this->retries_++;
|
||||
if (this->retries_ == this->max_retries_) {
|
||||
ESP_LOGW(TAG, "%s: No answer to the link reset after %u tries; check that the peer has a reliability layer",
|
||||
this->name_, this->max_retries_);
|
||||
}
|
||||
}
|
||||
this->send_frame_(UNNUMBERED_SABM, nullptr, 0);
|
||||
this->start_timer_();
|
||||
return;
|
||||
}
|
||||
if (this->outstanding_() == 0)
|
||||
return;
|
||||
if (this->retries_ == this->max_retries_) {
|
||||
ESP_LOGW(TAG, "%s: No acknowledgement after %u retries, resetting link", this->name_, this->max_retries_);
|
||||
this->start_reset_();
|
||||
return;
|
||||
}
|
||||
this->retries_++;
|
||||
ESP_LOGV(TAG, "%s: Timeout, resending %u frames", this->name_, this->outstanding_());
|
||||
this->resend_outstanding_();
|
||||
this->start_timer_();
|
||||
}
|
||||
|
||||
// Unacknowledged frames are discarded; frames queued from now on are numbered from 0 and sent once the link is up.
|
||||
// Resetting a connected link can lose data both ways, which is reported to the layers above.
|
||||
void HdlcReliability::start_reset_() {
|
||||
bool was_connected = this->state_ == LinkState::LINK_STATE_CONNECTED;
|
||||
this->state_ = LinkState::LINK_STATE_RESET_PENDING;
|
||||
this->va_ = this->vs_ = this->vn_ = 0;
|
||||
this->head_ = 0;
|
||||
this->vr_ = 0;
|
||||
this->retries_ = 0;
|
||||
this->rej_sent_ = false;
|
||||
this->ack_sent_();
|
||||
this->cancel_timeout(RETRANSMIT_TIMER_ID);
|
||||
this->request_loop_();
|
||||
if (was_connected)
|
||||
this->session_callback_.call(false);
|
||||
}
|
||||
|
||||
void HdlcReliability::link_up_() {
|
||||
ESP_LOGD(TAG, "%s: Link connected", this->name_);
|
||||
this->state_ = LinkState::LINK_STATE_CONNECTED;
|
||||
this->reset_wait_logged_ = false;
|
||||
this->peer_frame_seen_ = false;
|
||||
this->retries_ = 0;
|
||||
this->cancel_timeout(RETRANSMIT_TIMER_ID);
|
||||
if (this->outstanding_() != 0) {
|
||||
this->resend_outstanding_();
|
||||
this->start_timer_();
|
||||
}
|
||||
}
|
||||
|
||||
void HdlcReliability::process_ack_(uint8_t nr) {
|
||||
uint8_t acked = (nr - this->va_) & 7;
|
||||
if (acked > this->outstanding_()) {
|
||||
ESP_LOGV(TAG, "%s: Ignoring invalid N(R) %u", this->name_, nr);
|
||||
return;
|
||||
}
|
||||
if (acked == 0)
|
||||
return;
|
||||
// The peer may have had frames that were refused when last resent
|
||||
if (acked > ((this->vn_ - this->va_) & 7))
|
||||
this->vn_ = nr;
|
||||
this->head_ = (this->head_ + acked) % this->max_queue_length_;
|
||||
this->va_ = nr;
|
||||
this->retries_ = 0;
|
||||
if (this->outstanding_() == 0) {
|
||||
this->cancel_timeout(RETRANSMIT_TIMER_ID);
|
||||
} else {
|
||||
this->start_timer_();
|
||||
}
|
||||
}
|
||||
|
||||
void HdlcReliability::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->name_);
|
||||
return;
|
||||
}
|
||||
auto frame = data.as_span();
|
||||
if (frame.size() < FRAME_OVERHEAD) {
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
size_t length = frame.size() - FRAME_OVERHEAD;
|
||||
uint8_t control = frame[0];
|
||||
const uint8_t *payload = frame.data() + 1;
|
||||
uint16_t crc = frame[length + 1] | (frame[length + 2] << 8);
|
||||
if (frame_crc(control, payload, length) != crc) {
|
||||
ESP_LOGV(TAG, "%s: CRC error, frame dropped", this->name_);
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
// The peer's packets may only be larger if it has a larger max_packet_size, which the layers above cannot take
|
||||
if (length > this->max_packet_size_) {
|
||||
if (!this->oversize_warned_) {
|
||||
this->oversize_warned_ = true;
|
||||
ESP_LOGW(TAG,
|
||||
"%s: Frame of %zu bytes is larger than %zu, dropped; check that both devices use the same "
|
||||
"max_packet_size",
|
||||
this->name_, length, this->max_packet_size_);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "%s: Frame of %zu bytes is larger than %zu, dropped", this->name_, length, this->max_packet_size_);
|
||||
}
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
this->note_received_(true);
|
||||
|
||||
uint8_t command = control & ~POLL_FINAL_BIT;
|
||||
if (command == UNNUMBERED_SABM) {
|
||||
// A peer that has not heard our UA repeats its SABM, and cannot have sent a numbered frame meanwhile; answering
|
||||
// again keeps this side connected, and the queued frames, instead of treating the repeat as a restart.
|
||||
if (this->state_ == LinkState::LINK_STATE_CONNECTED && this->peer_frame_seen_) {
|
||||
// The peer has restarted, so frames it has not acknowledged are lost
|
||||
ESP_LOGD(TAG, "%s: Link reset by peer", this->name_);
|
||||
this->start_reset_();
|
||||
}
|
||||
this->vr_ = 0;
|
||||
this->rej_sent_ = false;
|
||||
this->send_frame_(UNNUMBERED_UA, nullptr, 0);
|
||||
// Both sides now start from 0, so the link is up even if our own SABM is unanswered
|
||||
this->link_up_();
|
||||
return;
|
||||
}
|
||||
if (command == UNNUMBERED_UA) {
|
||||
if (this->state_ == LinkState::LINK_STATE_RESETTING)
|
||||
this->link_up_();
|
||||
return;
|
||||
}
|
||||
// Numbered frames are ignored until the link is reset; the peer answers our SABM first
|
||||
if (this->state_ != LinkState::LINK_STATE_CONNECTED)
|
||||
return;
|
||||
this->peer_frame_seen_ = true;
|
||||
|
||||
uint8_t nr = control >> 5;
|
||||
if ((control & 1) == 0) {
|
||||
this->process_ack_(nr);
|
||||
this->receive_info_(control, payload, length, meta_data);
|
||||
return;
|
||||
}
|
||||
if ((control & 3) != SUPERVISORY_FRAME)
|
||||
return;
|
||||
this->process_ack_(nr);
|
||||
if (((control >> 2) & 3) == SUPERVISORY_REJ && this->outstanding_() != 0) {
|
||||
ESP_LOGV(TAG, "%s: Peer rejected from %u", this->name_, nr);
|
||||
this->resend_outstanding_();
|
||||
this->start_timer_();
|
||||
}
|
||||
}
|
||||
|
||||
void HdlcReliability::receive_info_(uint8_t control, const uint8_t *payload, size_t length,
|
||||
const PacketMetaData &meta_data) {
|
||||
uint8_t ns = (control >> 1) & 7;
|
||||
if (ns != this->vr_) {
|
||||
// A gap or a duplicate; ask once for a resend from the expected frame, then just acknowledge at once
|
||||
if (!this->rej_sent_) {
|
||||
this->rej_sent_ = this->send_supervisory_(SUPERVISORY_REJ);
|
||||
if (this->rej_sent_)
|
||||
return;
|
||||
}
|
||||
this->owe_ack_(true);
|
||||
return;
|
||||
}
|
||||
this->vr_ = (this->vr_ + 1) & 7;
|
||||
this->rej_sent_ = false;
|
||||
// The acknowledgement is sent from loop(), so a reply sent from the callback can carry it instead
|
||||
this->owe_ack_(false);
|
||||
this->on_receive_from_interface_(PacketBuffer(payload, length), meta_data);
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,99 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_PACKET_INTERFACE_HDLC
|
||||
#include <array>
|
||||
|
||||
#include "packet_interface.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
/**
|
||||
* A reliability layer that delivers packets in order, without loss or duplicates, between two peers.
|
||||
*
|
||||
* It follows HDLC asynchronous balanced mode, with sequence numbers modulo 8 and go-back-N retransmission:
|
||||
* - I-frames carry a payload, the sequence number N(S) and an acknowledgement N(R) of the peer's frames.
|
||||
* - RR frames acknowledge without a payload; REJ frames ask the peer to resend from N(R).
|
||||
* - SABM resets both peers' sequence numbers, and UA answers it.
|
||||
*
|
||||
* Each frame is the HDLC control byte, the payload, then a CRC-16 of both in little endian order.
|
||||
* Unacknowledged frames are resent after the timeout; after max_retries the link is reset.
|
||||
*/
|
||||
class HdlcReliability : public PacketInterface {
|
||||
public:
|
||||
/// Most frames that can be unacknowledged at once, set by the 3 bit sequence numbers.
|
||||
static constexpr uint8_t MAX_WINDOW = 7;
|
||||
/// Control byte plus CRC-16.
|
||||
static constexpr size_t FRAME_OVERHEAD = 3;
|
||||
|
||||
explicit HdlcReliability(PacketInterface *lower);
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
|
||||
/// No room while the layer has no send queue: before setup(), or when setup() failed.
|
||||
size_t get_send_capacity() const override {
|
||||
return this->tx_slots_ == nullptr ? 0 : this->max_queue_length_ - this->outstanding_();
|
||||
}
|
||||
|
||||
void set_ack_timeout(uint32_t timeout_ms) { this->timeout_ms_ = timeout_ms; }
|
||||
void set_ack_delay(uint32_t ack_delay_ms) { this->ack_delay_ms_ = ack_delay_ms; }
|
||||
void set_max_queue_length(uint8_t length) { this->max_queue_length_ = length; }
|
||||
void set_max_retries(uint8_t retries) { this->max_retries_ = retries; }
|
||||
|
||||
bool is_connected() const { return this->state_ == LinkState::LINK_STATE_CONNECTED; }
|
||||
|
||||
protected:
|
||||
enum class LinkState : uint8_t {
|
||||
LINK_STATE_RESET_PENDING, // SABM to be sent from loop()
|
||||
LINK_STATE_RESETTING, // SABM sent, waiting for UA
|
||||
LINK_STATE_CONNECTED,
|
||||
};
|
||||
|
||||
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
|
||||
void receive_info_(uint8_t control, const uint8_t *payload, size_t length, const PacketMetaData &meta_data);
|
||||
bool send_frame_(uint8_t control, const uint8_t *payload, size_t length);
|
||||
bool send_supervisory_(uint8_t type);
|
||||
bool send_pending_();
|
||||
void resend_outstanding_();
|
||||
void retry_send_();
|
||||
/// An RR is owed: at once, or after the ack delay unless an I-frame carries it first.
|
||||
void owe_ack_(bool now);
|
||||
void ack_sent_();
|
||||
void process_ack_(uint8_t nr);
|
||||
void on_timeout_();
|
||||
void start_reset_();
|
||||
void link_up_();
|
||||
void start_timer_();
|
||||
void request_loop_();
|
||||
uint8_t outstanding_() const { return (this->vs_ - this->va_) & 7; }
|
||||
uint8_t slot_index_(uint8_t seq) const { return (this->head_ + ((seq - this->va_) & 7)) % this->max_queue_length_; }
|
||||
uint8_t *slot_(uint8_t seq) { return this->tx_slots_ + this->slot_index_(seq) * this->max_packet_size_; }
|
||||
|
||||
PacketInterface *lower_;
|
||||
// max_queue_length_ slots of max_packet_size_ bytes, a ring starting at head_ which holds frame va_; allocated in
|
||||
// setup() and never freed
|
||||
uint8_t *tx_slots_{nullptr};
|
||||
std::array<uint16_t, MAX_WINDOW> tx_lengths_{}; // indexed like the slots
|
||||
uint32_t timeout_ms_{200};
|
||||
uint32_t ack_delay_ms_{0};
|
||||
uint8_t max_queue_length_{4};
|
||||
uint8_t max_retries_{5};
|
||||
uint8_t retries_{0};
|
||||
uint8_t head_{0};
|
||||
uint8_t vs_{0}; // sequence number of the next new frame
|
||||
uint8_t vn_{0}; // next frame to send; frames from va_ up to it have been sent
|
||||
uint8_t va_{0}; // oldest unacknowledged frame
|
||||
uint8_t vr_{0}; // next frame expected from the peer
|
||||
LinkState state_{LinkState::LINK_STATE_RESET_PENDING};
|
||||
bool ack_pending_{false}; // an RR is owed
|
||||
bool ack_due_{false}; // and should be sent now
|
||||
bool rej_sent_{false};
|
||||
bool peer_frame_seen_{false}; // a numbered frame has arrived since the link came up
|
||||
bool oversize_warned_{false};
|
||||
bool reset_wait_logged_{false}; // the queue filled while waiting for this reset to be answered
|
||||
};
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,238 @@
|
||||
"""The kinds of layer that can be stacked on a packet interface, and the registry of their implementations.
|
||||
|
||||
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)
|
||||
async def build_hdlc(config: ConfigType, lower: MockObj) -> MockObj:
|
||||
...
|
||||
|
||||
The packet_interface component imports every module here before it builds its schemas, so a new layer type needs
|
||||
only a new module. The kinds, and the order in which they are stacked, are fixed here.
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
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.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.
|
||||
LayerBuilder = Callable[[ConfigType, MockObj], Awaitable[Any]]
|
||||
|
||||
|
||||
def _never(config: ConfigType) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
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]
|
||||
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:
|
||||
"""A kind of layer, such as encryption, with the types that implement it."""
|
||||
|
||||
def __init__(
|
||||
self, key: str, default_type: str, shorthand: str | None = None
|
||||
) -> None:
|
||||
self.key = key
|
||||
self.default_type = default_type
|
||||
# The option that a plain string value sets, as in `encryption: my key`
|
||||
self.shorthand = shorthand
|
||||
self.types: dict[str, LayerType] = {}
|
||||
# Set once the schema is built, as types registered after that would not be in it
|
||||
self.frozen = False
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
schema: Any = None,
|
||||
schema_factory: Callable[[], Any] | None = None,
|
||||
single_peer: Callable[[ConfigType], bool] = _never,
|
||||
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
|
||||
|
||||
def decorator(build: LayerBuilder) -> LayerBuilder:
|
||||
if self.frozen:
|
||||
raise ValueError(
|
||||
f"{self.key} layer type '{name}' is registered after the schemas were built; register it "
|
||||
"before the packet_interface component is imported"
|
||||
)
|
||||
if name in self.types:
|
||||
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,
|
||||
)
|
||||
return build
|
||||
|
||||
return decorator
|
||||
|
||||
def _expand(self, value: Any) -> Any:
|
||||
"""Allow a layer to be enabled with no options, as in `reliability:`, or with its shorthand."""
|
||||
if value is None:
|
||||
return {}
|
||||
if self.shorthand is not None and isinstance(value, str):
|
||||
return {self.shorthand: value}
|
||||
return value
|
||||
|
||||
def schema(self) -> Callable[[Any], ConfigType]:
|
||||
"""The schema for this kind; no type can be registered after this."""
|
||||
self.frozen = True
|
||||
return cv.All(
|
||||
self._expand,
|
||||
cv.typed_schema(
|
||||
{name: layer.schema_factory() for name, layer in self.types.items()},
|
||||
default_type=self.default_type,
|
||||
),
|
||||
)
|
||||
|
||||
def get_type(self, config: ConfigType) -> LayerType:
|
||||
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)
|
||||
|
||||
|
||||
def stack_schema() -> dict:
|
||||
"""Schema entries for the layers of one stack; call only once all layer types are registered."""
|
||||
return {cv.Optional(kind.key): kind.schema() for kind in STACK_ORDER}
|
||||
|
||||
|
||||
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 in config:
|
||||
return
|
||||
for kind in STACK_ORDER:
|
||||
if (layer := config.get(kind.key)) is not None and kind.get_type(
|
||||
layer
|
||||
).needs_session(layer):
|
||||
raise cv.Invalid(
|
||||
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 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:
|
||||
return
|
||||
for kind in STACK_ORDER:
|
||||
if (layer := config.get(kind.key)) is not None and kind.get_type(
|
||||
layer
|
||||
).single_peer(layer):
|
||||
raise cv.Invalid(
|
||||
f"This '{kind.key}' works between two devices only, so it needs a '{CONF_DESTINATION}' other than "
|
||||
"broadcast",
|
||||
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()
|
||||
for kind in STACK_ORDER:
|
||||
if (layer := config.get(kind.key)) is not None:
|
||||
loads.update(kind.get_type(layer).auto_load(layer))
|
||||
return loads
|
||||
|
||||
|
||||
async def build_stack(config: ConfigType, lower: MockObj) -> None:
|
||||
"""Create the layers configured in config, from the bottom up, above lower."""
|
||||
for kind in STACK_ORDER:
|
||||
if (layer := config.get(kind.key)) is not None:
|
||||
lower = await kind.get_type(layer).build(layer, lower)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""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
|
||||
@@ -0,0 +1,56 @@
|
||||
"""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
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Session layer that sends keep-alives and reports whether the peer can be reached."""
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BINARY_SENSOR,
|
||||
CONF_ID,
|
||||
CONF_TIMEOUT,
|
||||
DEVICE_CLASS_CONNECTIVITY,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import SESSION, PacketInterface, packet_interface_ns
|
||||
|
||||
CONF_KEEP_ALIVE = "keep_alive"
|
||||
|
||||
BasicSession = packet_interface_ns.class_("BasicSession", PacketInterface)
|
||||
|
||||
|
||||
def _session_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
|
||||
|
||||
|
||||
BASIC_SESSION_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BasicSession),
|
||||
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)),
|
||||
),
|
||||
cv.Optional(CONF_TIMEOUT): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=cv.TimePeriod(hours=3)),
|
||||
),
|
||||
cv.Optional(CONF_BINARY_SENSOR): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_CONNECTIVITY,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
_session_timeout,
|
||||
)
|
||||
|
||||
|
||||
@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:
|
||||
cg.add_define("USE_PACKET_INTERFACE_BASIC_SESSION")
|
||||
var = cg.new_Pvariable(config[CONF_ID], lower)
|
||||
cg.add(var.set_keep_alive(config[CONF_KEEP_ALIVE]))
|
||||
cg.add(var.set_session_timeout(config[CONF_TIMEOUT]))
|
||||
await cg.register_component(var, config)
|
||||
binary_sensors = binary_sensor.sub_binary_sensors(config)
|
||||
await binary_sensors(CONF_BINARY_SENSOR, var.set_connected_sensor)
|
||||
return var
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Reliability layer using HDLC style acknowledgement and go-back-N retransmission."""
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _ack_delay_within_timeout(config: ConfigType) -> ConfigType:
|
||||
"""The peer, configured the same, must hear the acknowledgement before its timeout."""
|
||||
if (
|
||||
config[CONF_ACK_DELAY].total_milliseconds
|
||||
>= config[CONF_TIMEOUT].total_milliseconds
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"Must be shorter than '{CONF_TIMEOUT}'", path=[CONF_ACK_DELAY]
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
HDLC_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(HdlcReliability),
|
||||
# Retransmitting more often than the main loop runs would exhaust the retries at once
|
||||
cv.Optional(CONF_TIMEOUT, default="200ms"): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(min=cv.TimePeriod(milliseconds=50)),
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ACK_DELAY, default="0ms"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_MAX_QUEUE_LENGTH, default=4): cv.int_range(1, 7),
|
||||
cv.Optional(CONF_MAX_RETRIES, default=5): cv.int_range(1, 255),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
_ack_delay_within_timeout,
|
||||
)
|
||||
|
||||
|
||||
@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],
|
||||
)
|
||||
async def build_hdlc(config: ConfigType, lower: MockObj) -> MockObj:
|
||||
cg.add_define("USE_PACKET_INTERFACE_HDLC")
|
||||
var = cg.new_Pvariable(config[CONF_ID], lower)
|
||||
cg.add(var.set_ack_timeout(config[CONF_TIMEOUT]))
|
||||
cg.add(var.set_ack_delay(config[CONF_ACK_DELAY]))
|
||||
cg.add(var.set_max_queue_length(config[CONF_MAX_QUEUE_LENGTH]))
|
||||
cg.add(var.set_max_retries(config[CONF_MAX_RETRIES]))
|
||||
await cg.register_component(var, config)
|
||||
return var
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Encryption layer using XXTEA, with an optional rolling code."""
|
||||
|
||||
import hashlib
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
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
|
||||
|
||||
CONF_ROLLING_CODE_ENABLE = "rolling_code_enable"
|
||||
|
||||
# A captured packet lets a guessed key be checked offline, so the key text is stretched to make each guess costly.
|
||||
# Both ends derive the same key from the same text, so the salt is fixed; it keeps tables built for other uses of
|
||||
# PBKDF2 from applying here.
|
||||
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(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(XxteaEncryption),
|
||||
cv.Required(CONF_KEY): cv.All(cv.sensitive(cv.string), cv.Length(min=1)),
|
||||
cv.Optional(CONF_ROLLING_CODE_ENABLE, default=False): cv.boolean,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
def derive_key(text: str) -> list[int]:
|
||||
"""The 256-bit key for the key text, as 32-bit words in little endian order."""
|
||||
digest = hashlib.pbkdf2_hmac("sha256", text.encode(), KEY_SALT, KEY_ITERATIONS)
|
||||
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],
|
||||
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")
|
||||
var = cg.new_Pvariable(config[CONF_ID], lower, config[CONF_ROLLING_CODE_ENABLE])
|
||||
cg.add(var.set_key(derive_key(config[CONF_KEY])))
|
||||
await cg.register_component(var, config)
|
||||
return var
|
||||
@@ -0,0 +1,45 @@
|
||||
#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,7 +7,12 @@ from esphome.components.packet_transport import (
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_PACKET_INTERFACE, PacketInterface, packet_interface_ns
|
||||
from .. import (
|
||||
CONF_PACKET_INTERFACE,
|
||||
PacketInterface,
|
||||
final_validate_interface,
|
||||
packet_interface_ns,
|
||||
)
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
DEPENDENCIES = ["packet_interface"]
|
||||
@@ -22,6 +27,8 @@ 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])
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#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
|
||||
@@ -0,0 +1,87 @@
|
||||
#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
|
||||
@@ -0,0 +1,138 @@
|
||||
"""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])))
|
||||
@@ -0,0 +1,182 @@
|
||||
#include "xxtea_encryption.h"
|
||||
#ifdef USE_PACKET_INTERFACE_XXTEA
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include "esphome/components/xxtea/xxtea.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "packet_interface.xxtea");
|
||||
|
||||
static constexpr uint32_t PREF_HASH = fnv1a_hash("packet_interface.xxtea");
|
||||
// Different for each mode, so devices set up differently drop each other's packets
|
||||
static constexpr uint8_t CHECK_BYTES[3] = {0x58, 0x54, 0x45};
|
||||
static constexpr uint8_t ROLLING_CHECK_BYTES[3] = {0x58, 0x54, 0x52};
|
||||
static constexpr size_t CHECK_SIZE = 4;
|
||||
// Boot number, counter, and the echo of the receiver's boot number
|
||||
static constexpr size_t ROLLING_SIZE = 12;
|
||||
// XXTEA needs at least two words
|
||||
static constexpr size_t MIN_LENGTH = 8;
|
||||
static constexpr size_t MAX_PADDING = 4;
|
||||
|
||||
static void put_uint32(uint8_t *buf, uint32_t value) {
|
||||
buf[0] = value;
|
||||
buf[1] = value >> 8;
|
||||
buf[2] = value >> 16;
|
||||
buf[3] = value >> 24;
|
||||
}
|
||||
|
||||
static uint32_t get_uint32(const uint8_t *buf) { return encode_uint32(buf[3], buf[2], buf[1], buf[0]); }
|
||||
|
||||
static constexpr size_t trailer_size(bool rolling_code) {
|
||||
return rolling_code ? ROLLING_SIZE + CHECK_SIZE : CHECK_SIZE;
|
||||
}
|
||||
|
||||
XxteaEncryption::XxteaEncryption(PacketInterface *lower, bool rolling_code)
|
||||
// XXTEA works on whole 32-bit words
|
||||
: PacketInterface(remaining_packet_size(lower->get_max_packet_size() & ~size_t{3}, trailer_size(rolling_code))),
|
||||
lower_(lower),
|
||||
trailer_size_(trailer_size(rolling_code)),
|
||||
rolling_code_(rolling_code) {
|
||||
this->stack_on_(lower, CAPABILITY_ENCRYPTION);
|
||||
}
|
||||
|
||||
void XxteaEncryption::set_key(std::initializer_list<uint32_t> key) {
|
||||
std::copy_n(key.begin(), std::min(key.size(), this->key_.size()), this->key_.begin());
|
||||
}
|
||||
|
||||
void XxteaEncryption::setup() {
|
||||
if (this->max_packet_size_ == 0) {
|
||||
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
|
||||
return;
|
||||
}
|
||||
size_t words = this->lower_->get_max_packet_size() / 4;
|
||||
RAMAllocator<uint32_t> allocator;
|
||||
this->tx_buffer_ = allocator.allocate(words * 2);
|
||||
if (this->tx_buffer_ == nullptr) {
|
||||
this->mark_failed(LOG_STR("Not enough memory for the packet buffers"));
|
||||
return;
|
||||
}
|
||||
this->rx_buffer_ = this->tx_buffer_ + words;
|
||||
if (this->rolling_code_) {
|
||||
this->pref_ = global_preferences->make_preference<uint32_t>(fnv1a_hash_extend(PREF_HASH, this->name_), true);
|
||||
this->pref_.load(&this->boot_);
|
||||
this->next_boot_();
|
||||
}
|
||||
this->lower_->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
|
||||
}
|
||||
|
||||
void XxteaEncryption::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"XXTEA Encryption on '%s':\n"
|
||||
" Max Packet Size: %zu\n"
|
||||
" Rolling Code: %s",
|
||||
this->name_, this->max_packet_size_, YESNO(this->rolling_code_));
|
||||
if (this->rolling_code_) {
|
||||
ESP_LOGCONFIG(TAG, " Boot Number: %" PRIu32, this->boot_);
|
||||
}
|
||||
}
|
||||
|
||||
// A new boot number must be saved before it is used, or a crash could make the peer see it again
|
||||
void XxteaEncryption::next_boot_() {
|
||||
this->boot_++;
|
||||
this->counter_ = 0;
|
||||
if (!this->pref_.save(&this->boot_) || !global_preferences->sync()) {
|
||||
ESP_LOGW(TAG, "%s: Could not save the boot number, so a restart could reuse it", this->name_);
|
||||
}
|
||||
}
|
||||
|
||||
bool XxteaEncryption::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 padding = (4 - (size + this->trailer_size_) % 4) % 4;
|
||||
if (size + padding + this->trailer_size_ < MIN_LENGTH)
|
||||
padding += 4;
|
||||
size_t length = size + padding + this->trailer_size_;
|
||||
auto *buf = reinterpret_cast<uint8_t *>(this->tx_buffer_);
|
||||
data.copy_to(buf, size);
|
||||
memset(buf + size, 0, padding);
|
||||
uint8_t *check = buf + length - CHECK_SIZE;
|
||||
if (this->rolling_code_) {
|
||||
put_uint32(check - ROLLING_SIZE, this->boot_);
|
||||
put_uint32(check - ROLLING_SIZE + 4, this->counter_);
|
||||
put_uint32(check - ROLLING_SIZE + 8, this->echo_boot_);
|
||||
memcpy(check, ROLLING_CHECK_BYTES, sizeof(ROLLING_CHECK_BYTES));
|
||||
if (++this->counter_ == 0)
|
||||
this->next_boot_();
|
||||
} else {
|
||||
memcpy(check, CHECK_BYTES, sizeof(CHECK_BYTES));
|
||||
}
|
||||
check[CHECK_SIZE - 1] = padding;
|
||||
xxtea::encrypt(this->tx_buffer_, length / 4, this->key_.data());
|
||||
return this->lower_->send_to_interface(PacketBuffer(buf, length), meta_data);
|
||||
}
|
||||
|
||||
bool XxteaEncryption::accept_counter_(uint32_t boot, uint32_t counter) {
|
||||
if (this->peer_seen_ && (boot < this->peer_boot_ || (boot == this->peer_boot_ && counter <= this->peer_counter_)))
|
||||
return false;
|
||||
this->peer_seen_ = true;
|
||||
this->peer_boot_ = boot;
|
||||
this->peer_counter_ = counter;
|
||||
return true;
|
||||
}
|
||||
|
||||
void XxteaEncryption::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
size_t length = data.size();
|
||||
if (length % 4 != 0 || length < std::max(MIN_LENGTH, this->trailer_size_) ||
|
||||
length > this->lower_->get_max_packet_size()) {
|
||||
ESP_LOGV(TAG, "%s: Packet of %zu bytes dropped", this->name_, length);
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
auto *buf = reinterpret_cast<uint8_t *>(this->rx_buffer_);
|
||||
data.copy_to(buf, length);
|
||||
xxtea::decrypt(this->rx_buffer_, length / 4, this->key_.data());
|
||||
const uint8_t *check = buf + length - CHECK_SIZE;
|
||||
const uint8_t *expected = this->rolling_code_ ? ROLLING_CHECK_BYTES : CHECK_BYTES;
|
||||
size_t padding = check[CHECK_SIZE - 1];
|
||||
if (memcmp(check, expected, sizeof(CHECK_BYTES)) != 0 || padding > MAX_PADDING ||
|
||||
padding > length - this->trailer_size_) {
|
||||
ESP_LOGV(TAG, "%s: Packet failed authentication", this->name_);
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
this->note_received_(true);
|
||||
if (this->rolling_code_) {
|
||||
uint32_t boot = get_uint32(check - ROLLING_SIZE);
|
||||
uint32_t counter = get_uint32(check - ROLLING_SIZE + 4);
|
||||
uint32_t echo = get_uint32(check - ROLLING_SIZE + 8);
|
||||
// Learn the peer's boot number even from a packet that is then dropped, so both sides can catch up
|
||||
if (boot > this->echo_boot_)
|
||||
this->echo_boot_ = boot;
|
||||
// A packet recorded before this boot cannot carry this boot's number
|
||||
if (echo != this->boot_) {
|
||||
ESP_LOGV(TAG, "%s: Packet for boot %" PRIu32 " dropped", this->name_, echo);
|
||||
return;
|
||||
}
|
||||
if (!this->accept_counter_(boot, counter)) {
|
||||
// A boot number lower than one already heard means the peer lost its saved boot number, or a replay
|
||||
if (boot < this->peer_boot_ && !this->old_boot_warned_) {
|
||||
this->old_boot_warned_ = true;
|
||||
ESP_LOGW(TAG,
|
||||
"%s: Packets from an older boot %" PRIu32 " of the peer are dropped; if its flash was erased, "
|
||||
"restart this device",
|
||||
this->name_, boot);
|
||||
}
|
||||
ESP_LOGV(TAG, "%s: Old packet %" PRIu32 ":%" PRIu32 " dropped", this->name_, boot, counter);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->on_receive_from_interface_(PacketBuffer(buf, length - this->trailer_size_ - padding), meta_data);
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_PACKET_INTERFACE_XXTEA
|
||||
#include <array>
|
||||
#include <initializer_list>
|
||||
|
||||
#include "esphome/core/preferences.h"
|
||||
#include "packet_interface.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
/**
|
||||
* An encryption layer that encrypts and authenticates each packet with XXTEA and a 256-bit shared key.
|
||||
*
|
||||
* The packet is padded with zeros and followed by a trailer, then the whole is encrypted as one block. With the
|
||||
* rolling code enabled, the trailer starts with:
|
||||
* - a 32-bit boot number, saved in flash and increased at every boot;
|
||||
* - a 32-bit packet counter, starting at 0 at every boot;
|
||||
* - the highest boot number heard from the peer, echoed back to it.
|
||||
* The trailer always ends with 3 fixed check bytes and the number of padding bytes. XXTEA mixes the whole block, so
|
||||
* a packet that was changed or encrypted with another key fails the check.
|
||||
* With the rolling code, a packet is dropped as a replay unless it echoes this device's current boot number, and its
|
||||
* boot number and counter are higher than those of the last packet accepted from the peer. A boot number is never
|
||||
* reused, so the echo acts as a fresh challenge after every restart.
|
||||
*/
|
||||
class XxteaEncryption : public PacketInterface {
|
||||
public:
|
||||
XxteaEncryption(PacketInterface *lower, bool rolling_code);
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
|
||||
|
||||
void set_key(std::initializer_list<uint32_t> key);
|
||||
|
||||
protected:
|
||||
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
|
||||
bool accept_counter_(uint32_t boot, uint32_t counter);
|
||||
void next_boot_();
|
||||
|
||||
PacketInterface *lower_;
|
||||
const size_t trailer_size_;
|
||||
std::array<uint32_t, 8> key_{};
|
||||
// Separate buffers, as a layer above may send a reply while a received packet is being passed up; both are in one
|
||||
// block allocated in setup(), and tx_buffer_ stays null if that fails
|
||||
uint32_t *tx_buffer_{nullptr};
|
||||
uint32_t *rx_buffer_{nullptr};
|
||||
ESPPreferenceObject pref_;
|
||||
uint32_t boot_{0};
|
||||
uint32_t counter_{0};
|
||||
uint32_t peer_boot_{0};
|
||||
uint32_t peer_counter_{0};
|
||||
uint32_t echo_boot_{0}; // highest boot number heard from the peer
|
||||
bool peer_seen_{false};
|
||||
bool old_boot_warned_{false};
|
||||
const bool rolling_code_;
|
||||
};
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "pca6416a.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::pca6416a {
|
||||
@@ -171,7 +170,11 @@ void PCA6416AComponent::update_register_(uint8_t pin, bool pin_value, uint8_t re
|
||||
this->read_register_(reg_addr, ®_value);
|
||||
}
|
||||
|
||||
assign_bits(reg_value, 1 << bit, pin_value);
|
||||
if (pin_value) {
|
||||
reg_value |= 1 << bit;
|
||||
} else {
|
||||
reg_value &= ~(1 << bit);
|
||||
}
|
||||
|
||||
this->write_register_(reg_addr, reg_value);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "pca9505.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::pca9505 {
|
||||
@@ -75,7 +74,11 @@ bool PCA9505Component::digital_read_cache(uint8_t pin) { return this->input_[pin
|
||||
void PCA9505Component::digital_write_hw(uint8_t pin, bool value) {
|
||||
const uint8_t bank = pin / 8;
|
||||
const uint8_t mask = 1 << (pin % 8);
|
||||
assign_bits(this->output_[bank], mask, value);
|
||||
if (value) {
|
||||
this->output_[bank] |= mask;
|
||||
} else {
|
||||
this->output_[bank] &= ~mask;
|
||||
}
|
||||
this->write_bank_(OUTPUT_REG, bank, this->output_[bank]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "pca9554.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::pca9554 {
|
||||
@@ -100,7 +99,11 @@ bool PCA9554Component::digital_read_cache(uint8_t pin) {
|
||||
}
|
||||
|
||||
void PCA9554Component::digital_write_hw(uint8_t pin, bool value) {
|
||||
assign_bits(this->output_mask_, 1 << pin, value);
|
||||
if (value) {
|
||||
this->output_mask_ |= (1 << pin);
|
||||
} else {
|
||||
this->output_mask_ &= ~(1 << pin);
|
||||
}
|
||||
#ifdef USE_PCA9554_PCAL
|
||||
if (this->write_register_(OUTPUT_REG, this->output_mask_))
|
||||
this->retry_pcal_shadows_(); // an output-only hub never reads, so it retries here
|
||||
|
||||
@@ -90,7 +90,11 @@ void HOT PCD8544::draw_absolute_pixel_internal(int x, int y, Color color) {
|
||||
|
||||
uint16_t pos = x + (y / 8) * this->get_width_internal();
|
||||
uint8_t subpos = y % 8;
|
||||
assign_bits(this->buffer_[pos], 1 << subpos, color.is_on());
|
||||
if (color.is_on()) {
|
||||
this->buffer_[pos] |= (1 << subpos);
|
||||
} else {
|
||||
this->buffer_[pos] &= ~(1 << subpos);
|
||||
}
|
||||
}
|
||||
|
||||
void PCD8544::dump_config() {
|
||||
|
||||
@@ -60,7 +60,11 @@ bool PCF8574Component::digital_read_hw(uint8_t pin) {
|
||||
bool PCF8574Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); }
|
||||
|
||||
void PCF8574Component::digital_write_hw(uint8_t pin, bool value) {
|
||||
assign_bits(this->output_mask_, 1 << pin, value);
|
||||
if (value) {
|
||||
this->output_mask_ |= (1 << pin);
|
||||
} else {
|
||||
this->output_mask_ &= ~(1 << pin);
|
||||
}
|
||||
this->write_gpio_();
|
||||
}
|
||||
void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "pi4ioe5v6408.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::pi4ioe5v6408 {
|
||||
@@ -130,7 +129,11 @@ void PI4IOE5V6408Component::digital_write_hw(uint8_t pin, bool value) {
|
||||
if (this->is_failed())
|
||||
return;
|
||||
|
||||
assign_bits(this->output_mask_, 1 << pin, value);
|
||||
if (value) {
|
||||
this->output_mask_ |= (1 << pin);
|
||||
} else {
|
||||
this->output_mask_ &= ~(1 << pin);
|
||||
}
|
||||
if (!this->status_comm_result(this->write_byte(PI4IOE5V6408_REGISTER_OUT_SET, this->output_mask_),
|
||||
LOG_STR("Failed to write output register")))
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "runtime_image.h"
|
||||
#include "image_decoder.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
@@ -92,7 +91,11 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) {
|
||||
auto on = is_color_on(color);
|
||||
if (this->has_transparency() && color.w < 0x80)
|
||||
on = false;
|
||||
assign_bits(this->buffer_[pos], bitno, on);
|
||||
if (on) {
|
||||
this->buffer_[pos] |= bitno;
|
||||
} else {
|
||||
this->buffer_[pos] &= ~bitno;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case image::IMAGE_TYPE_RGB111: {
|
||||
|
||||
@@ -342,7 +342,11 @@ void HOT SSD1306::draw_absolute_pixel_internal(int x, int y, Color color) {
|
||||
|
||||
uint16_t pos = x + (y / 8) * this->get_width_internal();
|
||||
uint8_t subpos = y & 0x07;
|
||||
assign_bits(this->buffer_[pos], 1 << subpos, color.is_on());
|
||||
if (color.is_on()) {
|
||||
this->buffer_[pos] |= (1 << subpos);
|
||||
} else {
|
||||
this->buffer_[pos] &= ~(1 << subpos);
|
||||
}
|
||||
}
|
||||
void SSD1306::fill(Color color) {
|
||||
// If clipping is active, fall back to base implementation
|
||||
|
||||
@@ -53,13 +53,6 @@ MODELS = DriverChip.get_models()
|
||||
DEFAULT_MODEL = "WAVESHARE-400X300"
|
||||
|
||||
|
||||
def _model_key(model: DriverChip, key: str) -> cv.Optional | cv.Required:
|
||||
"""Default the key from the model, or require it when the model has none."""
|
||||
if model.get_default(key, None) is None:
|
||||
return cv.Required(key)
|
||||
return model.option(key)
|
||||
|
||||
|
||||
def _model_schema(model: DriverChip) -> cv.Schema:
|
||||
iseqconf = (
|
||||
cv.Required(CONF_INIT_SEQUENCE)
|
||||
@@ -79,12 +72,12 @@ def _model_schema(model: DriverChip) -> cv.Schema:
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ST7305),
|
||||
cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True),
|
||||
_model_key(model, CONF_CS_PIN): pins.gpio_output_pin_schema,
|
||||
_model_key(model, CONF_DC_PIN): pins.gpio_output_pin_schema,
|
||||
model.option(CONF_CS_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
|
||||
model.option(CONF_DC_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
|
||||
model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
|
||||
_model_key(model, CONF_WIDTH): cv.int_range(min=8, max=1024),
|
||||
_model_key(model, CONF_HEIGHT): cv.int_range(min=8, max=1024),
|
||||
_model_key(model, CONF_PORTRAIT): cv.boolean,
|
||||
cv.Optional(CONF_WIDTH): cv.int_range(min=8, max=1024),
|
||||
cv.Optional(CONF_HEIGHT): cv.int_range(min=8, max=1024),
|
||||
cv.Optional(CONF_PORTRAIT): cv.boolean,
|
||||
cv.Optional(CONF_COLUMN_START): cv.int_range(min=0, max=255),
|
||||
cv.Optional(CONF_COLUMN_END): cv.int_range(min=0, max=255),
|
||||
cv.Optional(CONF_ROW_START): cv.int_range(min=0, max=255),
|
||||
@@ -96,9 +89,23 @@ def _model_schema(model: DriverChip) -> cv.Schema:
|
||||
|
||||
|
||||
def _resolve(config: ConfigType, model: DriverChip) -> None:
|
||||
"""Derive and validate the address window, and default the init sequence."""
|
||||
width = config[CONF_WIDTH]
|
||||
height = config[CONF_HEIGHT]
|
||||
"""Fill in model defaults and validate the geometry and address window."""
|
||||
if (width := config.get(CONF_WIDTH)) is None:
|
||||
if (width := model.get_default(CONF_WIDTH, None)) is None:
|
||||
raise cv.Invalid("'width' is required for this model", path=[CONF_WIDTH])
|
||||
config[CONF_WIDTH] = width
|
||||
if (height := config.get(CONF_HEIGHT)) is None:
|
||||
if (height := model.get_default(CONF_HEIGHT, None)) is None:
|
||||
raise cv.Invalid("'height' is required for this model", path=[CONF_HEIGHT])
|
||||
config[CONF_HEIGHT] = height
|
||||
|
||||
if config.get(CONF_PORTRAIT) is None:
|
||||
if (default := model.get_default(CONF_PORTRAIT, None)) is None:
|
||||
raise cv.Invalid(
|
||||
"'portrait' is required for this model", path=[CONF_PORTRAIT]
|
||||
)
|
||||
config[CONF_PORTRAIT] = default
|
||||
|
||||
if config[CONF_PORTRAIT]:
|
||||
if width % 12 or height % 2:
|
||||
raise cv.Invalid(
|
||||
@@ -145,6 +152,10 @@ def _resolve(config: ConfigType, model: DriverChip) -> None:
|
||||
if config.get(CONF_INIT_SEQUENCE) is None:
|
||||
config[CONF_INIT_SEQUENCE] = model.initsequence
|
||||
|
||||
for key, label in ((CONF_CS_PIN, "cs_pin"), (CONF_DC_PIN, "dc_pin")):
|
||||
if key not in config:
|
||||
raise cv.Invalid(f"'{label}' is required for this model", path=[key])
|
||||
|
||||
|
||||
def model_schema(config: ConfigType) -> cv.Schema:
|
||||
"""Return the schema for the model selected in the config."""
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#include "st7305.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::st7305 {
|
||||
@@ -27,6 +27,7 @@ bool ST7305::init_buffer_() {
|
||||
this->mark_failed(LOG_STR("Buffer allocation failed"));
|
||||
return false;
|
||||
}
|
||||
memset(this->buffer_, 0x00, this->buffer_length_);
|
||||
this->clear_dirty_();
|
||||
return true;
|
||||
}
|
||||
@@ -190,7 +191,11 @@ void HOT ST7305::draw_absolute_pixel_internal(int x, int y, Color color) {
|
||||
// The init sequence enables INVON, so a set bit is white and a clear bit is black.
|
||||
uint8_t &value = this->buffer_[index];
|
||||
const uint8_t previous = value;
|
||||
assign_bits(value, mask, color.is_on());
|
||||
if (color.is_on()) {
|
||||
value |= mask;
|
||||
} else {
|
||||
value &= ~mask;
|
||||
}
|
||||
if (value == previous)
|
||||
return;
|
||||
this->extend_dirty_(x, y, x, y);
|
||||
|
||||
@@ -123,7 +123,11 @@ void HOT ST7567::draw_absolute_pixel_internal(int x, int y, Color color) {
|
||||
|
||||
uint16_t pos = x + (y / 8) * this->get_width_internal();
|
||||
uint8_t subpos = y & 0x07;
|
||||
assign_bits(this->buffer_[pos], 1 << subpos, color.is_on());
|
||||
if (color.is_on()) {
|
||||
this->buffer_[pos] |= (1 << subpos);
|
||||
} else {
|
||||
this->buffer_[pos] &= ~(1 << subpos);
|
||||
}
|
||||
}
|
||||
|
||||
void ST7567::fill(Color color) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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)
|
||||
@@ -0,0 +1,10 @@
|
||||
#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
|
||||
@@ -1,6 +1,7 @@
|
||||
#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 {
|
||||
@@ -402,6 +403,15 @@ 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
|
||||
@@ -430,10 +440,11 @@ void SX126x::run_image_cal() {
|
||||
void SX126x::set_mode_rx() {
|
||||
uint8_t buf[8];
|
||||
|
||||
// configure irq params
|
||||
// configure irq params; a valid header or sync word is flagged for is_receiving(), but not signalled on DIO1
|
||||
uint16_t irq = IRQ_RX_DONE | IRQ_RX_TX_TIMEOUT | IRQ_CRC_ERROR;
|
||||
buf[0] = (irq >> 8) & 0xFF;
|
||||
buf[1] = (irq >> 0) & 0xFF;
|
||||
uint16_t flags = irq | IRQ_HEADER_VALID | IRQ_SYNCWORD_VALID;
|
||||
buf[0] = (flags >> 8) & 0xFF;
|
||||
buf[1] = (flags >> 0) & 0xFF;
|
||||
buf[2] = (irq >> 8) & 0xFF;
|
||||
buf[3] = (irq >> 0) & 0xFF;
|
||||
buf[4] = (IRQ_RADIO_NONE >> 8) & 0xFF;
|
||||
|
||||
@@ -104,6 +104,8 @@ 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:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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)
|
||||
@@ -0,0 +1,10 @@
|
||||
#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
|
||||
@@ -354,6 +354,18 @@ 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);
|
||||
|
||||
@@ -88,6 +88,8 @@ 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:
|
||||
|
||||
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user