Compare commits

..
Author SHA1 Message Date
J. Nick Koston 6ca7614820 [core] Add bit_at, the read side of assign_bits 2026-10-22 09:31:48 -10:00
395 changed files with 3526 additions and 22196 deletions

No files matched your search

+1 -1
View File
@@ -286,7 +286,7 @@ jobs:
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Run prek
uses: j178/prek-action@af4d4fcb6922b8e6f65c0426947125fcc38794ce # v3.0.2
uses: j178/prek-action@1382ad72dea72f9d53832f2bcb65b3e87a947204 # v3.0.1
with:
prek-version: ${{ steps.prek.outputs.version }}
# This job only runs on pull requests, so nothing ever populates
+4 -5
View File
@@ -212,9 +212,10 @@ file does, and it is the authority when they disagree. The most useful starting
logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`.
A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`.
* **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `xor_bytes`, `sum16_bytes`,
`reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend<BITS>`, `bcd_to_uint8`, `assign_bits`); pick
the helper's arguments instead of writing a loop. 7-byte BCD RTC calendars use `time::decode_bcd_calendar<LAYOUT>`
and `encode_bcd_calendar<LAYOUT>` with `EPSON_BCD_CALENDAR`, `DS_BCD_CALENDAR` or `PCF_BCD_CALENDAR`.
`reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend<BITS>`, `bcd_to_uint8`, `assign_bits`,
`bit_at`); pick the helper's arguments instead of writing a loop. 7-byte BCD RTC calendars use
`time::decode_bcd_calendar<LAYOUT>` and `encode_bcd_calendar<LAYOUT>` with `EPSON_BCD_CALENDAR`,
`DS_BCD_CALENDAR` or `PCF_BCD_CALENDAR`.
* **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with
`LineReader<N>`; I2C has `update_register`, `write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id`
and `read_bytes_16_le`. `if (!this->status_comm_result(ok)) return;` sets or clears the warning.
@@ -446,8 +447,6 @@ file does, and it is the authority when they disagree. The most useful starting
switches = switch.sub_switches(config, parent=hub)
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
```
Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)`
to still drive a state when the restore mode is disabled.
- **Other codegen helpers:** `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`,
`spi.new_spi_component`, `modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`)
-15
View File
@@ -147,7 +147,6 @@ esphome/components/cst816/* @clydebarrow
esphome/components/cst9220/* @clydebarrow
esphome/components/ct_clamp/* @jesserockz
esphome/components/current_based/* @djwmarcx
esphome/components/cw2017/* @exciton
esphome/components/d01/* @ch604
esphome/components/dac7678/* @NickB1
esphome/components/daikin_arc/* @MagicBear
@@ -187,7 +186,6 @@ esphome/components/ens160_base/* @latonita @vincentscode
esphome/components/ens160_i2c/* @latonita
esphome/components/ens160_spi/* @latonita
esphome/components/ens210/* @itn3rd77
esphome/components/entity_transport/* @clydebarrow
esphome/components/epaper_spi/* @esphome/core
esphome/components/es7210/* @kahrendt
esphome/components/es7243e/* @kbx81
@@ -213,7 +211,6 @@ esphome/components/esp_ldo/* @clydebarrow
esphome/components/espectre/* @francescopace
esphome/components/esphome/ota/* @esphome/core
esphome/components/espnow/* @jesserockz
esphome/components/espnow/packet_interface/* @clydebarrow
esphome/components/espnow/packet_transport/* @EasilyBoredEngineer
esphome/components/ethernet_info/* @gtjadsonsantos
esphome/components/event/* @nohat
@@ -328,7 +325,6 @@ esphome/components/internal_temperature/* @Mat931
esphome/components/interval/* @esphome/core
esphome/components/ir_rf_base/* @bdraco @kbx81
esphome/components/ir_rf_proxy/* @kbx81
esphome/components/islandaire/* @ipopov
esphome/components/it8951/* @koosoli @limengdu @Passific
esphome/components/jsn_sr04t/* @Mafus1
esphome/components/json/* @esphome/core
@@ -360,7 +356,6 @@ esphome/components/lm75b/* @beormund
esphome/components/ln882h_ble/* @Bl00d-B0b
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
esphome/components/ln882x/* @lamauny
esphome/components/ln882x_spi_led_strip/* @sebo-b
esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core
esphome/components/logger/select/* @clydebarrow
@@ -371,7 +366,6 @@ esphome/components/ltr390/* @latonita @sjtrny
esphome/components/ltr501/* @latonita
esphome/components/ltr_als_ps/* @latonita
esphome/components/lvgl/* @clydebarrow
esphome/components/m5stack_4relay/* @KoenBreeman
esphome/components/m5stack_8angle/* @rnauber
esphome/components/mapping/* @clydebarrow
esphome/components/matrix_keypad/* @ssieb
@@ -464,8 +458,6 @@ esphome/components/openthread/* @mrene
esphome/components/opt3001/* @ccutrer
esphome/components/ota/* @esphome/core
esphome/components/output/* @esphome/core
esphome/components/packet_interface/* @clydebarrow
esphome/components/packet_interface/packet_transport/* @clydebarrow
esphome/components/packet_transport/* @clydebarrow
esphome/components/pca6416a/* @Mat931
esphome/components/pca9505/* @ajfriesen @ssieb
@@ -545,7 +537,6 @@ esphome/components/sdp3x/* @Azimath
esphome/components/seeed_mr24hpc1/* @limengdu
esphome/components/seeed_mr60bha2/* @limengdu
esphome/components/seeed_mr60fda2/* @limengdu
esphome/components/seeed_multi_channel_relay/* @KoenBreeman
esphome/components/selec_meter/* @sourabhjaiswal
esphome/components/select/* @esphome/core
esphome/components/sen0321/* @notjj
@@ -631,9 +622,7 @@ esphome/components/sun_gtil2/* @Mat931
esphome/components/switch/* @esphome/core
esphome/components/switch/binary_sensor/* @ssieb
esphome/components/sx126x/* @swoboda1337
esphome/components/sx126x/packet_interface/* @clydebarrow
esphome/components/sx127x/* @swoboda1337
esphome/components/sx127x/packet_interface/* @clydebarrow
esphome/components/sy6970/* @linkedupbits
esphome/components/syslog/* @clydebarrow
esphome/components/systa_bus/* @Mat931
@@ -645,8 +634,6 @@ esphome/components/tca8418/* @zebble
esphome/components/tca9548a/* @andreashergert1984
esphome/components/tca9555/* @mobrembski
esphome/components/tcl112/* @glmnet
esphome/components/tcp/* @clydebarrow
esphome/components/tcp/packet_interface/* @clydebarrow
esphome/components/tcp_uart/* @Bascht74
esphome/components/tee501/* @Stock-M
esphome/components/teleinfo/* @0hax @FredM67
@@ -690,12 +677,10 @@ esphome/components/tuya/water_heater/* @iago-veiga
esphome/components/uart/* @esphome/core
esphome/components/uart/button/* @ssieb
esphome/components/uart/event/* @eoasmxd
esphome/components/uart/packet_interface/* @clydebarrow
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
+4 -1
View File
@@ -8,7 +8,10 @@ ESPHOME_LOG_TAG(TAG, "copy.lock");
void CopyLock::setup() {
source_->add_on_state_callback([this](lock::LockState state) { this->publish_state(state); });
this->traits = this->source_->traits;
traits.set_assumed_state(source_->traits.get_assumed_state());
traits.set_requires_code(source_->traits.get_requires_code());
traits.set_supported_states_mask(source_->traits.get_supported_states_mask());
traits.set_supports_open(source_->traits.get_supports_open());
this->publish_state(source_->state);
}
+18 -24
View File
@@ -1,37 +1,31 @@
#pragma once
#include <cstdint>
namespace esphome::cover {
class CoverTraits {
public:
CoverTraits() = default;
bool get_is_assumed_state() const { return this->flags_.is_assumed_state; }
void set_is_assumed_state(bool is_assumed_state) { this->flags_.is_assumed_state = is_assumed_state; }
bool get_supports_position() const { return this->flags_.supports_position; }
void set_supports_position(bool supports_position) { this->flags_.supports_position = supports_position; }
bool get_supports_tilt() const { return this->flags_.supports_tilt; }
void set_supports_tilt(bool supports_tilt) { this->flags_.supports_tilt = supports_tilt; }
bool get_supports_toggle() const { return this->flags_.supports_toggle; }
void set_supports_toggle(bool supports_toggle) { this->flags_.supports_toggle = supports_toggle; }
bool get_supports_stop() const { return this->flags_.supports_stop; }
void set_supports_stop(bool supports_stop) { this->flags_.supports_stop = supports_stop; }
bool get_supports_stop_tilt() const { return this->flags_.supports_stop_tilt; }
void set_supports_stop_tilt(bool supports_stop_tilt) { this->flags_.supports_stop_tilt = supports_stop_tilt; }
bool get_is_assumed_state() const { return this->is_assumed_state_; }
void set_is_assumed_state(bool is_assumed_state) { this->is_assumed_state_ = is_assumed_state; }
bool get_supports_position() const { return this->supports_position_; }
void set_supports_position(bool supports_position) { this->supports_position_ = supports_position; }
bool get_supports_tilt() const { return this->supports_tilt_; }
void set_supports_tilt(bool supports_tilt) { this->supports_tilt_ = supports_tilt; }
bool get_supports_toggle() const { return this->supports_toggle_; }
void set_supports_toggle(bool supports_toggle) { this->supports_toggle_ = supports_toggle; }
bool get_supports_stop() const { return this->supports_stop_; }
void set_supports_stop(bool supports_stop) { this->supports_stop_ = supports_stop; }
bool get_supports_stop_tilt() const { return this->supports_stop_tilt_; }
void set_supports_stop_tilt(bool supports_stop_tilt) { this->supports_stop_tilt_ = supports_stop_tilt; }
protected:
// Bit-packed flags to save memory (1 byte instead of 6)
struct CoverTraitFlags {
uint8_t is_assumed_state : 1;
uint8_t supports_position : 1;
uint8_t supports_tilt : 1;
uint8_t supports_toggle : 1;
uint8_t supports_stop : 1;
uint8_t supports_stop_tilt : 1;
uint8_t reserved : 2;
} flags_{};
bool is_assumed_state_{false};
bool supports_position_{false};
bool supports_tilt_{false};
bool supports_toggle_{false};
bool supports_stop_{false};
bool supports_stop_tilt_{false};
};
} // namespace esphome::cover
-2
View File
@@ -1,2 +0,0 @@
CODEOWNERS = ["@exciton"]
DOMAIN = "cw2017"
-196
View File
@@ -1,196 +0,0 @@
#include "cw2017.h"
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#ifdef USE_DEEP_SLEEP
#include "esphome/components/deep_sleep/deep_sleep_component.h"
#endif
namespace esphome::cw2017 {
ESPHOME_LOG_TAG(TAG, "cw2017");
static constexpr uint8_t REG_VERSION = 0x00;
static constexpr uint8_t REG_VCELL = 0x02;
static constexpr uint8_t REG_SOC = 0x04;
static constexpr uint8_t REG_MODE = 0x08;
static constexpr uint8_t REG_SOC_ALERT = 0x0B;
static constexpr uint8_t REG_BATINFO = 0x10;
static constexpr uint8_t MODE_NORMAL = 0x00;
static constexpr uint8_t MODE_SLEEP = 0x30;
static constexpr uint8_t UPDATE_FLAG = 0x80;
static constexpr uint8_t RESTART_MODES[] PROGMEM = {0xF0, MODE_SLEEP, MODE_NORMAL};
static constexpr uint8_t RESTART_STEPS = sizeof(RESTART_MODES);
static constexpr uint16_t VCELL_MASK = 0x3FFF;
static constexpr float VCELL_VOLTS_PER_LSB = 312.5e-6f;
static constexpr uint32_t STEP_DELAY_MS = 20;
static constexpr uint32_t READY_POLL_MS = 100;
static constexpr uint8_t READY_ATTEMPTS = 40;
static constexpr uint32_t INIT_RETRY_MS = 1000;
static constexpr uint8_t INIT_ATTEMPTS = 3;
static bool version_is_running(uint8_t version) { return (version & 0xFD) == 0x0D; }
// The configured profile only changes with new firmware, and flashing never ends in a deep-sleep wake.
static bool woke_from_deep_sleep() {
#ifdef USE_DEEP_SLEEP
return deep_sleep::woke_from_deep_sleep();
#else
return false;
#endif
}
bool CW2017::read_profile_matches_(bool &matches) {
uint8_t stored[PROFILE_SIZE];
uint8_t expected[PROFILE_SIZE];
if (!this->read_bytes(REG_BATINFO, stored, PROFILE_SIZE))
return false;
progmem_memcpy(expected, this->profile_, PROFILE_SIZE);
matches = std::memcmp(stored, expected, PROFILE_SIZE) == 0;
return true;
}
bool CW2017::upload_profile_() {
// Built here with the register first, so the bus layer does not heap-allocate a buffer this size.
uint8_t buffer[1 + PROFILE_SIZE] = {REG_BATINFO};
progmem_memcpy(buffer + 1, this->profile_, PROFILE_SIZE);
return this->write(buffer, sizeof(buffer)) == i2c::ERROR_OK && this->write_byte(REG_SOC_ALERT, UPDATE_FLAG);
}
void CW2017::setup() { this->init_(); }
// Retried because another device resetting on the same bus can disturb an early transfer.
void CW2017::init_failed_(const LogString *reason) {
if (++this->init_attempts_ >= INIT_ATTEMPTS) {
this->mark_failed(reason);
return;
}
this->set_timeout(INIT_RETRY_MS, [this]() { this->init_(); });
}
void CW2017::init_() {
// A profile upload only takes effect after a restart, so a failed restart is retried, not skipped.
if (this->restart_pending_) {
this->restart_();
return;
}
uint8_t mode = 0;
uint8_t version = 0;
uint8_t config = 0;
if (!this->read_byte(REG_MODE, &mode) || !this->read_byte(REG_VERSION, &version) ||
!this->read_byte(REG_SOC_ALERT, &config)) {
this->init_failed_(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
const bool has_profile = (config & UPDATE_FLAG) != 0;
bool matches = has_profile && woke_from_deep_sleep();
if (has_profile && !matches && !this->read_profile_matches_(matches)) {
this->init_failed_(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (!matches) {
ESP_LOGI(TAG, "Uploading battery profile");
if (!this->upload_profile_()) {
this->init_failed_(LOG_STR("Battery profile upload failed"));
return;
}
this->restart_();
return;
}
if (mode == MODE_SLEEP) {
// The gauge keeps its profile and state through its own sleep, so a wake is a mode write, not a restart.
if (!this->write_byte(REG_MODE, MODE_NORMAL)) {
this->init_failed_(LOG_STR("Gauge wake failed"));
return;
}
this->set_timeout(STEP_DELAY_MS, [this]() { this->wait_ready_(0); });
return;
}
if (mode != MODE_NORMAL || !version_is_running(version)) {
this->restart_();
return;
}
this->wait_ready_(0);
}
void CW2017::restart_() {
this->restart_pending_ = true;
this->set_timeout(STEP_DELAY_MS, [this]() { this->restart_step_(0); });
}
void CW2017::restart_step_(uint8_t step) {
if (!this->write_byte(REG_MODE, progmem_read_byte(&RESTART_MODES[step]))) {
this->init_failed_(LOG_STR("Restart failed"));
return;
}
if (step + 1 < RESTART_STEPS) {
this->set_timeout(STEP_DELAY_MS, [this, step]() { this->restart_step_(step + 1); });
} else {
this->restart_pending_ = false;
this->set_timeout(STEP_DELAY_MS, [this]() { this->wait_ready_(0); });
}
}
void CW2017::wait_ready_(uint8_t attempt) {
uint8_t version = 0;
uint8_t soc = 0;
if (this->read_byte(REG_VERSION, &version) && version_is_running(version) && this->read_byte(REG_SOC, &soc) &&
soc <= 100) {
ESP_LOGD(TAG, "Gauge ready %u ms after boot", (unsigned) millis());
this->ready_ = true;
this->update();
return;
}
if (attempt + 1 >= READY_ATTEMPTS) {
this->init_failed_(LOG_STR("Gauge did not become ready"));
return;
}
this->set_timeout(READY_POLL_MS, [this, attempt]() { this->wait_ready_(attempt + 1); });
}
void CW2017::update() {
if (!this->ready_)
return;
if (this->battery_level_sensor_ != nullptr) {
uint8_t soc = 0;
if (this->read_byte(REG_SOC, &soc) && soc <= 100) {
this->battery_level_sensor_->publish_state(soc);
} else {
this->status_set_warning();
return;
}
}
if (this->battery_voltage_sensor_ != nullptr) {
uint16_t raw = 0;
if (this->read_byte_16(REG_VCELL, &raw)) {
this->battery_voltage_sensor_->publish_state((raw & VCELL_MASK) * VCELL_VOLTS_PER_LSB);
} else {
this->status_set_warning();
return;
}
}
this->status_clear_warning();
}
void CW2017::dump_config() {
ESP_LOGCONFIG(TAG, "CW2017:");
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_);
LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_sensor_);
}
} // namespace esphome::cw2017
-39
View File
@@ -1,39 +0,0 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::cw2017 {
static constexpr uint8_t PROFILE_SIZE = 80;
class CW2017 : public PollingComponent, public i2c::I2CDevice {
public:
explicit CW2017(const uint8_t *profile) : profile_(profile) {}
void set_battery_level_sensor(sensor::Sensor *sensor) { this->battery_level_sensor_ = sensor; }
void set_battery_voltage_sensor(sensor::Sensor *sensor) { this->battery_voltage_sensor_ = sensor; }
void setup() override;
void update() override;
void dump_config() override;
protected:
void init_();
void init_failed_(const LogString *reason);
bool read_profile_matches_(bool &matches);
bool upload_profile_();
void restart_();
void restart_step_(uint8_t step);
void wait_ready_(uint8_t attempt);
const uint8_t *profile_;
sensor::Sensor *battery_level_sensor_{nullptr};
sensor::Sensor *battery_voltage_sensor_{nullptr};
bool ready_{false};
bool restart_pending_{false};
uint8_t init_attempts_{0};
};
} // namespace esphome::cw2017
-61
View File
@@ -1,61 +0,0 @@
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_RAW_DATA_ID,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_VOLTAGE,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_MEASUREMENT,
UNIT_PERCENT,
UNIT_VOLT,
)
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
CONF_BATTERY_PROFILE = "battery_profile"
PROFILE_SIZE = 80
cw2017_ns = cg.esphome_ns.namespace("cw2017")
CW2017 = cw2017_ns.class_("CW2017", cg.PollingComponent, i2c.I2CDevice)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(CW2017),
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
cv.Required(CONF_BATTERY_PROFILE): cv.All(
cv.ensure_list(cv.hex_uint8_t),
cv.Length(min=PROFILE_SIZE, max=PROFILE_SIZE),
),
cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_BATTERY,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_BATTERY_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x63))
)
async def to_code(config: ConfigType) -> None:
profile = cg.progmem_array(config[CONF_RAW_DATA_ID], config[CONF_BATTERY_PROFILE])
var = await i2c.new_i2c_component(config, profile)
sensors = sensor.sub_sensors(config)
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor)
await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage_sensor)
@@ -7,6 +7,7 @@ namespace esphome::deep_sleep {
ESPHOME_LOG_TAG(TAG, "deep_sleep");
#ifdef USE_DEEP_SLEEP_ON_WAKE
WakeupCause get_wakeup_cause() {
switch (lt_get_reboot_reason()) {
case REBOOT_REASON_SLEEP_GPIO:
@@ -19,6 +20,7 @@ WakeupCause get_wakeup_cause() {
return WAKEUP_CAUSE_NONE;
}
}
#endif // USE_DEEP_SLEEP_ON_WAKE
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
@@ -60,6 +60,8 @@ struct WakeupCauseToRunDuration {
#endif // USE_ESP32
#ifdef USE_DEEP_SLEEP_ON_WAKE
/// Why the device woke from deep sleep. Passed to on_wake automations.
enum WakeupCause : uint8_t {
/// The device did not wake from deep sleep (for example a cold boot, reset or OTA restart).
@@ -77,11 +79,6 @@ enum WakeupCause : uint8_t {
/// Return why the device woke from deep sleep. Implemented per platform.
WakeupCause get_wakeup_cause();
/// Return true when this boot is a wake from deep sleep.
inline bool woke_from_deep_sleep() { return get_wakeup_cause() != WAKEUP_CAUSE_NONE; }
#ifdef USE_DEEP_SLEEP_ON_WAKE
/** Setup priority of on_wake triggers.
*
* Between restoring global variables (setup_priority::HARDWARE, 800) and on_boot automations at
@@ -30,6 +30,7 @@ namespace esphome::deep_sleep {
ESPHOME_LOG_TAG(TAG, "deep_sleep");
#ifdef USE_DEEP_SLEEP_ON_WAKE
WakeupCause get_wakeup_cause() {
switch (esp_sleep_get_wakeup_cause()) {
case ESP_SLEEP_WAKEUP_EXT0:
@@ -46,6 +47,7 @@ WakeupCause get_wakeup_cause() {
return WAKEUP_CAUSE_UNKNOWN;
}
}
#endif // USE_DEEP_SLEEP_ON_WAKE
optional<uint32_t> DeepSleepComponent::get_run_duration_() const {
if (this->wakeup_cause_to_run_duration_.has_value()) {
@@ -3,14 +3,17 @@
#include <Esp.h>
#ifdef USE_DEEP_SLEEP_ON_WAKE
extern "C" {
#include <user_interface.h>
}
#endif
namespace esphome::deep_sleep {
ESPHOME_LOG_TAG(TAG, "deep_sleep");
#ifdef USE_DEEP_SLEEP_ON_WAKE
WakeupCause get_wakeup_cause() {
// The ESP8266 can only wake from deep sleep through the RTC timer (via GPIO16 -> RST).
// NOLINTNEXTLINE(readability-static-accessed-through-instance)
@@ -19,6 +22,7 @@ WakeupCause get_wakeup_cause() {
}
return WAKEUP_CAUSE_NONE;
}
#endif // USE_DEEP_SLEEP_ON_WAKE
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
@@ -31,9 +31,6 @@ static bool wakeable_delay_feed_wdt(uint32_t ms) {
return false;
}
// A wake from System OFF is not told apart from a reset yet.
WakeupCause get_wakeup_cause() { return WAKEUP_CAUSE_NONE; }
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
void DeepSleepComponent::dump_config_platform_() {}
+6 -239
View File
@@ -1,18 +1,7 @@
from esphome import automation, pins
from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components.const import CONF_ENABLED
import esphome.config_validation as cv
from esphome.const import (
CONF_FREQUENCY,
CONF_HOUR,
CONF_INTERRUPT_PIN,
CONF_MINUTE,
CONF_MODE,
CONF_OFFSET,
CONF_SECOND,
CONF_STATE,
)
from esphome.types import ConfigType
CODEOWNERS = ["@linkedupbits"]
@@ -21,245 +10,23 @@ MULTI_CONF = True
DOMAIN = "ds3231"
CONF_DS3231_ID = "ds3231_id"
CONF_ALARM = "alarm"
CONF_ALARM_CHECK_INTERVAL = "alarm_check_interval"
CONF_AGING_OFFSET = "aging_offset"
CONF_AUTO_CLEAR_ALARMS = "auto_clear_alarms"
CONF_BATTERY_BACKED_SQUARE_WAVE = "battery_backed_square_wave"
CONF_DAY_OF_MONTH = "day_of_month"
CONF_DAY_OF_WEEK = "day_of_week"
CONF_ON_ALARM_1 = "on_alarm_1"
CONF_ON_ALARM_2 = "on_alarm_2"
CONF_OUTPUT_32KHZ = "output_32khz"
CONF_SQUARE_WAVE_FREQUENCY = "square_wave_frequency"
ds3231_ns = cg.esphome_ns.namespace("ds3231")
DS3231Component = ds3231_ns.class_("DS3231Component", cg.Component, i2c.I2CDevice)
AlarmMode = ds3231_ns.enum("AlarmMode")
ALARM_1_MODES = AlarmMode.options(
"every_second",
"every_minute",
"hourly",
"daily",
"monthly",
"weekly",
prefix="ALARM_MODE_",
)
# Alarm 2 has no seconds register.
ALARM_2_MODES = {k: v for k, v in ALARM_1_MODES.items() if k != "every_second"}
SquareWaveFrequency = ds3231_ns.enum("SquareWaveFrequency")
# Keyed by the frequency in Hz; cv.frequency turns "1024Hz", "1.024kHz" or 1024 into it.
SQUARE_WAVE_FREQUENCIES = {
1: SquareWaveFrequency.SQUARE_WAVE_1HZ,
1024: SquareWaveFrequency.SQUARE_WAVE_1024HZ,
4096: SquareWaveFrequency.SQUARE_WAVE_4096HZ,
8192: SquareWaveFrequency.SQUARE_WAVE_8192HZ,
}
validate_square_wave_frequency = cv.All(
cv.frequency, cv.enum(SQUARE_WAVE_FREQUENCIES, int=True)
)
validate_aging_offset = cv.int_range(min=-128, max=127)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(DS3231Component),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_SQUARE_WAVE_FREQUENCY): validate_square_wave_frequency,
cv.Optional(CONF_BATTERY_BACKED_SQUARE_WAVE): cv.boolean,
cv.Optional(CONF_OUTPUT_32KHZ): cv.boolean,
cv.Optional(CONF_AGING_OFFSET): validate_aging_offset,
cv.Optional(CONF_AUTO_CLEAR_ALARMS, default=True): cv.boolean,
cv.Optional(
CONF_ALARM_CHECK_INTERVAL, default="1s"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_ON_ALARM_1): automation.validate_automation(single=True),
cv.Optional(CONF_ON_ALARM_2): automation.validate_automation(single=True),
}
)
CONFIG_SCHEMA = (
cv.Schema({cv.GenerateID(): cv.declare_id(DS3231Component)})
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x68)),
cv.has_at_most_one_key(CONF_INTERRUPT_PIN, CONF_SQUARE_WAVE_FREQUENCY),
.extend(i2c.i2c_device_schema(0x68))
)
async def to_code(config: ConfigType) -> None:
var = await i2c.new_i2c_component(config)
await i2c.new_i2c_component(config)
await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN))
cg.add_optional(
var.set_square_wave_frequency, config.get(CONF_SQUARE_WAVE_FREQUENCY)
)
cg.add_optional(
var.set_battery_backed_square_wave, config.get(CONF_BATTERY_BACKED_SQUARE_WAVE)
)
cg.add_optional(var.set_output_32khz_config, config.get(CONF_OUTPUT_32KHZ))
cg.add_optional(var.set_aging_offset_config, config.get(CONF_AGING_OFFSET))
cg.add(var.set_auto_clear_alarms(config[CONF_AUTO_CLEAR_ALARMS]))
cg.add(var.set_alarm_check_interval(config[CONF_ALARM_CHECK_INTERVAL]))
# The alarm flags are only read when something uses them; the binary sensor platform
# turns this on as well. With an interrupt pin the check also releases INT/SQW.
if any(
key in config for key in (CONF_INTERRUPT_PIN, CONF_ON_ALARM_1, CONF_ON_ALARM_2)
):
cg.add(var.set_monitor_alarms(True))
# One callback for both alarms; each automation runs only for its own alarm number.
for conf_key, alarm in ((CONF_ON_ALARM_1, 1), (CONF_ON_ALARM_2, 2)):
if (conf := config.get(conf_key)) is not None:
await automation.build_callback_automation(
var,
"add_on_alarm_callback",
[],
conf,
params=[(cg.uint8, "alarm")],
forward=[],
when=f"alarm == {alarm}",
)
# ---------------------------------------------------------------------------------------------
# Actions
# ---------------------------------------------------------------------------------------------
DS3231_ACTION_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(DS3231Component)}
)
automation.register_apply_action(
"ds3231.force_temperature_conversion",
DS3231_ACTION_SCHEMA,
automation.maybe_simple_id({cv.GenerateID(): cv.use_id(DS3231Component)}),
automation.ApplyCall("force_temperature_conversion()"),
)
def _validate_alarm_day(config: ConfigType) -> ConfigType:
mode = config[CONF_MODE]
if mode == "monthly" and CONF_DAY_OF_MONTH not in config:
raise cv.Invalid(f"'{CONF_DAY_OF_MONTH}' is required in monthly mode")
if mode == "weekly" and CONF_DAY_OF_WEEK not in config:
raise cv.Invalid(f"'{CONF_DAY_OF_WEEK}' is required in weekly mode")
# The unused day still has to be a valid register value.
config.setdefault(CONF_DAY_OF_MONTH, 1)
config.setdefault(CONF_DAY_OF_WEEK, 1)
return config
def _alarm_schema(modes: dict, with_second: bool) -> cv.Schema:
schema = {
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_MODE): cv.enum(modes, lower=True),
cv.Optional(CONF_MINUTE, default=0): cv.templatable(
cv.int_range(min=0, max=59)
),
cv.Optional(CONF_HOUR, default=0): cv.templatable(cv.int_range(min=0, max=23)),
cv.Optional(CONF_DAY_OF_MONTH): cv.templatable(cv.int_range(min=1, max=31)),
# 1 = Sunday ... 7 = Saturday, as in ESPHome's time component.
cv.Optional(CONF_DAY_OF_WEEK): cv.templatable(cv.int_range(min=1, max=7)),
cv.Optional(CONF_ENABLED, default=True): cv.templatable(cv.boolean),
}
if with_second:
schema[cv.Optional(CONF_SECOND, default=0)] = cv.templatable(
cv.int_range(min=0, max=59)
)
return cv.All(cv.Schema(schema), _validate_alarm_day)
automation.register_apply_action(
"ds3231.set_alarm_1",
_alarm_schema(ALARM_1_MODES, with_second=True),
automation.ApplyCall(
"set_alarm_1({}, {}, {}, {}, {}, {}, {})",
(
(CONF_MODE, AlarmMode),
(CONF_SECOND, cg.uint8),
(CONF_MINUTE, cg.uint8),
(CONF_HOUR, cg.uint8),
(CONF_DAY_OF_MONTH, cg.uint8),
(CONF_DAY_OF_WEEK, cg.uint8),
(CONF_ENABLED, cg.bool_),
),
),
)
automation.register_apply_action(
"ds3231.set_alarm_2",
_alarm_schema(ALARM_2_MODES, with_second=False),
automation.ApplyCall(
"set_alarm_2({}, {}, {}, {}, {}, {})",
(
(CONF_MODE, AlarmMode),
(CONF_MINUTE, cg.uint8),
(CONF_HOUR, cg.uint8),
(CONF_DAY_OF_MONTH, cg.uint8),
(CONF_DAY_OF_WEEK, cg.uint8),
(CONF_ENABLED, cg.bool_),
),
),
)
DS3231_ALARM_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_ALARM): cv.templatable(cv.int_range(min=1, max=2)),
}
)
for _name, _method in (
("ds3231.enable_alarm", "enable_alarm"),
("ds3231.disable_alarm", "disable_alarm"),
("ds3231.clear_alarm", "clear_alarm"),
):
automation.register_apply_action(
_name,
DS3231_ALARM_ACTION_SCHEMA,
automation.ApplyField(CONF_ALARM, _method, cg.uint8),
)
automation.register_apply_condition(
"ds3231.is_alarm_triggered",
DS3231_ALARM_ACTION_SCHEMA,
automation.ApplyCall("is_alarm_triggered({})", ((CONF_ALARM, cg.uint8),)),
)
automation.register_apply_action(
"ds3231.enable_square_wave",
cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_FREQUENCY): validate_square_wave_frequency,
}
),
automation.ApplyField(CONF_FREQUENCY, "enable_square_wave", SquareWaveFrequency),
)
automation.register_apply_action(
"ds3231.disable_square_wave",
DS3231_ACTION_SCHEMA,
automation.ApplyCall("disable_square_wave()"),
)
automation.register_apply_action(
"ds3231.set_32khz_output",
cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_STATE): cv.templatable(cv.boolean),
}
),
automation.ApplyField(CONF_STATE, "set_output_32khz", cg.bool_),
)
automation.register_apply_action(
"ds3231.set_aging_offset",
cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_OFFSET): cv.templatable(validate_aging_offset),
}
),
automation.ApplyField(CONF_OFFSET, "set_aging_offset", cg.int8),
)
@@ -1,47 +0,0 @@
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_ID, DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC
from esphome.types import ConfigType
from .. import CONF_DS3231_ID, DS3231Component
DEPENDENCIES = ["ds3231"]
CONF_ALARM_1 = "alarm_1"
CONF_ALARM_2 = "alarm_2"
CONF_OSCILLATOR_STOPPED = "oscillator_stopped"
ICON_ALARM = "mdi:alarm"
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component),
cv.Optional(CONF_ALARM_1): binary_sensor.binary_sensor_schema(
icon=ICON_ALARM
),
cv.Optional(CONF_ALARM_2): binary_sensor.binary_sensor_schema(
icon=ICON_ALARM
),
cv.Optional(CONF_OSCILLATOR_STOPPED): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_PROBLEM,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
),
cv.has_at_least_one_key(CONF_ALARM_1, CONF_ALARM_2, CONF_OSCILLATOR_STOPPED),
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_DS3231_ID])
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_ALARM_1, hub.set_alarm_1_binary_sensor)
await binary_sensors(CONF_ALARM_2, hub.set_alarm_2_binary_sensor)
await binary_sensors(
CONF_OSCILLATOR_STOPPED, hub.set_oscillator_stopped_binary_sensor
)
if CONF_ALARM_1 in config or CONF_ALARM_2 in config:
cg.add(hub.set_monitor_alarms(True))
+14 -423
View File
@@ -9,184 +9,29 @@ namespace esphome::ds3231 {
ESPHOME_LOG_TAG(TAG, "ds3231");
static constexpr uint8_t DS3231_REG_SECONDS = 0x00;
static constexpr uint8_t DS3231_REG_ALARM_1 = 0x07;
static constexpr uint8_t DS3231_REG_ALARM_2 = 0x0B;
static constexpr uint8_t DS3231_REG_CONTROL = 0x0E;
static constexpr uint8_t DS3231_REG_STATUS = 0x0F;
static constexpr uint8_t DS3231_REG_AGING_OFFSET = 0x10;
static constexpr uint8_t DS3231_REG_TEMPERATURE = 0x11;
static constexpr uint8_t ALARM_MASK_BIT = 0x80; // AxMy: this register is ignored when matching
static constexpr uint8_t ALARM_DAY_OF_WEEK = 0x40; // DY/DT: match day of week instead of date
/// How long an alarm binary sensor stays on when the flag is cleared automatically.
static constexpr uint32_t ALARM_PULSE_MS = 1000;
static const LogString *alarm_state_to_string(uint8_t control, uint8_t alarm) {
return (control & (alarm == 1 ? 0x01 : 0x02)) != 0 ? LOG_STR("enabled") : LOG_STR("disabled");
}
static const LogString *square_wave_to_string(SquareWaveFrequency frequency) {
switch (frequency) {
case SQUARE_WAVE_1HZ:
return LOG_STR("1 Hz");
case SQUARE_WAVE_1024HZ:
return LOG_STR("1.024 kHz");
case SQUARE_WAVE_4096HZ:
return LOG_STR("4.096 kHz");
case SQUARE_WAVE_8192HZ:
return LOG_STR("8.192 kHz");
default:
return LOG_STR("unknown");
}
}
void IRAM_ATTR DS3231Component::interrupt_handler(DS3231Component *arg) { arg->enable_loop_soon_any_context(); }
void DS3231Component::setup() {
if (!this->read_control_status_()) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
// Keep the oscillator running on battery. INT/SQW is the alarm interrupt line unless a square
// wave is configured. The alarm interrupt enables (A1IE/A2IE) are kept as they are: the alarms
// live on the chip and survive a reboot of the ESP on battery.
uint8_t mask = CONTROL_EOSC | CONTROL_INTCN;
uint8_t value = CONTROL_INTCN;
if (this->square_wave_configured_) {
mask |= CONTROL_RS_MASK;
value = this->square_wave_frequency_ << CONTROL_RS_SHIFT;
}
if (this->battery_backed_square_wave_ >= 0) {
mask |= CONTROL_BBSQW;
assign_bits(value, CONTROL_BBSQW, this->battery_backed_square_wave_ != 0);
}
if (!this->update_control_(mask, value)) {
this->control_reg_ &= ~CONTROL_EOSC; // keep the oscillator running on battery
// INT/SQW is the (unused) interrupt line rather than a square-wave output.
this->control_reg_ |= CONTROL_INTCN;
if (!this->write_control_()) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (this->output_32khz_ >= 0) {
assign_bits(this->status_reg_, STATUS_EN32KHZ, this->output_32khz_ != 0);
if (!this->write_status_(0)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
}
if (this->aging_offset_configured_ && !this->write_aging_offset_(this->aging_offset_)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if ((this->status_reg_ & STATUS_OSF) != 0) {
ESP_LOGW(TAG, "Oscillator was stopped (battery empty or missing?), the stored time is invalid.");
}
this->publish_oscillator_stopped_();
if (!this->monitor_alarms_) {
this->disable_loop();
return;
}
// Start from a known state; an alarm that fired while the ESP was off is picked up by the first
// check, which runs from the loop right after setup.
this->publish_alarm_state_(1, false);
this->publish_alarm_state_(2, false);
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
// INT/SQW is open drain and active low: a falling edge means an enabled alarm matched.
// The handler only wakes the loop; the flags are read from there.
this->interrupt_pin_->attach_interrupt(&DS3231Component::interrupt_handler, this, gpio::INTERRUPT_FALLING_EDGE);
}
// Without an interrupt pin this is how alarms are seen. With one it is a fallback: it picks up an
// edge that was missed, and latched flags (auto_clear_alarms: false) that were cleared elsewhere.
this->set_interval(this->alarm_check_interval_, [this]() { this->check_alarms_(); });
}
void DS3231Component::loop() {
// Only runs once after setup and when woken by the interrupt pin.
this->disable_loop();
this->check_alarms_();
}
void DS3231Component::dump_config() {
ESP_LOGCONFIG(TAG, "DS3231:");
LOG_I2C_DEVICE(this);
if (this->is_failed())
return;
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
if ((this->control_reg_ & CONTROL_INTCN) == 0) {
ESP_LOGCONFIG(TAG, " INT/SQW: square wave %s",
LOG_STR_ARG(square_wave_to_string(
static_cast<SquareWaveFrequency>((this->control_reg_ & CONTROL_RS_MASK) >> CONTROL_RS_SHIFT))));
} else {
ESP_LOGCONFIG(TAG, " INT/SQW: alarm interrupt");
}
ESP_LOGCONFIG(TAG,
" Battery-backed square wave: %s\n"
" 32 kHz output: %s\n"
" Alarm 1: %s\n"
" Alarm 2: %s\n"
" Auto clear alarms: %s",
YESNO(this->control_reg_ & CONTROL_BBSQW), ONOFF(this->status_reg_ & STATUS_EN32KHZ),
LOG_STR_ARG(alarm_state_to_string(this->control_reg_, 1)),
LOG_STR_ARG(alarm_state_to_string(this->control_reg_, 2)), YESNO(this->auto_clear_alarms_));
if (this->aging_offset_configured_) {
ESP_LOGCONFIG(TAG, " Aging offset: %d", this->aging_offset_);
}
if (this->monitor_alarms_) {
ESP_LOGCONFIG(TAG, " Alarm check interval: %" PRIu32 " ms", this->alarm_check_interval_);
}
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Alarm 1", this->alarm_1_binary_sensor_);
LOG_BINARY_SENSOR(" ", "Alarm 2", this->alarm_2_binary_sensor_);
LOG_BINARY_SENSOR(" ", "Oscillator Stopped", this->oscillator_stopped_binary_sensor_);
#endif
}
// ---------------------------------------------------------------------------------------------
// Clock
// ---------------------------------------------------------------------------------------------
bool DS3231Component::read_calendar(uint8_t *raw) {
if (this->is_failed() || !this->status_comm_result(this->read_bytes(DS3231_REG_SECONDS, raw, CALENDAR_LEN)))
return false;
// Without a valid status the oscillator-stop flag is unknown, so the time can't be trusted.
if (!this->read_control_status_())
return false;
this->publish_oscillator_stopped_();
if ((this->status_reg_ & STATUS_OSF) != 0) {
ESP_LOGW(TAG, "Oscillator stop flag set, the RTC time is invalid. Write a valid time first.");
return false;
}
return true;
}
bool DS3231Component::write_calendar(const uint8_t *raw) {
if (this->is_failed() || !this->status_comm_result(this->write_bytes(DS3231_REG_SECONDS, raw, CALENDAR_LEN)))
return false;
// The time is valid again: clear the oscillator stop flag. If that fails, the RTC would keep
// reporting its time as invalid, so the write is not reported as successful.
if (!this->read_control_status_())
return false;
if ((this->status_reg_ & STATUS_OSF) != 0) {
if (!this->write_status_(STATUS_OSF))
return false;
this->status_reg_ &= ~STATUS_OSF;
}
this->publish_oscillator_stopped_();
return true;
}
// ---------------------------------------------------------------------------------------------
// Temperature
// ---------------------------------------------------------------------------------------------
bool DS3231Component::read_temperature(float &out) {
uint8_t raw[2];
if (this->is_failed() || !this->read_bytes(DS3231_REG_TEMPERATURE, raw, sizeof(raw))) {
@@ -208,280 +53,26 @@ void DS3231Component::force_temperature_conversion() {
ESP_LOGW(TAG, "A temperature conversion is already in progress.");
return;
}
// CONV is a one-shot command bit, so it is not kept in the cached value.
this->status_comm_result(this->write_byte(DS3231_REG_CONTROL, this->control_reg_ | CONTROL_CONV));
this->control_reg_ |= CONTROL_CONV;
this->write_control_();
}
// ---------------------------------------------------------------------------------------------
// Alarms
// ---------------------------------------------------------------------------------------------
void DS3231Component::set_alarm_1(AlarmMode mode, uint8_t second, uint8_t minute, uint8_t hour, uint8_t day_of_month,
uint8_t day_of_week, bool enabled) {
if (second > 59 || minute > 59 || hour > 23 || day_of_month < 1 || day_of_month > 31 || day_of_week < 1 ||
day_of_week > 7 || mode > ALARM_MODE_WEEKLY) {
ESP_LOGE(TAG, "Invalid alarm 1 setting.");
return;
}
// Mask bits A1M1..A1M4: a set bit means the register is ignored when matching.
uint8_t regs[4] = {
uint8_to_bcd(second),
uint8_to_bcd(minute),
uint8_to_bcd(hour),
mode == ALARM_MODE_WEEKLY ? static_cast<uint8_t>(ALARM_DAY_OF_WEEK | day_of_week) : uint8_to_bcd(day_of_month),
};
switch (mode) {
case ALARM_MODE_EVERY_SECOND:
regs[0] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_EVERY_MINUTE:
regs[1] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_HOURLY:
regs[2] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_DAILY:
regs[3] |= ALARM_MASK_BIT;
break;
default: // MONTHLY / WEEKLY: everything matches
break;
}
ESP_LOGD(TAG, "Setting alarm 1: mode %u, %02u:%02u:%02u, day of month %u, day of week %u, %s", mode, hour, minute,
second, day_of_month, day_of_week, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
this->write_alarm_(1, regs, sizeof(regs), enabled);
}
void DS3231Component::set_alarm_2(AlarmMode mode, uint8_t minute, uint8_t hour, uint8_t day_of_month,
uint8_t day_of_week, bool enabled) {
if (minute > 59 || hour > 23 || day_of_month < 1 || day_of_month > 31 || day_of_week < 1 || day_of_week > 7 ||
mode == ALARM_MODE_EVERY_SECOND || mode > ALARM_MODE_WEEKLY) {
ESP_LOGE(TAG, "Invalid alarm 2 setting.");
return;
}
// Mask bits A2M2..A2M4.
uint8_t regs[3] = {
uint8_to_bcd(minute),
uint8_to_bcd(hour),
mode == ALARM_MODE_WEEKLY ? static_cast<uint8_t>(ALARM_DAY_OF_WEEK | day_of_week) : uint8_to_bcd(day_of_month),
};
switch (mode) {
case ALARM_MODE_EVERY_MINUTE:
regs[0] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_HOURLY:
regs[1] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_DAILY:
regs[2] |= ALARM_MASK_BIT;
break;
default: // MONTHLY / WEEKLY: everything matches
break;
}
ESP_LOGD(TAG, "Setting alarm 2: mode %u, %02u:%02u, day of month %u, day of week %u, %s", mode, hour, minute,
day_of_month, day_of_week, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
this->write_alarm_(2, regs, sizeof(regs), enabled);
}
void DS3231Component::write_alarm_(uint8_t alarm, const uint8_t *regs, size_t len, bool enabled) {
if (this->is_failed())
return;
if (!this->status_comm_result(this->write_bytes(alarm == 1 ? DS3231_REG_ALARM_1 : DS3231_REG_ALARM_2, regs, len)))
return;
// Changing the registers may have matched on the way: start from a clean flag. Enabling the
// alarm with a stale flag would assert INT/SQW right away.
if (!this->clear_alarm_flag_(alarm)) {
ESP_LOGW(TAG, "Alarm %u written, but its flag could not be cleared; not changing its enable.", alarm);
return;
}
this->set_alarm_enabled_(alarm, enabled);
}
void DS3231Component::set_alarm_enabled_(uint8_t alarm, bool enabled) {
if (alarm != 1 && alarm != 2) {
ESP_LOGE(TAG, "Invalid alarm number %u.", alarm);
return;
}
if (this->is_failed())
return;
const uint8_t bit = alarm == 1 ? CONTROL_A1IE : CONTROL_A2IE;
if (!this->update_control_(bit, enabled ? bit : 0))
return;
if (enabled && (this->control_reg_ & CONTROL_INTCN) == 0) {
ESP_LOGW(TAG, "Alarm %u enabled, but INT/SQW is a square-wave output: only the flag will be set.", alarm);
}
ESP_LOGD(TAG, "Alarm %u %s.", alarm, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
}
void DS3231Component::enable_alarm(uint8_t alarm) {
// The chip sets the flag on every match even while the alarm is disabled; enabling with it set
// would fire the trigger (and INT/SQW) for that old match right away.
if (this->clear_alarm_flag_(alarm))
this->set_alarm_enabled_(alarm, true);
}
void DS3231Component::clear_alarm(uint8_t alarm) { this->clear_alarm_flag_(alarm); }
bool DS3231Component::clear_alarm_flag_(uint8_t alarm) {
if (alarm != 1 && alarm != 2) {
ESP_LOGE(TAG, "Invalid alarm number %u.", alarm);
return false;
}
if (this->is_failed() || !this->read_control_status_())
return false;
const uint8_t flag = alarm == 1 ? STATUS_A1F : STATUS_A2F;
if ((this->status_reg_ & flag) != 0) {
// On failure the flag is still set on the chip: keep reporting the alarm as it is.
if (!this->write_status_(flag))
return false;
this->status_reg_ &= ~flag;
}
this->alarm_active_[alarm - 1] = false;
this->publish_alarm_state_(alarm, false);
return true;
}
bool DS3231Component::is_alarm_triggered(uint8_t alarm) {
if (this->is_failed() || !this->read_control_status_())
return false;
return (this->status_reg_ & (alarm == 1 ? STATUS_A1F : STATUS_A2F)) != 0;
}
void DS3231Component::check_alarms_() {
if (this->is_failed() || !this->read_control_status_())
return;
this->publish_oscillator_stopped_();
uint8_t to_clear = 0;
for (uint8_t alarm = 1; alarm <= 2; alarm++) {
const uint8_t flag = alarm == 1 ? STATUS_A1F : STATUS_A2F;
const uint8_t enable = alarm == 1 ? CONTROL_A1IE : CONTROL_A2IE;
const bool triggered = (this->status_reg_ & flag) != 0;
const bool enabled = (this->control_reg_ & enable) != 0;
bool &active = this->alarm_active_[alarm - 1];
if (!triggered) {
if (active && !this->auto_clear_alarms_)
this->publish_alarm_state_(alarm, false);
active = false;
continue;
}
// The flag is set on every match even when the alarm is disabled; only enabled alarms count.
if (enabled && !active) {
ESP_LOGI(TAG, "Alarm %u triggered.", alarm);
active = true;
this->publish_alarm_state_(alarm, true);
this->alarm_callback_.call(alarm);
}
if (this->auto_clear_alarms_)
to_clear |= flag;
}
if (to_clear != 0 && this->write_status_(to_clear)) {
this->status_reg_ &= ~to_clear;
for (uint8_t alarm = 1; alarm <= 2; alarm++) {
if ((to_clear & (alarm == 1 ? STATUS_A1F : STATUS_A2F)) == 0 || !this->alarm_active_[alarm - 1])
continue;
this->alarm_active_[alarm - 1] = false;
#ifdef USE_BINARY_SENSOR
// Keep the binary sensor on long enough for the state to be seen, then turn it off again.
this->set_timeout(ALARM_PULSE_TIMEOUT_ID + alarm - 1, ALARM_PULSE_MS,
[this, alarm]() { this->publish_alarm_state_(alarm, false); });
#endif
}
}
}
void DS3231Component::publish_alarm_state_(uint8_t alarm, bool state) {
#ifdef USE_BINARY_SENSOR
auto *sensor = alarm == 1 ? this->alarm_1_binary_sensor_ : this->alarm_2_binary_sensor_;
if (sensor != nullptr)
sensor->publish_state(state);
#endif
}
void DS3231Component::publish_oscillator_stopped_() {
#ifdef USE_BINARY_SENSOR
if (this->oscillator_stopped_binary_sensor_ != nullptr)
this->oscillator_stopped_binary_sensor_->publish_state((this->status_reg_ & STATUS_OSF) != 0);
#endif
}
// ---------------------------------------------------------------------------------------------
// Outputs and calibration
// ---------------------------------------------------------------------------------------------
void DS3231Component::enable_square_wave(SquareWaveFrequency frequency) {
if (this->interrupt_pin_ != nullptr) {
ESP_LOGE(TAG, "Can't enable the square wave: INT/SQW is used as the alarm interrupt pin.");
return;
}
if (this->is_failed())
return;
if (this->update_control_(CONTROL_INTCN | CONTROL_RS_MASK, frequency << CONTROL_RS_SHIFT)) {
ESP_LOGD(TAG, "Square wave enabled at %s.", LOG_STR_ARG(square_wave_to_string(frequency)));
}
}
void DS3231Component::disable_square_wave() {
if (this->is_failed())
return;
if (this->update_control_(CONTROL_INTCN, CONTROL_INTCN)) {
ESP_LOGD(TAG, "Square wave disabled, INT/SQW is the alarm interrupt.");
}
}
void DS3231Component::set_output_32khz(bool enabled) {
if (this->is_failed() || !this->read_control_status_())
return;
assign_bits(this->status_reg_, STATUS_EN32KHZ, enabled);
if (this->write_status_(0)) {
ESP_LOGD(TAG, "32 kHz output %s.", ONOFF(enabled));
}
}
void DS3231Component::set_aging_offset(int8_t offset) {
if (this->is_failed())
return;
if (!this->write_aging_offset_(offset))
return;
ESP_LOGD(TAG, "Aging offset set to %d.", offset);
// The new offset takes effect with the next temperature conversion.
this->force_temperature_conversion();
}
// ---------------------------------------------------------------------------------------------
// Register access
// ---------------------------------------------------------------------------------------------
bool DS3231Component::read_control_status_() {
uint8_t raw[2];
if (!this->status_comm_result(this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))))
if (!this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))) {
ESP_LOGW(TAG, "Can't read control/status registers.");
return false;
this->control_reg_ = raw[0] & ~CONTROL_CONV;
}
this->control_reg_ = raw[0];
this->status_reg_ = raw[1];
return true;
}
bool DS3231Component::update_control_(uint8_t mask, uint8_t value) {
// CONV is a one-shot command bit: always write it back as 0 so a control change never starts
// a temperature conversion.
if (!this->status_comm_result(this->update_register(DS3231_REG_CONTROL, mask | CONTROL_CONV, value)))
bool DS3231Component::write_control_() {
if (!this->write_byte(DS3231_REG_CONTROL, this->control_reg_)) {
ESP_LOGW(TAG, "Can't write control register.");
return false;
this->control_reg_ = (this->control_reg_ & ~mask) | (value & mask);
return true;
}
bool DS3231Component::write_status_(uint8_t clear) {
// The flags can only be written to 0; writing 1 leaves them unchanged, so a flag that was set
// between the read and this write is never lost.
const uint8_t value = ((this->status_reg_ & STATUS_EN32KHZ) | STATUS_FLAGS) & ~clear;
return this->status_comm_result(this->write_byte(DS3231_REG_STATUS, value));
}
bool DS3231Component::write_aging_offset_(int8_t offset) {
if (!this->status_comm_result(this->write_byte(DS3231_REG_AGING_OFFSET, static_cast<uint8_t>(offset))))
return false;
this->aging_offset_ = offset;
}
return true;
}
+1 -132
View File
@@ -1,163 +1,32 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/components/i2c/i2c.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::ds3231 {
/// When an alarm fires. Alarm 1 supports every mode, alarm 2 has no seconds register and so no EVERY_SECOND.
enum AlarmMode : uint8_t {
ALARM_MODE_EVERY_SECOND = 0, ///< Alarm 1 only.
ALARM_MODE_EVERY_MINUTE, ///< Alarm 1: when the seconds match. Alarm 2: at second 00 of every minute.
ALARM_MODE_HOURLY, ///< When minutes (and seconds) match.
ALARM_MODE_DAILY, ///< When hours, minutes (and seconds) match.
ALARM_MODE_MONTHLY, ///< When date, hours, minutes (and seconds) match.
ALARM_MODE_WEEKLY, ///< When day of week, hours, minutes (and seconds) match.
};
/// Frequency of the INT/SQW pin when it is a square-wave output (RS2:RS1 in the control register).
enum SquareWaveFrequency : uint8_t {
SQUARE_WAVE_1HZ = 0,
SQUARE_WAVE_1024HZ = 1,
SQUARE_WAVE_4096HZ = 2,
SQUARE_WAVE_8192HZ = 3,
};
/// Hub for a single DS3231 real-time clock. Owns all register access; the platforms talk to the
/// chip through this class.
class DS3231Component final : public Component, public i2c::I2CDevice {
public:
/// Seconds through year, the 7-byte BCD calendar the time platform decodes with time::DS_BCD_CALENDAR.
static constexpr size_t CALENDAR_LEN = 7;
void setup() override;
void loop() override;
void dump_config() override;
// ---- Configuration (set from codegen) ----
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_square_wave_frequency(SquareWaveFrequency frequency) {
this->square_wave_frequency_ = frequency;
this->square_wave_configured_ = true;
}
void set_battery_backed_square_wave(bool enabled) { this->battery_backed_square_wave_ = enabled ? 1 : 0; }
void set_output_32khz_config(bool enabled) { this->output_32khz_ = enabled ? 1 : 0; }
void set_aging_offset_config(int8_t offset) {
this->aging_offset_ = offset;
this->aging_offset_configured_ = true;
}
void set_auto_clear_alarms(bool auto_clear) { this->auto_clear_alarms_ = auto_clear; }
void set_alarm_check_interval(uint32_t interval) { this->alarm_check_interval_ = interval; }
void set_monitor_alarms(bool monitor) { this->monitor_alarms_ = monitor; }
/// Register a void(uint8_t alarm) callback that runs when an enabled alarm (1 or 2) matches.
template<typename F> void add_on_alarm_callback(F &&callback) {
this->alarm_callback_.add(std::forward<F>(callback));
}
#ifdef USE_BINARY_SENSOR
SUB_BINARY_SENSOR(alarm_1)
SUB_BINARY_SENSOR(alarm_2)
SUB_BINARY_SENSOR(oscillator_stopped)
#endif
// ---- Clock registers (used by the time platform) ----
/// Read the calendar registers into @p raw. Fails if the oscillator was stopped since the time was last set.
bool read_calendar(uint8_t *raw);
/// Write the calendar registers from @p raw and clear the oscillator-stop flag.
bool write_calendar(const uint8_t *raw);
// ---- Temperature ----
bool read_temperature(float &out);
void force_temperature_conversion();
// ---- Alarms ----
/// Program alarm 1. @p day_of_month is used in MONTHLY mode, @p day_of_week (1 = Sunday) in WEEKLY mode.
void set_alarm_1(AlarmMode mode, uint8_t second, uint8_t minute, uint8_t hour, uint8_t day_of_month,
uint8_t day_of_week, bool enabled);
/// Program alarm 2 (minute resolution, fires at second 00).
void set_alarm_2(AlarmMode mode, uint8_t minute, uint8_t hour, uint8_t day_of_month, uint8_t day_of_week,
bool enabled);
/// Enable @p alarm. A match from while it was disabled is cleared first, so only the next one fires.
void enable_alarm(uint8_t alarm);
void disable_alarm(uint8_t alarm) { this->set_alarm_enabled_(alarm, false); }
/// Clear the alarm flag, which also releases the INT/SQW line.
void clear_alarm(uint8_t alarm);
/// True while the alarm flag of @p alarm (1 or 2) is set on the chip.
bool is_alarm_triggered(uint8_t alarm);
// ---- Outputs and calibration ----
/// Switch INT/SQW to a square-wave output at @p frequency. Alarms then no longer drive the pin.
void enable_square_wave(SquareWaveFrequency frequency);
/// Switch INT/SQW back to the (active-low) alarm interrupt output.
void disable_square_wave();
void set_output_32khz(bool enabled);
/// Aging offset in LSB (about 0.1 ppm each at 25 degC); positive values slow the clock down.
void set_aging_offset(int8_t offset);
protected:
static constexpr uint8_t CONTROL_A1IE = 0x01;
static constexpr uint8_t CONTROL_A2IE = 0x02;
static constexpr uint8_t CONTROL_INTCN = 0x04;
static constexpr uint8_t CONTROL_RS_MASK = 0x18;
static constexpr uint8_t CONTROL_RS_SHIFT = 3;
static constexpr uint8_t CONTROL_CONV = 0x20;
static constexpr uint8_t CONTROL_BBSQW = 0x40;
static constexpr uint8_t CONTROL_EOSC = 0x80;
static constexpr uint8_t STATUS_A1F = 0x01;
static constexpr uint8_t STATUS_A2F = 0x02;
static constexpr uint8_t STATUS_BSY = 0x04;
static constexpr uint8_t STATUS_EN32KHZ = 0x08;
static constexpr uint8_t STATUS_OSF = 0x80;
static constexpr uint8_t STATUS_FLAGS = STATUS_A1F | STATUS_A2F | STATUS_OSF;
/// Scheduler ids: one pulse timer per alarm (id + alarm - 1).
static constexpr uint32_t ALARM_PULSE_TIMEOUT_ID = 0;
static void interrupt_handler(DS3231Component *arg);
/// Read the control and status registers into the cached copies. Sets the warning flag on failure.
bool read_control_status_();
/// Replace the bits in @p mask of the control register with @p value, on the chip and in the cache.
bool update_control_(uint8_t mask, uint8_t value);
/// Write the status register, clearing the flags in @p clear (writing 1 to a flag leaves it unchanged).
bool write_status_(uint8_t clear);
bool write_aging_offset_(int8_t offset);
/// Clear the flag of @p alarm and report it off. Returns false (state unchanged) if that fails.
bool clear_alarm_flag_(uint8_t alarm);
void set_alarm_enabled_(uint8_t alarm, bool enabled);
/// Write the alarm registers, clear the stale flag and set the interrupt enable.
void write_alarm_(uint8_t alarm, const uint8_t *regs, size_t len, bool enabled);
/// Read the flags, fire triggers and update the binary sensors.
void check_alarms_();
void publish_alarm_state_(uint8_t alarm, bool state);
void publish_oscillator_stopped_();
bool write_control_();
InternalGPIOPin *interrupt_pin_{nullptr};
LazyCallbackManager<void(uint8_t)> alarm_callback_;
uint32_t alarm_check_interval_{1000};
uint8_t control_reg_{0};
uint8_t status_reg_{0};
int8_t aging_offset_{0};
SquareWaveFrequency square_wave_frequency_{SQUARE_WAVE_1HZ};
/// -1: leave the chip as it is, 0: off, 1: on.
int8_t battery_backed_square_wave_{-1};
int8_t output_32khz_{-1};
bool square_wave_configured_{false};
bool aging_offset_configured_{false};
bool auto_clear_alarms_{true};
bool monitor_alarms_{false};
/// Set while an alarm flag has been reported, so a latched flag fires its trigger only once.
bool alarm_active_[2]{false, false};
};
} // namespace esphome::ds3231
@@ -1,40 +0,0 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import time
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
from .. import CONF_DS3231_ID, DS3231Component, ds3231_ns
DEPENDENCIES = ["ds3231"]
DS3231Time = ds3231_ns.class_(
"DS3231Time", time.RealTimeClock, cg.Parented.template(DS3231Component)
)
CONFIG_SCHEMA = time.TIME_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(DS3231Time),
cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component),
}
)
DS3231_TIME_ACTION_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(DS3231Time)}
)
automation.register_apply_action(
"ds3231.write_time", DS3231_TIME_ACTION_SCHEMA, automation.ApplyCall("write_time()")
)
automation.register_apply_action(
"ds3231.read_time", DS3231_TIME_ACTION_SCHEMA, automation.ApplyCall("read_time()")
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_DS3231_ID])
await time.register_time(var, config)
@@ -1,48 +0,0 @@
#include "ds3231_time.h"
#include "esphome/core/log.h"
namespace esphome::ds3231 {
ESPHOME_LOG_TAG(TAG, "ds3231.time");
void DS3231Time::update() { this->read_time(); }
void DS3231Time::dump_config() {
ESP_LOGCONFIG(TAG, "DS3231 Time:");
RealTimeClock::dump_config();
}
void DS3231Time::read_time() {
uint8_t raw[DS3231Component::CALENDAR_LEN];
if (!this->status_comm_result(this->parent_->read_calendar(raw), LOG_STR("No valid time from the RTC")))
return;
const ESPTime rtc_time = time::decode_bcd_calendar<time::DS_BCD_CALENDAR>(raw);
ESP_LOGD(TAG, "Read UTC time: %04u-%02u-%02u %02u:%02u:%02u (day of week %u)", rtc_time.year, rtc_time.month,
rtc_time.day_of_month, rtc_time.hour, rtc_time.minute, rtc_time.second, rtc_time.day_of_week);
// The day of week is a free-running counter on the DS3231 and may have been set with a different
// numbering by other software, so it is not used to validate the time.
if (!this->status_comm_result(rtc_time.is_valid(/*check_day_of_week=*/false, /*check_day_of_year=*/false),
LOG_STR("Invalid RTC time")))
return;
this->synchronize_epoch_(rtc_time.timestamp);
}
void DS3231Time::write_time() {
auto now = this->utcnow();
if (!now.is_valid()) {
ESP_LOGE(TAG, "Invalid system time, not syncing to RTC.");
return;
}
if (now.year > 2199) {
ESP_LOGE(TAG, "Year %u is out of the DS3231 range (2000-2199).", now.year);
return;
}
uint8_t raw[DS3231Component::CALENDAR_LEN];
time::encode_bcd_calendar<time::DS_BCD_CALENDAR>(now, raw);
if (!this->status_comm_result(this->parent_->write_calendar(raw)))
return;
ESP_LOGD(TAG, "Wrote UTC time: %04u-%02u-%02u %02u:%02u:%02u", now.year, now.month, now.day_of_month, now.hour,
now.minute, now.second);
}
} // namespace esphome::ds3231
@@ -1,22 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/time/real_time_clock.h"
#include "../ds3231.h"
namespace esphome::ds3231 {
class DS3231Time final : public time::RealTimeClock, public Parented<DS3231Component> {
public:
void update() override;
void dump_config() override;
/// Set the system clock from the RTC.
void read_time();
/// Store the system clock in the RTC.
void write_time();
};
} // namespace esphome::ds3231
@@ -1,177 +0,0 @@
"""Send sensor and binary sensor states to other devices over a packet interface."""
from collections.abc import Callable
from typing import Any
import esphome.codegen as cg
from esphome.components import packet_interface
from esphome.components.binary_sensor import BinarySensor
from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID
from esphome.components.packet_interface import (
CONF_PACKET_INTERFACE,
final_validate_interface,
interface_has_layer,
)
from esphome.components.packet_interface.layers import CONF_SESSION
from esphome.components.sensor import Sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BINARY_SENSORS,
CONF_ID,
CONF_SENSORS,
CONF_TIMEOUT,
CONF_UPDATE_INTERVAL,
)
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.types import ConfigType
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["packet_interface"]
DOMAIN = "entity_transport"
MULTI_CONF = True
CONF_BROADCAST_ID = "broadcast_id"
CONF_ENTITY_TRANSPORT_ID = "entity_transport_id"
entity_transport_ns = cg.esphome_ns.namespace("entity_transport")
EntityTransport = entity_transport_ns.class_("EntityTransport", cg.Component)
_request_remote_sensor_slot = cg.slot_counter("ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT")
_request_remote_binary_sensor_slot = cg.slot_counter(
"ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT"
)
def _advertised_id(conf: ConfigType) -> str:
return conf.get(CONF_BROADCAST_ID, conf[CONF_ID].id)
def _unique_advertised_ids(entities: list[ConfigType]) -> list[ConfigType]:
"""A receiver tells entities of one type apart by their advertised ids, so each must be different."""
seen: set[str] = set()
for index, conf in enumerate(entities):
if (advertised := _advertised_id(conf)) in seen:
raise cv.Invalid(
f"The id '{advertised}' is sent for two entities", path=[index]
)
seen.add(advertised)
return entities
def _local_entity(cls: MockObjClass) -> Callable[[Any], Any]:
return cv.maybe_simple_value(
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(cls),
cv.Optional(CONF_BROADCAST_ID): cv.All(
cv.validate_id_name, cv.Length(max=255)
),
}
),
key=CONF_ID,
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(EntityTransport),
cv.Required(CONF_PACKET_INTERFACE): cv.use_id(packet_interface.PacketInterface),
# Defaults to a periodic resend unless the interface has session and reliability layers
cv.Optional(CONF_UPDATE_INTERVAL): cv.All(
cv.update_interval, cv.Range(min=cv.TimePeriod(seconds=1))
),
# Remote entities lose their state when none arrives for this long
cv.Optional(CONF_TIMEOUT): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(seconds=1)),
),
# Entities are numbered with one byte
cv.Optional(CONF_SENSORS): cv.All(
cv.ensure_list(_local_entity(Sensor)),
cv.Length(max=255),
_unique_advertised_ids,
),
cv.Optional(CONF_BINARY_SENSORS): cv.All(
cv.ensure_list(_local_entity(BinarySensor)),
cv.Length(max=255),
_unique_advertised_ids,
),
}
).extend(cv.COMPONENT_SCHEMA)
def _final_validate(config: ConfigType) -> ConfigType:
"""With a session layer, which clears the states when the peer is lost, nothing would refresh an unchanged state."""
if CONF_TIMEOUT in config and interface_has_layer(
config[CONF_PACKET_INTERFACE], CONF_SESSION
):
raise cv.Invalid(
f"Not allowed with a '{CONF_SESSION}' layer, or a link that does its job, which already clears the "
"states when the other device cannot be reached; unchanged states may never be sent again, so they "
"would time out on a working link",
path=[CONF_TIMEOUT],
)
return config
FINAL_VALIDATE_SCHEMA = cv.All(
final_validate_interface(CONF_PACKET_INTERFACE), _final_validate
)
def _validate_remote_id(config: ConfigType) -> ConfigType:
if CONF_REMOTE_ID not in config and not config[CONF_ID].is_manual:
raise cv.Invalid(f"'{CONF_REMOTE_ID}' is required unless '{CONF_ID}' is given")
return config
def remote_entity_schema(base_schema: cv.Schema) -> cv.Schema:
"""Schema for an entity whose state comes from another device."""
return cv.All(
base_schema.extend(
{
cv.GenerateID(CONF_ENTITY_TRANSPORT_ID): cv.use_id(EntityTransport),
cv.Required(CONF_PROVIDER): cv.valid_name,
cv.Optional(CONF_REMOTE_ID): cv.All(
cv.string_strict, cv.Length(min=1, max=255)
),
}
),
_validate_remote_id,
)
def _remote_id(config: ConfigType) -> str:
return config.get(CONF_REMOTE_ID) or config[CONF_ID].id
async def register_remote_sensor(config: ConfigType, var: MockObj) -> None:
hub = await cg.get_variable(config[CONF_ENTITY_TRANSPORT_ID])
_request_remote_sensor_slot(str(hub))
cg.add(hub.add_remote_sensor(config[CONF_PROVIDER], _remote_id(config), var))
async def register_remote_binary_sensor(config: ConfigType, var: MockObj) -> None:
hub = await cg.get_variable(config[CONF_ENTITY_TRANSPORT_ID])
_request_remote_binary_sensor_slot(str(hub))
cg.add(hub.add_remote_binary_sensor(config[CONF_PROVIDER], _remote_id(config), var))
async def to_code(config: ConfigType) -> None:
interface = await cg.get_variable(config[CONF_PACKET_INTERFACE])
var = cg.new_Pvariable(config[CONF_ID], interface)
await cg.register_component(var, config)
if (interval := config.get(CONF_UPDATE_INTERVAL)) is not None:
cg.add(var.set_update_interval(interval))
if (timeout := config.get(CONF_TIMEOUT)) is not None:
cg.add(var.set_remote_timeout(timeout))
sensors = config.get(CONF_SENSORS, [])
binary_sensors = config.get(CONF_BINARY_SENSORS, [])
if count := len(sensors) + len(binary_sensors):
cg.add(var.set_entity_count(count))
for conf in sensors:
sensor = await cg.get_variable(conf[CONF_ID])
cg.add(var.add_sensor(_advertised_id(conf), sensor))
for conf in binary_sensors:
sensor = await cg.get_variable(conf[CONF_ID])
cg.add(var.add_binary_sensor(_advertised_id(conf), sensor))
@@ -1,11 +0,0 @@
from esphome.components import binary_sensor
from esphome.types import ConfigType
from . import register_remote_binary_sensor, remote_entity_schema
CONFIG_SCHEMA = remote_entity_schema(binary_sensor.binary_sensor_schema())
async def to_code(config: ConfigType) -> None:
var = await binary_sensor.new_binary_sensor(config)
await register_remote_binary_sensor(config, var)
@@ -1,683 +0,0 @@
#include "entity_transport.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::entity_transport {
using packet_interface::PacketBuffer;
using packet_interface::PacketMetaData;
ESPHOME_LOG_TAG(TAG, "entity_transport");
// The packet type byte holds the format version in its high nibble
static constexpr uint8_t FORMAT_VERSION = 1;
static constexpr uint8_t PACKET_DICTIONARY = 0x11;
static constexpr uint8_t PACKET_DATA = 0x12;
static constexpr uint8_t PACKET_REQUEST = 0x13;
// Entity types, in dictionary sections and data records; 0 is not used
static constexpr uint8_t TYPE_SENSOR = 1;
static constexpr uint8_t TYPE_BINARY_SENSOR = 2;
static constexpr uint8_t MAX_TYPE = 31;
// Packet type and dictionary id
static constexpr size_t HEADER_SIZE = 5;
// A dictionary packet's part byte follows the header: the part's index in bits 0-6, and on the last part, bit 7
static constexpr uint8_t PART_INDEX_MASK = 0x7F;
static constexpr uint8_t PART_LAST = 0x80;
static constexpr uint8_t PART_NONE = 0xFF; // no dictionary is being received in order
// A data record starts with a 16-bit tag: the entity's number in bits 0-7, its type in bits 8-12, the payload form in
// bits 13-14, and with PAYLOAD_NONE, the state in bit 15. Any record can be skipped without knowing its type.
static constexpr size_t TAG_SIZE = 2;
static constexpr uint8_t TAG_TYPE_SHIFT = 8;
static constexpr uint8_t TAG_PAYLOAD_SHIFT = 13;
static constexpr uint16_t TAG_VALUE_BIT = 0x8000;
static constexpr uint8_t PAYLOAD_NONE = 0; // the state is in the tag's value bit
static constexpr uint8_t PAYLOAD_WORD = 1; // 4 bytes follow
static constexpr uint8_t PAYLOAD_LENGTH = 2; // a length byte follows, then that many bytes
static constexpr uint8_t PAYLOAD_NO_STATE = 3; // the entity has no state
static constexpr size_t WORD_SIZE = 4;
static constexpr size_t MAX_RECORD_SIZE = TAG_SIZE + WORD_SIZE;
// A dictionary section starts with the entity type, first number and count
static constexpr size_t SECTION_HEADER_SIZE = 3;
static constexpr size_t MAX_STRING_LENGTH = 255;
static constexpr uint32_t DEFAULT_UPDATE_INTERVAL_MS = 15000;
static constexpr uint32_t REQUEST_INTERVAL_MS = 5000;
static constexpr uint32_t RETRY_DELAY_MS = 1000;
static constexpr uint32_t RETRY_TIMER_ID = 0;
static constexpr uint32_t TIMEOUT_CHECK_INTERVAL_MS = 1000;
static constexpr uint32_t WARNING_INTERVAL_MS = 60000;
// How long the interface may refuse every packet before it is reported
static constexpr uint32_t REFUSED_WARNING_MS = 60000;
static void put_uint32(uint8_t *buf, uint32_t value) {
for (size_t i = 0; i != 4; i++)
buf[i] = value >> (i * 8);
}
// True if the NUL terminated str is the same as the len bytes at data
static bool matches(const char *str, const char *data, size_t len) {
return strlen(str) == len && memcmp(str, data, len) == 0;
}
struct Record {
uint8_t number;
uint8_t type;
uint8_t payload;
bool value;
const uint8_t *data; // what follows the tag
};
static Record parse_record(std::span<const uint8_t> packet, size_t pos) {
uint16_t tag = encode_uint16(packet[pos + 1], packet[pos]);
return {static_cast<uint8_t>(tag), static_cast<uint8_t>((tag >> TAG_TYPE_SHIFT) & MAX_TYPE),
static_cast<uint8_t>((tag >> TAG_PAYLOAD_SHIFT) & 3), (tag & TAG_VALUE_BIT) != 0,
packet.data() + pos + TAG_SIZE};
}
// The size of the data record at pos, or 0 if it does not fit in the packet
static size_t record_size(std::span<const uint8_t> packet, size_t pos) {
if (pos + TAG_SIZE > packet.size())
return 0;
size_t size = TAG_SIZE;
uint8_t payload = parse_record(packet, pos).payload;
if (payload == PAYLOAD_WORD) {
size += WORD_SIZE;
} else if (payload == PAYLOAD_LENGTH) {
if (pos + TAG_SIZE == packet.size())
return 0;
size += 1 + packet[pos + TAG_SIZE];
}
return pos + size <= packet.size() ? size : 0;
}
// Checks every record of a data packet, so that a damaged or forged packet changes no state at all
static bool data_well_formed(std::span<const uint8_t> packet) {
for (size_t pos = HEADER_SIZE; pos != packet.size();) {
size_t size = record_size(packet, pos);
if (size == 0)
return false;
Record record = parse_record(packet, pos);
if (record.type == 0 || (record.value && record.payload != PAYLOAD_NONE))
return false;
// Types this device knows must use their own payload form; others are skipped
if (record.type == TYPE_SENSOR && record.payload != PAYLOAD_WORD && record.payload != PAYLOAD_NO_STATE)
return false;
if (record.type == TYPE_BINARY_SENSOR && record.payload != PAYLOAD_NONE && record.payload != PAYLOAD_NO_STATE)
return false;
pos += size;
}
return true;
}
// Checks the device name and every section of a dictionary packet before anything is learnt from it
static bool dictionary_well_formed(std::span<const uint8_t> packet) {
size_t pos = HEADER_SIZE + 1;
if (pos >= packet.size())
return false;
pos += 1 + packet[pos];
if (pos > packet.size())
return false;
while (pos != packet.size()) {
if (pos + SECTION_HEADER_SIZE > packet.size())
return false;
uint8_t type = packet[pos];
size_t first = packet[pos + 1];
size_t count = packet[pos + 2];
pos += SECTION_HEADER_SIZE;
// Numbers are one byte, and a data record can name types up to MAX_TYPE only
if (type == 0 || type > MAX_TYPE || count == 0 || first + count > 256)
return false;
for (size_t i = 0; i != count; i++) {
if (pos == packet.size())
return false;
pos += 1 + packet[pos];
if (pos > packet.size())
return false;
}
}
return true;
}
void EntityTransport::setup() {
this->interface_ = this->interface_->top();
this->max_packet_size_ = this->interface_->get_max_packet_size();
const auto &name = App.get_name();
size_t longest_id = 0;
for (const auto &entity : this->entities_)
longest_id = std::max(longest_id, strlen(entity.id));
// A dictionary packet must hold the device name and any one id, which also leaves room for a data record
if (name.size() > MAX_STRING_LENGTH || longest_id > MAX_STRING_LENGTH ||
HEADER_SIZE + 2 + name.size() + SECTION_HEADER_SIZE + 1 + longest_id > this->max_packet_size_) {
this->mark_failed(LOG_STR("max_packet_size too small for the device name and entity ids"));
return;
}
RAMAllocator<uint8_t> allocator;
this->buffer_ = allocator.allocate(this->max_packet_size_);
if (this->buffer_ == nullptr) {
this->mark_failed(LOG_STR("Not enough memory for the packet buffer"));
return;
}
// The dictionary id changes when the device name or any entity changes
uint32_t hash = fnv1a_hash(name.c_str());
for (const auto &entity : this->entities_) {
hash = fnv1a_hash_extend(hash, entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR);
hash = fnv1a_hash_extend(hash, entity.id);
}
this->dictionary_id_ = hash;
// [&entity] is safe: FixedVector never reallocates, and the parent pointer keeps the capture to one pointer
for (auto &entity : this->entities_) {
#ifdef USE_SENSOR
if (entity.is_sensor) {
static_cast<sensor::Sensor *>(entity.entity)->add_on_state_callback([&entity](float) {
entity.parent->mark_updated_(entity);
});
}
#endif
#ifdef USE_BINARY_SENSOR
if (!entity.is_sensor) {
// The full state callback also fires for the first state
static_cast<binary_sensor::BinarySensor *>(entity.entity)
->add_full_state_callback(
[&entity](optional<bool>, optional<bool>) { entity.parent->mark_updated_(entity); });
}
#endif
}
this->interface_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &) { this->receive_(data); });
bool session = this->interface_->has_capability(packet_interface::CAPABILITY_SESSION);
if (session)
this->interface_->add_on_session_state_callback([this](bool connected) { this->on_session_(connected); });
// Without a session and reliable delivery, a lost dictionary or state is only replaced by a periodic resend
if (this->update_interval_ms_ == 0) {
this->update_interval_ms_ = session && this->interface_->has_capability(packet_interface::CAPABILITY_RELIABILITY)
? SCHEDULER_DONT_RUN
: DEFAULT_UPDATE_INTERVAL_MS;
}
if (this->update_interval_ms_ != SCHEDULER_DONT_RUN)
this->set_interval(this->update_interval_ms_, [this] { this->send_all_(); });
if (this->remote_timeout_ms_ != 0)
this->set_interval(TIMEOUT_CHECK_INTERVAL_MS, [this] { this->clear_remote_states_(true); });
this->send_all_();
}
void EntityTransport::dump_config() {
ESP_LOGCONFIG(TAG,
"Entity Transport:\n"
" Packet Interface: %s\n"
" Max Packet Size: %zu\n"
" Encryption: %s\n"
" Reliability: %s\n"
" Session: %s\n"
" Dictionary ID: %08" PRIX32,
this->interface_->get_name(), this->max_packet_size_,
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_ENCRYPTION)),
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_RELIABILITY)),
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_SESSION)), this->dictionary_id_);
if (this->update_interval_ms_ == SCHEDULER_DONT_RUN) {
ESP_LOGCONFIG(TAG, " Update Interval: never");
} else {
ESP_LOGCONFIG(TAG, " Update Interval: %" PRIu32 "ms", this->update_interval_ms_);
}
if (this->remote_timeout_ms_ != 0) {
ESP_LOGCONFIG(TAG, " Remote Timeout: %" PRIu32 "ms", this->remote_timeout_ms_);
}
for (const auto &entity : this->entities_) {
ESP_LOGCONFIG(TAG, " %s %u: %s", entity.is_sensor ? LOG_STR_LITERAL("Sensor") : LOG_STR_LITERAL("Binary Sensor"),
entity.number, entity.id);
}
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (const auto &remote : this->remote_sensors_) {
ESP_LOGCONFIG(TAG, " Remote Sensor: %s from %s", remote.remote_id, remote.provider);
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (const auto &remote : this->remote_binary_sensors_) {
ESP_LOGCONFIG(TAG, " Remote Binary Sensor: %s from %s", remote.remote_id, remote.provider);
}
#endif
}
void EntityTransport::send_all_() {
this->dictionary_pending_ = true;
this->dictionary_next_ = 0;
this->dictionary_part_ = 0;
for (auto &entity : this->entities_)
entity.updated = true;
this->enable_loop();
}
void EntityTransport::mark_updated_(LocalEntity &entity) {
entity.updated = true;
this->enable_loop();
}
void EntityTransport::loop() {
this->disable_loop();
// States are only sent once the dictionary that names them has been accepted
if ((this->dictionary_pending_ && !this->send_dictionary_()) || !this->send_updated_()) {
this->sending_refused_();
this->set_timeout(RETRY_TIMER_ID, RETRY_DELAY_MS, [this] { this->enable_loop(); });
return;
}
this->refusing_ = false;
if (this->status_has_warning()) {
ESP_LOGI(TAG, "Packets are accepted again");
this->status_clear_warning();
}
}
// A link that is down or full refuses packets for a while; one that refuses them for long sends nothing at all
void EntityTransport::sending_refused_() {
uint32_t now = App.get_loop_component_start_time();
if (!this->refusing_) {
this->refusing_ = true;
this->refused_since_ms_ = now;
return;
}
if (!this->status_has_warning() && now - this->refused_since_ms_ >= REFUSED_WARNING_MS) {
ESP_LOGW(TAG, "The packet interface has accepted no packets for %" PRIu32 "s, so no states are sent",
(now - this->refused_since_ms_) / 1000);
this->status_set_warning();
}
}
size_t EntityTransport::write_header_(uint8_t type) const {
this->buffer_[0] = type;
put_uint32(this->buffer_ + 1, this->dictionary_id_);
return HEADER_SIZE;
}
bool EntityTransport::send_dictionary_() {
uint8_t *buf = this->buffer_;
const auto &name = App.get_name();
size_t header = this->write_header_(PACKET_DICTIONARY);
size_t part_offset = header++;
buf[header++] = name.size();
memcpy(buf + header, name.c_str(), name.size());
header += name.size();
size_t length = header;
size_t count_offset = 0;
uint8_t section_type = 0; // none
// A dictionary in several packets resumes after the last packet accepted, so a refused one does not resend the rest
for (size_t i = this->dictionary_next_; i != this->entities_.size(); i++) {
const auto &entity = this->entities_[i];
uint8_t type = entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR;
size_t id_length = strlen(entity.id);
bool new_section = type != section_type;
if (length + (new_section ? SECTION_HEADER_SIZE : 0) + 1 + id_length > this->max_packet_size_) {
buf[part_offset] = this->dictionary_part_ & PART_INDEX_MASK;
if (!this->interface_->send_to_interface(PacketBuffer(buf, length), {})) {
ESP_LOGV(TAG, "Dictionary packet of %zu bytes not accepted, will retry", length);
return false;
}
this->dictionary_next_ = i;
this->dictionary_part_++;
length = header;
new_section = true;
}
if (new_section) {
buf[length++] = type;
buf[length++] = entity.number;
count_offset = length;
buf[length++] = 0;
section_type = type;
}
buf[count_offset]++;
buf[length++] = id_length;
memcpy(buf + length, entity.id, id_length);
length += id_length;
}
if (length != header) {
buf[part_offset] = (this->dictionary_part_ & PART_INDEX_MASK) | PART_LAST;
if (!this->interface_->send_to_interface(PacketBuffer(buf, length), {})) {
ESP_LOGV(TAG, "Dictionary packet of %zu bytes not accepted, will retry", length);
return false;
}
}
this->dictionary_pending_ = false;
this->dictionary_next_ = 0;
this->dictionary_part_ = 0;
return true;
}
// Writes the entity's data record, and returns its size
size_t EntityTransport::write_record_(const LocalEntity &entity, uint8_t *record) const {
uint8_t type = entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR;
uint8_t payload = PAYLOAD_NO_STATE;
bool value = false;
size_t size = TAG_SIZE;
#ifdef USE_SENSOR
if (entity.is_sensor) {
float state = static_cast<sensor::Sensor *>(entity.entity)->get_state();
if (!std::isnan(state)) {
payload = PAYLOAD_WORD;
uint32_t bits;
memcpy(&bits, &state, sizeof(bits));
put_uint32(record + TAG_SIZE, bits);
size += WORD_SIZE;
}
}
#endif
#ifdef USE_BINARY_SENSOR
if (!entity.is_sensor) {
auto *sensor = static_cast<binary_sensor::BinarySensor *>(entity.entity);
if (sensor->has_state()) {
payload = PAYLOAD_NONE;
value = sensor->state;
}
}
#endif
uint16_t tag =
entity.number | (type << TAG_TYPE_SHIFT) | (payload << TAG_PAYLOAD_SHIFT) | (value ? TAG_VALUE_BIT : 0);
record[0] = tag;
record[1] = tag >> 8;
return size;
}
// Sends every updated entity, in as few packets as fit; returns false if a packet was refused
bool EntityTransport::send_updated_() {
size_t length = this->write_header_(PACKET_DATA);
size_t first = 0;
for (size_t i = 0; i != this->entities_.size(); i++) {
auto &entity = this->entities_[i];
if (!entity.updated)
continue;
uint8_t record[MAX_RECORD_SIZE];
size_t size = this->write_record_(entity, record);
if (length + size > this->max_packet_size_) {
if (!this->flush_(length, first, i))
return false;
length = HEADER_SIZE;
first = i;
}
memcpy(this->buffer_ + length, record, size);
length += size;
}
return this->flush_(length, first, this->entities_.size());
}
// Sends the packet holding the updated entities from first up to last, and clears their flags if it was accepted
bool EntityTransport::flush_(size_t length, size_t first, size_t last) {
if (length == HEADER_SIZE)
return true;
if (!this->interface_->send_to_interface(PacketBuffer(this->buffer_, length), {})) {
ESP_LOGV(TAG, "Packet of %zu bytes not accepted, will retry", length);
return false;
}
for (size_t i = first; i != last; i++)
this->entities_[i].updated = false;
return true;
}
void EntityTransport::receive_(const PacketBuffer &data) {
// Packet interface layers deliver packets in one piece
if (!data.is_contiguous()) {
ESP_LOGW(TAG, "Packet split across buffers is not supported");
return;
}
auto packet = data.as_span();
if (packet.size() < HEADER_SIZE) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Packet of %zu bytes is too short", packet.size());
}
return;
}
if (packet[0] >> 4 != FORMAT_VERSION) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Packet format version %u not supported; the sender may run another ESPHome version",
packet[0] >> 4);
}
return;
}
uint32_t dictionary_id = encode_uint32(packet[4], packet[3], packet[2], packet[1]);
if (dictionary_id == this->dictionary_id_) {
// Either a request for our dictionary, or our own packet heard on a broadcast channel
if (packet[0] == PACKET_REQUEST && packet.size() == HEADER_SIZE) {
ESP_LOGD(TAG, "Dictionary requested");
this->send_all_();
}
return;
}
if (packet[0] == PACKET_DICTIONARY) {
this->receive_dictionary_(packet, dictionary_id);
} else if (packet[0] == PACKET_DATA) {
this->receive_data_(packet, dictionary_id);
} else if (packet[0] != PACKET_REQUEST) {
ESP_LOGV(TAG, "Unknown packet type 0x%02X", packet[0]);
}
}
void EntityTransport::receive_dictionary_(std::span<const uint8_t> packet, uint32_t dictionary_id) {
if (!dictionary_well_formed(packet)) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Dictionary packet of %zu bytes is malformed", packet.size());
}
return;
}
size_t pos = HEADER_SIZE;
uint8_t part = packet[pos++];
uint8_t index = part & PART_INDEX_MASK;
// The dictionary is complete, and need not be asked for again, once its last part follows all the others in order
bool in_order = index == 0 || (this->rx_dictionary_id_ == dictionary_id && index == this->rx_next_part_);
this->rx_dictionary_id_ = dictionary_id;
this->rx_next_part_ = in_order ? (index + 1) & PART_INDEX_MASK : PART_NONE;
bool completed = in_order && (part & PART_LAST) != 0;
bool first_time = completed && !this->dictionary_seen_(dictionary_id);
if (first_time) {
this->seen_dictionaries_[this->seen_next_] = dictionary_id;
this->seen_next_ = (this->seen_next_ + 1) % this->seen_dictionaries_.size();
}
size_t provider_length = packet[pos++];
const auto *provider = reinterpret_cast<const char *>(packet.data() + pos);
pos += provider_length;
while (pos != packet.size()) {
uint8_t type = packet[pos];
uint8_t number = packet[pos + 1];
uint8_t count = packet[pos + 2];
pos += SECTION_HEADER_SIZE;
for (uint8_t i = 0; i != count; i++) {
size_t id_length = packet[pos++];
this->learn_(provider, provider_length, type, number + i, reinterpret_cast<const char *>(packet.data() + pos),
id_length, dictionary_id);
pos += id_length;
}
}
// A provider may go back to a dictionary already seen, so every complete one is checked
if (completed)
this->forget_unnamed_(provider, provider_length, dictionary_id, first_time);
}
void EntityTransport::forget_unnamed_(const char *provider, size_t provider_length, uint32_t dictionary_id,
bool first_time) {
// A remote entity that its provider's whole dictionary does not name would otherwise keep no or a stale state
// silently
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (auto &remote : this->remote_sensors_) {
if (!matches(remote.provider, provider, provider_length) || (remote.known && remote.dictionary_id == dictionary_id))
continue;
if (remote.known || first_time) {
ESP_LOGW(TAG, "'%s' does not send '%s'", remote.provider, remote.remote_id);
}
remote.known = false;
if (remote.has_state) {
remote.has_state = false;
remote.entity->publish_state(NAN);
}
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (auto &remote : this->remote_binary_sensors_) {
if (!matches(remote.provider, provider, provider_length) || (remote.known && remote.dictionary_id == dictionary_id))
continue;
if (remote.known || first_time) {
ESP_LOGW(TAG, "'%s' does not send '%s'", remote.provider, remote.remote_id);
}
remote.known = false;
if (remote.has_state) {
remote.has_state = false;
remote.entity->invalidate_state();
}
}
#endif
}
void EntityTransport::learn_(const char *provider, size_t provider_length, uint8_t type, uint8_t number, const char *id,
size_t id_length, uint32_t dictionary_id) {
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
if (type == TYPE_SENSOR) {
for (auto &remote : this->remote_sensors_) {
if (matches(remote.provider, provider, provider_length) && matches(remote.remote_id, id, id_length)) {
remote.dictionary_id = dictionary_id;
remote.number = number;
remote.known = true;
}
}
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
if (type == TYPE_BINARY_SENSOR) {
for (auto &remote : this->remote_binary_sensors_) {
if (matches(remote.provider, provider, provider_length) && matches(remote.remote_id, id, id_length)) {
remote.dictionary_id = dictionary_id;
remote.number = number;
remote.known = true;
}
}
}
#endif
}
bool EntityTransport::dictionary_seen_(uint32_t dictionary_id) const {
return std::find(this->seen_dictionaries_.begin(), this->seen_dictionaries_.end(), dictionary_id) !=
this->seen_dictionaries_.end();
}
void EntityTransport::receive_data_(std::span<const uint8_t> packet, uint32_t dictionary_id) {
bool known = false;
bool has_remotes = false;
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (const auto &remote : this->remote_sensors_) {
known |= remote.known && remote.dictionary_id == dictionary_id;
has_remotes = true;
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (const auto &remote : this->remote_binary_sensors_) {
known |= remote.known && remote.dictionary_id == dictionary_id;
has_remotes = true;
}
#endif
if (!has_remotes)
return;
// Checked first, so a damaged packet does not make the provider resend everything
if (!data_well_formed(packet)) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Data packet of %zu bytes is malformed", packet.size());
}
return;
}
// A dictionary not yet received in full may name our entities, or be a provider's new one; one received in full
// named all of them it could, so asking again would only make that provider resend everything
if (!this->dictionary_seen_(dictionary_id))
this->request_dictionary_(dictionary_id);
if (!known)
return;
[[maybe_unused]] uint32_t now = App.get_loop_component_start_time();
for (size_t pos = HEADER_SIZE; pos != packet.size(); pos += record_size(packet, pos)) {
[[maybe_unused]] Record record = parse_record(packet, pos);
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
if (record.type == TYPE_SENSOR) {
float value = NAN;
if (record.payload == PAYLOAD_WORD) {
uint32_t bits = encode_uint32(record.data[3], record.data[2], record.data[1], record.data[0]);
memcpy(&value, &bits, sizeof(value));
}
for (auto &remote : this->remote_sensors_) {
if (remote.known && remote.dictionary_id == dictionary_id && remote.number == record.number) {
remote.last_state_ms = now;
remote.has_state = true;
remote.entity->publish_state(value);
}
}
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
if (record.type == TYPE_BINARY_SENSOR) {
for (auto &remote : this->remote_binary_sensors_) {
if (!remote.known || remote.dictionary_id != dictionary_id || remote.number != record.number)
continue;
remote.last_state_ms = now;
remote.has_state = true;
if (record.payload == PAYLOAD_NO_STATE) {
remote.entity->invalidate_state();
} else {
remote.entity->publish_state(record.value);
}
}
}
#endif
}
}
void EntityTransport::request_dictionary_(uint32_t dictionary_id) {
uint32_t now = App.get_loop_component_start_time();
if (this->request_sent_ && now - this->last_request_ms_ < REQUEST_INTERVAL_MS)
return;
uint8_t packet[HEADER_SIZE] = {PACKET_REQUEST};
put_uint32(packet + 1, dictionary_id);
// A refused request is tried again with the next data packet under that dictionary id
if (!this->interface_->send_to_interface(PacketBuffer(packet, sizeof(packet)), {})) {
ESP_LOGV(TAG, "Request for dictionary %08" PRIX32 " not accepted", dictionary_id);
return;
}
this->request_sent_ = true;
this->last_request_ms_ = now;
ESP_LOGD(TAG, "Requested dictionary %08" PRIX32, dictionary_id);
}
// Dropped packets point to a device with other firmware or a fault, and may arrive steadily, so warn only now and then
bool EntityTransport::warning_due_() {
uint32_t now = App.get_loop_component_start_time();
if (this->warned_ && now - this->last_warning_ms_ < WARNING_INTERVAL_MS)
return false;
this->warned_ = true;
this->last_warning_ms_ = now;
return true;
}
// A newly connected or restarted peer needs the dictionary and every state; while it is lost, its entities have no
// state
void EntityTransport::on_session_(bool connected) {
if (connected) {
this->send_all_();
return;
}
this->clear_remote_states_(false);
}
// Clears the state of every remote entity, or with timed_out_only, of those whose state is older than the timeout
void EntityTransport::clear_remote_states_(bool timed_out_only) {
[[maybe_unused]] uint32_t now = App.get_loop_component_start_time();
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (auto &remote : this->remote_sensors_) {
if (timed_out_only && (!remote.has_state || now - remote.last_state_ms <= this->remote_timeout_ms_))
continue;
remote.has_state = false;
remote.entity->publish_state(NAN);
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (auto &remote : this->remote_binary_sensors_) {
if (timed_out_only && (!remote.has_state || now - remote.last_state_ms <= this->remote_timeout_ms_))
continue;
remote.has_state = false;
remote.entity->invalidate_state();
}
#endif
}
} // namespace esphome::entity_transport
@@ -1,171 +0,0 @@
#pragma once
#include <array>
#include <span>
#include "esphome/components/packet_interface/packet_interface.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/helpers.h"
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::entity_transport {
/**
* Sends the states of local sensors and binary sensors over a packet interface, and publishes the states received
* from other devices to remote entities.
*
* Each device numbers its sensors and its binary sensors from 0, and sends a dictionary that gives the id for each
* number. Data packets then refer to an entity by its number. Every packet carries the sender's dictionary id, a hash
* of its device name and dictionary, so that a receiver knows which device sent it and which dictionary applies.
* All values are little endian.
*
* Dictionary packet:
* - type: 1 byte, PACKET_DICTIONARY;
* - dictionary id: 4 bytes;
* - part: 1 byte, the packet's index in the dictionary in bits 0-6, counting from 0 and wrapping, and bit 7 set on
* the last packet;
* - sender's device name: length (1 byte), then the name;
* - one or more sections, each: entity type (1 byte), first number (1 byte), count (1 byte), then for each entity
* its id: length (1 byte), then the id.
*
* Data packet:
* - type: 1 byte, PACKET_DATA;
* - dictionary id: 4 bytes;
* - records, to the end, each a 16-bit tag, then its payload. The tag holds the entity's number in bits 0-7, its type
* in bits 8-12, and the payload form in bits 13-14: none, with the state in bit 15 (binary sensors); 4 bytes (a
* sensor's float); a length byte, then that many bytes; or no state, with no payload. The form gives the record's
* size, so a receiver skips records of types it does not know.
*
* Every packet is checked in full before anything in it is used, so a damaged or forged packet changes no state.
*
* Dictionary request packet, sent by a device that receives data with a dictionary id it has not received in full,
* with every part in order:
* - type: 1 byte, PACKET_REQUEST;
* - the dictionary id wanted: 4 bytes.
*
* Changed states are sent from loop(). The dictionary and all states are sent at boot, when a session layer reports
* the peer connected, when the dictionary is requested, and every update interval. Unless the interface has both a
* session and a reliability layer, the update interval defaults to DEFAULT_UPDATE_INTERVAL_MS. When the interface
* refuses a packet, sending is tried again after a delay, and a warning is set once it has refused every packet for a
* minute. With a remote timeout, a remote entity loses its state when
* none arrives for it within the timeout.
*/
class EntityTransport : public Component {
public:
explicit EntityTransport(packet_interface::PacketInterface *interface) : interface_(interface) {}
void setup() override;
void loop() override;
void dump_config() override;
/// 0 chooses the default for the interface's layers; SCHEDULER_DONT_RUN never resends.
void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; }
/// Remote entities lose their state when none arrives for this long; 0 keeps the last state.
void set_remote_timeout(uint32_t timeout_ms) { this->remote_timeout_ms_ = timeout_ms; }
void set_entity_count(size_t count) { this->entities_.init(count); }
// Sensors must be added before binary sensors, so that data packets hold sensor records first
#ifdef USE_SENSOR
void add_sensor(const char *id, sensor::Sensor *sensor) {
this->entities_.push_back({sensor, id, this, this->sensor_count_++, true, true});
}
#endif
#ifdef USE_BINARY_SENSOR
void add_binary_sensor(const char *id, binary_sensor::BinarySensor *sensor) {
this->entities_.push_back({sensor, id, this, this->binary_sensor_count_++, false, true});
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
void add_remote_sensor(const char *provider, const char *remote_id, sensor::Sensor *sensor) {
this->remote_sensors_.push_back({provider, remote_id, sensor});
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
void add_remote_binary_sensor(const char *provider, const char *remote_id, binary_sensor::BinarySensor *sensor) {
this->remote_binary_sensors_.push_back({provider, remote_id, sensor});
}
#endif
protected:
struct LocalEntity {
EntityBase *entity; // a sensor::Sensor or a binary_sensor::BinarySensor, as is_sensor says
const char *id;
EntityTransport *parent;
uint8_t number;
bool is_sensor;
bool updated;
};
template<typename T> struct RemoteEntity {
const char *provider;
const char *remote_id;
T *entity;
uint32_t dictionary_id{0}; // learnt from the provider's dictionary
uint32_t last_state_ms{0};
uint8_t number{0};
bool known{false};
bool has_state{false}; // a state arrived, and has not yet timed out
};
void send_all_();
void mark_updated_(LocalEntity &entity);
bool send_dictionary_();
bool send_updated_();
size_t write_record_(const LocalEntity &entity, uint8_t *record) const;
bool flush_(size_t length, size_t first, size_t last);
size_t write_header_(uint8_t type) const;
void receive_(const packet_interface::PacketBuffer &data);
void receive_dictionary_(std::span<const uint8_t> packet, uint32_t dictionary_id);
void receive_data_(std::span<const uint8_t> packet, uint32_t dictionary_id);
void learn_(const char *provider, size_t provider_length, uint8_t type, uint8_t number, const char *id,
size_t id_length, uint32_t dictionary_id);
void request_dictionary_(uint32_t dictionary_id);
bool dictionary_seen_(uint32_t dictionary_id) const;
/// Clears each remote entity of \p provider that its complete dictionary does not name, warning if the entity was
/// known or the dictionary is \p first_time seen, so a resent dictionary does not repeat the warning.
void forget_unnamed_(const char *provider, size_t provider_length, uint32_t dictionary_id, bool first_time);
bool warning_due_();
void sending_refused_();
void on_session_(bool connected);
void clear_remote_states_(bool timed_out_only);
packet_interface::PacketInterface *interface_;
FixedVector<LocalEntity> entities_;
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
StaticVector<RemoteEntity<sensor::Sensor>, ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT> remote_sensors_;
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
StaticVector<RemoteEntity<binary_sensor::BinarySensor>, ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT>
remote_binary_sensors_;
#endif
// Packets are built here, allocated in setup()
uint8_t *buffer_{nullptr};
size_t max_packet_size_{0};
uint32_t update_interval_ms_{0};
uint32_t remote_timeout_ms_{0};
uint32_t dictionary_id_{0};
uint32_t last_request_ms_{0};
uint32_t last_warning_ms_{0};
uint32_t refused_since_ms_{0}; // when the interface began refusing every packet
uint32_t rx_dictionary_id_{0}; // the dictionary whose parts are arriving
// The last dictionary ids received in full, so a provider whose dictionary names none of our entities is not asked
// again
std::array<uint32_t, 8> seen_dictionaries_{};
uint8_t seen_next_{0};
uint8_t rx_next_part_{0}; // the index of its next part, or PART_NONE if one was missed
uint8_t dictionary_part_{0}; // the index of the next dictionary packet to send
uint8_t sensor_count_{0};
uint8_t binary_sensor_count_{0};
size_t dictionary_next_{0}; // the first entity of the next dictionary packet to send
bool dictionary_pending_{true};
bool request_sent_{false};
bool warned_{false};
bool refusing_{false};
};
} // namespace esphome::entity_transport
@@ -1,11 +0,0 @@
from esphome.components import sensor
from esphome.types import ConfigType
from . import register_remote_sensor, remote_entity_schema
CONFIG_SCHEMA = remote_entity_schema(sensor.sensor_schema())
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await register_remote_sensor(config, var)
-16
View File
@@ -2213,22 +2213,6 @@ def idf_version() -> cv.Version:
return CORE.data[KEY_ESP32][KEY_IDF_VERSION]
def require_idf_version(
minimum: cv.Version, feature: str = "This feature"
) -> Callable[[Any], Any]:
"""Validator for an option that needs the underlying ESP-IDF at ``minimum`` or newer."""
def validator(value: Any) -> Any:
cv.only_on_esp32(value)
if (version := idf_version()) < minimum:
raise cv.Invalid(
f"{feature} requires ESP-IDF {minimum} or newer, got {version}"
)
return value
return validator
def require_fatfs() -> None:
"""Mark that FATFS support is required by a component.
@@ -2,7 +2,6 @@ import hashlib
import logging
from pathlib import Path
from tempfile import TemporaryDirectory
import time
from typing import Any
import voluptuous as vol
@@ -84,11 +83,6 @@ CONFIG_SCHEMA = cv.All(
)
# The other build's rename finishes within milliseconds; a few short checks cover it
CACHE_RACE_RETRIES = 5
CACHE_RACE_RETRY_S = 0.05
def _check_firmware_hash(data: bytes, expected: str, source: str | Path) -> None:
calculated = hashlib.sha256(data).hexdigest()
if calculated != expected:
@@ -97,14 +91,6 @@ def _check_firmware_hash(data: bytes, expected: str, source: str | Path) -> None
)
def _cached_image_digest(target: Path) -> str | None:
"""The SHA256 of the cached image, or None while it is missing or another build still writes it."""
try:
return hashlib.sha256(target.read_bytes()).hexdigest()
except OSError:
return None
def _firmware_path(config: ConfigType) -> Path:
"""The configured image file, or the cache slot of the pinned digest for a downloaded one."""
if CONF_URL in config:
@@ -123,9 +109,10 @@ def _validate_firmware(config: ConfigType) -> None:
_check_firmware_hash(target.read_bytes(), expected, target)
return
# A pinned digest needs no remote freshness check.
if (actual := _cached_image_digest(target)) == expected:
return
if actual is not None:
if target.is_file():
actual = hashlib.sha256(target.read_bytes()).hexdigest()
if actual == expected:
return
# Named after its digest, so a mismatch means the file was damaged or edited
_LOGGER.warning(
"Cached firmware %s hashes to %s, downloading it again", target, actual
@@ -136,20 +123,10 @@ def _validate_firmware(config: ConfigType) -> None:
candidate = Path(temporary) / "firmware.bin"
data = external_files.download_content(url, candidate, allow_stale=False)
_check_firmware_hash(data, expected, url)
for attempt in range(CACHE_RACE_RETRIES + 1):
try:
candidate.replace(target)
return
except OSError as err:
# Windows refuses to replace a file another build holds at that moment; retry, or
# take that build's copy: named after its digest, a verified one is the same image
if _cached_image_digest(target) == expected:
return
if attempt == CACHE_RACE_RETRIES:
raise cv.Invalid(
f"Could not cache the firmware from {url}: {err}"
) from err
time.sleep(CACHE_RACE_RETRY_S)
try:
candidate.replace(target)
except OSError as err:
raise cv.Invalid(f"Could not cache the firmware from {url}: {err}") from err
FINAL_VALIDATE_SCHEMA = _validate_firmware
@@ -24,7 +24,7 @@ ESPHOME_LOG_TAG(TAG, "espnow");
ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
const LogString *espnow_error_to_str(esp_err_t error) {
static const LogString *espnow_error_to_str(esp_err_t error) {
switch (error) {
case ESP_ERR_ESPNOW_FAILED:
return LOG_STR("ESPNow is in fail mode");
@@ -25,9 +25,6 @@ namespace esphome::espnow {
using peer_address_t = std::array<uint8_t, ESP_NOW_ETH_ALEN>;
/// A short description of an ESP-NOW error code, for logging.
const LogString *espnow_error_to_str(esp_err_t error);
enum class ESPNowTriggers : uint8_t {
TRIGGER_NONE = 0,
ON_NEW_PEER = 1,
@@ -126,7 +123,6 @@ class ESPNowComponent final : public Component {
uint8_t get_wifi_channel();
void set_auto_add_peer(bool value) { this->auto_add_peer_ = value; }
bool get_auto_add_peer() const { return this->auto_add_peer_; }
#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS)
// WiFiConnectStateListener interface: refresh the cached channel after each (re)connect
@@ -1,90 +0,0 @@
import esphome.codegen as cg
from esphome.components import packet_interface
from esphome.components.packet_interface import (
CONF_MAX_PACKET_SIZE,
new_packet_interface,
packet_interface_schema,
)
from esphome.components.packet_interface.layers import (
CONF_ADDRESSING,
check_broadcast_link,
)
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core import HexInt
import esphome.final_validate as fv
from esphome.types import ConfigType
from .. import (
CONF_MAX_PAYLOAD_SIZE,
DOMAIN as ESPNOW_DOMAIN,
ESPNowComponent,
espnow_ns,
)
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["espnow"]
CONF_ESPNOW_ID = "espnow_id"
CONF_PEER_ADDRESS = "peer_address"
# The ESP-NOW v1 payload; a larger max_payload_size on the espnow component allows a larger max_packet_size
MAX_PACKET_SIZE = 250
BROADCAST_MAC = "FF:FF:FF:FF:FF:FF"
ESPNowPacketInterface = espnow_ns.class_(
"ESPNowPacketInterface", packet_interface.PacketInterface
)
def _is_broadcast(config: ConfigType) -> bool:
return str(config[CONF_PEER_ADDRESS]) == BROADCAST_MAC
def _validate_addressing(config: ConfigType) -> ConfigType:
if _is_broadcast(config):
check_broadcast_link(config)
elif CONF_ADDRESSING in config:
raise cv.Invalid(
f"Packets to one '{CONF_PEER_ADDRESS}' are already addressed, so '{CONF_ADDRESSING}' is only "
"for broadcast",
path=[CONF_ADDRESSING],
)
return config
CONFIG_SCHEMA = cv.All(
packet_interface_schema(ESPNowPacketInterface, MAX_PACKET_SIZE, 1470).extend(
{
cv.GenerateID(CONF_ESPNOW_ID): cv.use_id(ESPNowComponent),
cv.Optional(CONF_PEER_ADDRESS, default=BROADCAST_MAC): cv.mac_address,
}
),
_validate_addressing,
)
def _validate_packet_size(config: ConfigType) -> ConfigType:
payload = fv.full_config.get()[ESPNOW_DOMAIN][CONF_MAX_PAYLOAD_SIZE]
if config[CONF_MAX_PACKET_SIZE] > payload:
raise cv.Invalid(
f"Must not be larger than the espnow {CONF_MAX_PAYLOAD_SIZE} of {payload}",
path=[CONF_MAX_PACKET_SIZE],
)
return config
FINAL_VALIDATE_SCHEMA = _validate_packet_size
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_ESPNOW_ID])
peer = [HexInt(x) for x in config[CONF_PEER_ADDRESS].parts]
if not _is_broadcast(config):
cg.add(parent.add_peer(peer))
# The copy buffer's size is known here, so it is a static array rather than allocated at run time
tx_buffer = f"{config[CONF_ID].id}__tx_buffer"
cg.add_global(
cg.RawStatement(f"static uint8_t {tx_buffer}[{config[CONF_MAX_PACKET_SIZE]}];")
)
await new_packet_interface(config, parent, cg.RawExpression(tx_buffer), peer)
@@ -1,125 +0,0 @@
#include "espnow_packet_interface.h"
#if defined(USE_ESP32) || defined(USE_ESP8266)
#include <cstring>
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::espnow {
using packet_interface::PacketBuffer;
using packet_interface::PacketMetaData;
ESPHOME_LOG_TAG(TAG, "espnow.packet_interface");
ESPNowPacketInterface::ESPNowPacketInterface(ESPNowComponent *parent, std::span<uint8_t> tx_buffer,
peer_address_t peer_address)
: packet_interface::PacketInterface(tx_buffer.size()),
parent_(parent),
peer_address_(peer_address),
tx_buffer_(tx_buffer) {
if (!this->is_broadcast_())
this->capabilities_ = packet_interface::CAPABILITY_ADDRESSING;
}
void ESPNowPacketInterface::setup() {
// Each mode hears only its own kind of packet, so a peer link and a broadcast link can share the component
if (!this->is_broadcast_() || !ESPNOW_REPORTS_DESTINATION)
this->parent_->register_receive_handler(this);
if (this->is_broadcast_()) {
this->parent_->register_broadcast_handler(this);
this->parent_->register_unknown_peer_handler(this);
}
}
void ESPNowPacketInterface::dump_config() {
char peer_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(this->peer_address_.data(), peer_buf);
ESP_LOGCONFIG(TAG,
"ESP-NOW Packet Interface '%s':\n"
" Peer: %s\n"
" Max Packet Size: %zu",
this->name_, peer_buf, this->max_packet_size_);
}
bool ESPNowPacketInterface::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
// Layers above are set up first, and may send while setup waits for another component
if (!this->is_ready())
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;
}
const uint8_t *payload;
if (data.is_contiguous()) {
payload = data.as_span().data();
} else {
data.copy_to(this->tx_buffer_.data(), size);
payload = this->tx_buffer_.data();
}
esp_err_t err = this->parent_->send(this->peer_address_.data(), payload, size);
if (err != ESP_OK) {
this->send_failed_(err);
return false;
}
return true;
}
// A full send queue is logged by the espnow component, and ESP-NOW is not running before setup or while disabled.
// Other errors, such as a peer that could not be added, repeat on every send, so warn now and then
void ESPNowPacketInterface::send_failed_(esp_err_t err) {
static constexpr uint32_t WARNING_INTERVAL_MS = 60000;
uint32_t now = App.get_loop_component_start_time();
if (err == ESP_ERR_ESPNOW_NO_MEM || err == ESP_ERR_ESPNOW_NOT_INIT ||
(this->send_warned_ && now - this->last_send_warning_ms_ < WARNING_INTERVAL_MS)) {
ESP_LOGV(TAG, "%s: Send failed: %s", this->name_, LOG_STR_ARG(espnow_error_to_str(err)));
return;
}
this->send_warned_ = true;
this->last_send_warning_ms_ = now;
ESP_LOGW(TAG, "%s: Send failed: %s", this->name_, LOG_STR_ARG(espnow_error_to_str(err)));
}
bool ESPNowPacketInterface::on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) {
this->receive_(info, data, size);
return false;
}
bool ESPNowPacketInterface::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) {
this->receive_(info, data, size);
return false;
}
bool ESPNowPacketInterface::on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) {
// Only known peers reach the other handlers; with auto_add_peer this sender is added and arrives there
if (this->parent_->get_auto_add_peer())
return false;
if (!ESPNOW_REPORTS_DESTINATION || memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0)
this->receive_(info, data, size);
return false;
}
void ESPNowPacketInterface::receive_(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) {
if (size == 0)
return;
if (size > this->max_packet_size_) {
ESP_LOGV(TAG, "%s: Packet of %u bytes is larger than %zu, dropped", this->name_, size, this->max_packet_size_);
return;
}
if (!this->is_broadcast_() && memcmp(info.src_addr, this->peer_address_.data(), ESP_NOW_ETH_ALEN) != 0)
return;
PacketMetaData meta_data;
memcpy(meta_data.mac_address, info.src_addr, ESP_NOW_ETH_ALEN);
#ifdef USE_ESP32
meta_data.rssi = info.rx_ctrl->rssi;
#endif
this->on_receive_from_interface_(PacketBuffer(data, size), meta_data);
}
} // namespace esphome::espnow
#endif // USE_ESP32 || USE_ESP8266
@@ -1,59 +0,0 @@
#pragma once
#include "../espnow_component.h"
#if defined(USE_ESP32) || defined(USE_ESP8266)
#include <cstring>
#include <span>
#include "esphome/components/packet_interface/packet_interface.h"
namespace esphome::espnow {
/**
* A packet interface link over ESP-NOW.
*
* With a peer address other than broadcast, packets go to that device only, and only packets sent to this device by
* it are received, so the link provides the addressing capability itself. With the broadcast address, packets go to
* every device on the channel and every broadcast packet is received, from known and unknown senders; an addressing
* layer then tells the devices apart. ESP8266 does not report a packet's destination, so there a broadcast link also
* receives packets sent to this device, and a peer link also receives the peer's broadcasts.
*
* ESP-NOW queues each packet and reports later whether it was delivered; a reliability layer above resends what is
* lost.
*/
class ESPNowPacketInterface : public packet_interface::PacketInterface,
public ESPNowReceivedPacketHandler,
public ESPNowBroadcastHandler,
public ESPNowUnknownPeerHandler {
public:
/// @param tx_buffer Joins packets split across buffers; its size is the largest packet, and it must outlive the link
ESPNowPacketInterface(ESPNowComponent *parent, std::span<uint8_t> tx_buffer, peer_address_t peer_address);
void setup() override;
void dump_config() override;
bool send_to_interface(const packet_interface::PacketBuffer &data,
const packet_interface::PacketMetaData &meta_data) override;
bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override;
bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override;
bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override;
protected:
bool is_broadcast_() const {
return memcmp(this->peer_address_.data(), ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0;
}
void receive_(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size);
void send_failed_(esp_err_t err);
ESPNowComponent *parent_;
peer_address_t peer_address_;
const std::span<uint8_t> tx_buffer_;
uint32_t last_send_warning_ms_{0};
bool send_warned_{false};
};
} // namespace esphome::espnow
#endif // USE_ESP32 || USE_ESP8266
+5 -4
View File
@@ -362,7 +362,7 @@ def _validate(config: ConfigType) -> ConfigType:
from esphome.components.esp32 import (
VARIANT_ESP32S31,
get_esp32_variant,
require_idf_version,
idf_version,
)
eth_type = config[CONF_TYPE]
@@ -372,9 +372,10 @@ def _validate(config: ConfigType) -> ConfigType:
f"The '{eth_type}' (RGMII) PHY is only supported on gigabit-capable "
f"variants (ESP32-S31), not {variant}"
)
require_idf_version(cv.Version(6, 0, 0), f"The '{eth_type}' (RGMII) PHY")(
config
)
if idf_version() < cv.Version(6, 0, 0):
raise cv.Invalid(
f"The '{eth_type}' (RGMII) PHY requires ESP-IDF 6.0 or newer."
)
elif config[CONF_TYPE] != "OPENETH":
from esphome.components.esp32 import (
VARIANT_ESP32,
+10 -12
View File
@@ -45,15 +45,14 @@ void HmacSHA256::get_hex(char *output) {
}
bool HmacSHA256::equals_bytes(const uint8_t *expected) {
return constant_time_equals(this->digest_, expected, SHA256_DIGEST_SIZE);
return memcmp(this->digest_, expected, SHA256_DIGEST_SIZE) == 0;
}
bool HmacSHA256::equals_hex(const char *expected) {
uint8_t parsed[SHA256_DIGEST_SIZE];
if (parse_hex(expected, SHA256_DIGEST_SIZE * 2, parsed, SHA256_DIGEST_SIZE) != SHA256_DIGEST_SIZE * 2) {
return false;
}
return this->equals_bytes(parsed);
char hex_output[SHA256_DIGEST_SIZE * 2 + 1];
this->get_hex(hex_output);
hex_output[SHA256_DIGEST_SIZE * 2] = '\0';
return strncmp(hex_output, expected, SHA256_DIGEST_SIZE * 2) == 0;
}
#elif defined(USE_HMAC_SHA256_MBEDTLS)
@@ -78,15 +77,14 @@ void HmacSHA256::get_hex(char *output) {
}
bool HmacSHA256::equals_bytes(const uint8_t *expected) {
return constant_time_equals(this->digest_, expected, SHA256_DIGEST_SIZE);
return memcmp(this->digest_, expected, SHA256_DIGEST_SIZE) == 0;
}
bool HmacSHA256::equals_hex(const char *expected) {
uint8_t parsed[SHA256_DIGEST_SIZE];
if (parse_hex(expected, SHA256_DIGEST_SIZE * 2, parsed, SHA256_DIGEST_SIZE) != SHA256_DIGEST_SIZE * 2) {
return false;
}
return this->equals_bytes(parsed);
char hex_output[SHA256_DIGEST_SIZE * 2 + 1];
this->get_hex(hex_output);
hex_output[SHA256_DIGEST_SIZE * 2] = '\0';
return strncmp(hex_output, expected, SHA256_DIGEST_SIZE * 2) == 0;
}
#else
@@ -1 +0,0 @@
DOMAIN = "islandaire"
-27
View File
@@ -1,27 +0,0 @@
import esphome.codegen as cg
from esphome.components import climate_ir
from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL
import esphome.config_validation as cv
from esphome.const import CONF_SENSOR
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
CODEOWNERS = ["@ipopov"]
islandaire_ns = cg.esphome_ns.namespace("islandaire")
IslandaireClimate = islandaire_ns.class_("IslandaireClimate", climate_ir.ClimateIR)
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(IslandaireClimate).extend(
{
# The unit has no auto (heat/cool) mode
cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false,
# The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it
cv.Optional(CONF_SENSOR): cv.invalid(
"'sensor' is not supported by the Islandaire climate component"
),
}
)
async def to_code(config: ConfigType) -> None:
await climate_ir.new_climate_ir(config)
@@ -1,199 +0,0 @@
#include "islandaire.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
namespace esphome::islandaire {
ESPHOME_LOG_TAG(TAG, "islandaire.climate");
// 14-byte frame, LSB first:
// Bytes 0-4: 0x22 preamble
// Byte 5: power (0x20 off, 0x24 on); bit 3 (0x08) set while the auto-off timer is active
// Byte 6: mode (0x01 heat, 0x03 cool, 0x04 fan only)
// Byte 7: target temperature in °F (61-88)
// Byte 8: fan speed (0x00 auto, 0x02 low, 0x04 high, 0x01 read as low too); bit 6 (0x40) set while the
// timer is active
// Byte 9: 0x00
// Byte 10: auto-off timer in hours (1-12, 0 = off)
// Bytes 11-12: 0x00
// Byte 13: sum of bytes 0-12
// The auto-off timer is not implemented: the unit switches itself off with no feedback, which
// would leave the climate state stale.
static constexpr uint8_t STATE_LENGTH = 14;
static constexpr uint8_t PREAMBLE_LENGTH = 5;
static constexpr uint8_t PREAMBLE = 0x22;
static constexpr uint8_t POWER_OFF = 0x20;
static constexpr uint8_t POWER_MASK = 0x04;
static constexpr uint8_t POWER_ON = POWER_OFF | POWER_MASK;
static constexpr uint8_t MODE_HEAT = 0x01;
static constexpr uint8_t MODE_COOL = 0x03;
static constexpr uint8_t MODE_FAN = 0x04;
static constexpr uint8_t FAN_AUTO = 0x00;
static constexpr uint8_t FAN_LOW_ALT = 0x01;
static constexpr uint8_t FAN_LOW = 0x02;
static constexpr uint8_t FAN_HIGH = 0x04;
static constexpr uint8_t FAN_TIMER_FLAG = 0x40;
static constexpr uint8_t TEMP_DEFAULT = 72;
static constexpr uint16_t HEADER_MARK = 3608;
static constexpr uint16_t HEADER_SPACE = 1520;
static constexpr uint16_t BIT_MARK = 560;
static constexpr uint16_t ONE_SPACE = 1208;
static constexpr uint16_t ZERO_SPACE = 408;
static constexpr uint32_t CARRIER_FREQUENCY = 38000;
static constexpr remote_base::BitTimings BIT_TIMINGS{
.bit_mark = BIT_MARK, .one_space = ONE_SPACE, .zero_space = ZERO_SPACE};
void IslandaireClimate::setup() {
ClimateIR::setup();
// The base class default assumes Celsius
if (std::isnan(this->target_temperature) || this->target_temperature < TEMP_MIN)
this->target_temperature = TEMP_DEFAULT;
this->target_temperature = std::min<float>(this->target_temperature, TEMP_MAX);
}
void IslandaireClimate::dump_config() {
// ClimateIR::dump_config() is not called: it prints the temperature range in Celsius
LOG_CLIMATE("", "Islandaire Climate", this);
ESP_LOGCONFIG(TAG, " Temperature range: %u to %u °F", TEMP_MIN, TEMP_MAX);
}
climate::ClimateTraits IslandaireClimate::traits() {
auto traits = ClimateIR::traits();
traits.set_temperature_unit(TemperatureUnit::FAHRENHEIT);
return traits;
}
void IslandaireClimate::transmit_state() {
uint8_t remote_state[STATE_LENGTH] = {0};
std::fill_n(remote_state, PREAMBLE_LENGTH, PREAMBLE);
remote_state[5] = this->mode == climate::CLIMATE_MODE_OFF ? POWER_OFF : POWER_ON;
switch (this->mode) {
case climate::CLIMATE_MODE_HEAT:
remote_state[6] = MODE_HEAT;
break;
case climate::CLIMATE_MODE_FAN_ONLY:
remote_state[6] = MODE_FAN;
break;
case climate::CLIMATE_MODE_COOL:
default:
remote_state[6] = MODE_COOL;
break;
}
// Whole degrees within the unit's range, stored as well so the published state matches what was sent
const auto temperature =
static_cast<uint8_t>(std::clamp<float>(std::round(this->target_temperature), TEMP_MIN, TEMP_MAX));
this->target_temperature = temperature;
remote_state[7] = temperature;
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) {
case climate::CLIMATE_FAN_LOW:
remote_state[8] = FAN_LOW;
break;
case climate::CLIMATE_FAN_HIGH:
remote_state[8] = FAN_HIGH;
break;
case climate::CLIMATE_FAN_AUTO:
default:
remote_state[8] = FAN_AUTO;
break;
}
remote_state[STATE_LENGTH - 1] = sum_bytes(remote_state, STATE_LENGTH - 1);
ESP_LOGV_HEX(TAG, remote_state, STATE_LENGTH, STATE_LENGTH, "Sending: %s");
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
data->set_carrier_frequency(CARRIER_FREQUENCY);
data->reserve(2 + STATE_LENGTH * 16 + 1);
data->item(HEADER_MARK, HEADER_SPACE);
data->item_bytes<BIT_TIMINGS>(remote_state, STATE_LENGTH);
data->mark(BIT_MARK);
transmit.perform();
}
bool IslandaireClimate::on_receive(remote_base::RemoteReceiveData data) {
if (!data.expect_item(HEADER_MARK, HEADER_SPACE)) {
ESP_LOGVV(TAG, "Header fail");
return false;
}
uint8_t remote_state[STATE_LENGTH];
if (!data.expect_bytes<BIT_TIMINGS>(remote_state, STATE_LENGTH)) {
ESP_LOGVV(TAG, "Data fail");
return false;
}
if (!data.expect_mark(BIT_MARK)) {
ESP_LOGVV(TAG, "Footer fail");
return false;
}
if (sum_bytes(remote_state, STATE_LENGTH - 1) != remote_state[STATE_LENGTH - 1]) {
ESP_LOGVV(TAG, "Checksum fail");
return false;
}
for (uint8_t i = 0; i < PREAMBLE_LENGTH; i++) {
if (remote_state[i] != PREAMBLE)
return false;
}
ESP_LOGV_HEX(TAG, remote_state, STATE_LENGTH, STATE_LENGTH, "Received: %s");
if ((remote_state[5] & POWER_MASK) == 0) {
this->mode = climate::CLIMATE_MODE_OFF;
} else {
switch (remote_state[6]) {
case MODE_HEAT:
this->mode = climate::CLIMATE_MODE_HEAT;
break;
case MODE_COOL:
this->mode = climate::CLIMATE_MODE_COOL;
break;
case MODE_FAN:
this->mode = climate::CLIMATE_MODE_FAN_ONLY;
break;
default:
ESP_LOGV(TAG, "Unknown mode 0x%02X", remote_state[6]);
return false;
}
}
const uint8_t temp_f = remote_state[7];
if (temp_f >= TEMP_MIN && temp_f <= TEMP_MAX)
this->target_temperature = temp_f;
switch (remote_state[8] & ~FAN_TIMER_FLAG) {
case FAN_HIGH:
this->fan_mode = climate::CLIMATE_FAN_HIGH;
break;
case FAN_LOW:
case FAN_LOW_ALT:
this->fan_mode = climate::CLIMATE_FAN_LOW;
break;
case FAN_AUTO:
default:
this->fan_mode = climate::CLIMATE_FAN_AUTO;
break;
}
this->publish_state();
return true;
}
} // namespace esphome::islandaire
@@ -1,25 +0,0 @@
#pragma once
#include "esphome/components/climate_ir/climate_ir.h"
namespace esphome::islandaire {
class IslandaireClimate : public climate_ir::ClimateIR {
public:
// The unit works in whole degrees Fahrenheit (see traits())
static constexpr uint8_t TEMP_MIN = 61;
static constexpr uint8_t TEMP_MAX = 88;
IslandaireClimate()
: ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, false, true,
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_HIGH}, {}) {}
protected:
void setup() override;
void dump_config() override;
climate::ClimateTraits traits() override;
void transmit_state() override;
bool on_receive(remote_base::RemoteReceiveData data) override;
};
} // namespace esphome::islandaire
+15 -6
View File
@@ -285,8 +285,12 @@ void LD6002BComponent::setup() {
#ifdef USE_SWITCH
if (this->target_display_switch_ != nullptr) {
target_display_controlled = true;
// Nothing reports this switch back, so its restored state is the only state there is.
this->target_display_switch_->restore(true);
// Nothing reports this switch back, so its restored state is the only state
// there is. Restoring through the switch keeps its inversion in the path:
// the restored value is logical, and driving the switch is what turns it
// into the raw command, the published state and the stream flag.
const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true);
this->target_display_switch_->control(state);
}
#endif
if (!target_display_controlled) {
@@ -303,7 +307,10 @@ void LD6002BComponent::setup() {
#ifdef USE_SWITCH
if (this->point_cloud_switch_ != nullptr) {
point_cloud_controlled = true;
this->point_cloud_switch_->restore(false);
// The switch owns the stream, so it is also what applies the restored state:
// driving it rather than the module keeps the entity's inversion in the path.
const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false);
this->point_cloud_switch_->control(state);
}
#endif
if (!point_cloud_controlled) {
@@ -342,9 +349,11 @@ void LD6002BComponent::setup() {
#ifdef USE_SWITCH
bool want_low_power = this->low_power_switch_ != nullptr;
if (want_low_power) {
// The query below confirms what the module took; until it lands, the work mode
// fallback runs on the restored value.
this->low_power_switch_->restore(false);
// The module reports this one back, so the query below confirms what it took.
// Driving the switch applies its inversion; it also marks the restored value
// as reported, so the work mode fallback runs on that until the query lands.
const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false);
this->low_power_switch_->control(state);
}
#else
bool want_low_power = false;
+8 -81
View File
@@ -13,7 +13,6 @@ from esphome.const import (
CONF_COLD_WHITE_COLOR_TEMPERATURE,
CONF_COLOR_CORRECT,
CONF_DEFAULT_TRANSITION_LENGTH,
CONF_EFFECT,
CONF_EFFECTS,
CONF_ENTITY_CATEGORY,
CONF_FLASH_TRANSITION_LENGTH,
@@ -32,21 +31,12 @@ from esphome.const import (
CONF_RESTORE_MODE,
CONF_RESTORE_STATE,
CONF_RGB_ORDER,
CONF_STATE,
CONF_TRIGGER_ID,
CONF_WARM_WHITE_COLOR_TEMPERATURE,
CONF_WEB_SERVER,
)
from esphome.core import (
CORE,
ID,
CoroPriority,
EsphomeError,
HexInt,
coroutine_with_priority,
)
from esphome.core import CORE, ID, CoroPriority, HexInt, coroutine_with_priority
from esphome.core.entity_helpers import (
RESTORE_STATE_NONE,
entity_duplicate_validator,
queue_entity_register,
setup_entity,
@@ -66,6 +56,7 @@ from .effects import (
)
from .restore_state import (
LEGACY_RESTORE_MODES,
RESTORE_STATE_NONE,
RESTORE_STATE_SCHEMA,
_build_state_lambda,
_initial_state_overridden_by_legacy_mode,
@@ -200,11 +191,9 @@ def _get_or_create_gamma_table(gamma_correct: float, table_id: ID) -> cg.RawExpr
def find_effect_index(effects: list, effect_name: str) -> int | None:
"""Find the 1-based index of an effect by name (case-insensitive).
Returns 0 for "None" (no effect), the 1-based index if found, or None if not found.
Returns the 1-based index if found, or None if not found.
"""
effect_name_lower = effect_name.lower()
if effect_name_lower == "none":
return 0
for i, effect_conf in enumerate(effects):
key = next(iter(effect_conf))
if effect_conf[key][CONF_NAME].lower() == effect_name_lower:
@@ -220,14 +209,6 @@ def available_effects_str(effects: list) -> str:
return ", ".join(f"'{name}'" for name in available) if available else "none"
def effect_not_found_message(effect_name: str, effects: list, light: str) -> str:
"""Return the error for an effect name that `light` (e.g. "this light") lacks."""
return (
f"Effect '{effect_name}' not found for {light}. "
f"Available effects: {available_effects_str(effects)}"
)
# Accepted values of the deprecated `rgb_order` key.
RGB_ORDERS = ("RGB", "RBG", "GRB", "GBR", "BGR", "BRG")
@@ -335,12 +316,9 @@ def _final_validate(config: ConfigType) -> None:
platform entries across the file, not a single light's own config.
"""
for light_config in config:
initial_state = light_config.get(CONF_INITIAL_STATE) or {}
cold_boot_state = initial_state.get(CONF_STATE, False)
restore_mode = light_config.get(CONF_RESTORE_MODE)
if restore_mode is not None:
legacy = LEGACY_RESTORE_MODES[restore_mode]
cold_boot_state = legacy.cold_boot_state
if _initial_state_overridden_by_legacy_mode(
legacy, light_config.get(CONF_INITIAL_STATE)
):
@@ -352,19 +330,6 @@ def _final_validate(config: ConfigType) -> None:
restore_mode,
"ON" if legacy.cold_boot_state else "OFF",
)
effect_name = initial_state.get(CONF_EFFECT, "none")
if (
effect_name.lower() != "none"
and not cold_boot_state
and not light_config.get(CONF_RESUME_EFFECT)
):
_LOGGER.warning(
"[%s] 'initial_state: effect: %s' does not start at boot because the "
"light boots OFF; add 'resume_effect: true' to start it at the first "
"turn-on",
light_config.get(CONF_NAME) or light_config[CONF_ID],
effect_name,
)
data = _get_data()
if not data.effect_refs and not data.effect_cycle_refs:
@@ -390,9 +355,9 @@ def _final_validate(config: ConfigType) -> None:
if find_effect_index(effects, ref.effect_name) is None:
raise cv.FinalExternalInvalid(
effect_not_found_message(
ref.effect_name, effects, f"light '{ref.light_id}'"
),
f"Effect '{ref.effect_name}' not found for light "
f"'{ref.light_id}'. "
f"Available effects: {available_effects_str(effects)}",
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
)
@@ -420,13 +385,6 @@ FINAL_VALIDATE_SCHEMA = _final_validate
DEFAULT_FLASH_TRANSITION_LENGTH = "0s"
CONF_TRANSITION_STATE_PUBLISH_INTERVAL = "transition_state_publish_interval"
# `effect` stays out of LIGHT_STATE_SCHEMA: light.control defines it templatable and transformer-exclusive
INITIAL_STATE_SCHEMA = LIGHT_STATE_SCHEMA.extend(
{
cv.Optional(CONF_EFFECT): cv.string,
}
)
LIGHT_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
@@ -456,7 +414,7 @@ LIGHT_SCHEMA = (
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LightStateTrigger),
}
),
cv.Optional(CONF_INITIAL_STATE): INITIAL_STATE_SCHEMA,
cv.Optional(CONF_INITIAL_STATE): LIGHT_STATE_SCHEMA,
cv.Optional(CONF_RESUME_EFFECT, default=False): cv.boolean,
}
)
@@ -464,35 +422,6 @@ LIGHT_SCHEMA = (
LIGHT_SCHEMA.add_extra(entity_duplicate_validator("light"))
def _validate_initial_state_effect(config: ConfigType) -> ConfigType:
"""Check that `initial_state: effect` names an effect of this light."""
initial_state = config.get(CONF_INITIAL_STATE) or {}
if (effect_name := initial_state.get(CONF_EFFECT)) is not None:
effects = config.get(CONF_EFFECTS, [])
if find_effect_index(effects, effect_name) is None:
raise cv.Invalid(
effect_not_found_message(effect_name, effects, "this light"),
path=[CONF_INITIAL_STATE, CONF_EFFECT],
)
return config
LIGHT_SCHEMA.add_extra(_validate_initial_state_effect)
def _initial_effect_index(config: ConfigType) -> int:
"""Resolve `initial_state: effect` to its effect index, 0 when not set."""
effect_name = (config.get(CONF_INITIAL_STATE) or {}).get(CONF_EFFECT, "none")
effects = config.get(CONF_EFFECTS, [])
if (index := find_effect_index(effects, effect_name)) is not None:
return index
# Safety net for a light whose schema skipped _validate_initial_state_effect
raise EsphomeError(
effect_not_found_message(effect_name, effects, f"light '{config[CONF_ID]}'")
)
BINARY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend(
{
cv.Optional(CONF_EFFECTS): validate_effects(BINARY_EFFECTS),
@@ -628,9 +557,7 @@ async def setup_light_core_(light_var, config, output_var):
# ways to build the same state callback and save_enabled flag that LightState's
# runtime actually understands.
initial_state_config = config.get(CONF_INITIAL_STATE)
initial_statements = await _initial_state_statements(
initial_state_config, _initial_effect_index(config)
)
initial_statements = await _initial_state_statements(initial_state_config)
if config[CONF_RESUME_EFFECT]:
cg.add_define("USE_LIGHT_RESUME_EFFECT")
cg.add(light_var.set_resume_effect(True))
+5 -2
View File
@@ -250,8 +250,10 @@ def _resolve_effect_index(config: ConfigType, original_name: str) -> int:
Effect index 0 means "None" (no effect). Effects are 1-indexed matching
the C++ convention in LightState.
"""
from . import effect_not_found_message, find_effect_index
from . import available_effects_str, find_effect_index
if original_name.lower() == "none":
return 0
light_id = config[CONF_ID]
light_path = CORE.config.get_path_for_id(light_id)[:-1]
light_config = CORE.config.get_config_for_path(light_path)
@@ -262,7 +264,8 @@ def _resolve_effect_index(config: ConfigType, original_name: str) -> int:
# Should never reach here — effect names are validated during config
# validation in FINAL_VALIDATE_SCHEMA. This is a safety net.
raise EsphomeError(
effect_not_found_message(original_name, effects, f"light '{light_id}'")
f"Effect '{original_name}' not found for light '{light_id}'. "
f"Available effects: {available_effects_str(effects)}"
)
+1 -5
View File
@@ -83,13 +83,9 @@ void LightState::setup() {
call.set_color_temperature_if_supported(recovered.color_temp);
call.set_cold_white_if_supported(recovered.cold_white);
call.set_warm_white_if_supported(recovered.warm_white);
if (recovered.state && recovered.effect != 0) {
if (recovered.effect != 0) {
call.set_effect(recovered.effect);
} else {
#ifdef USE_LIGHT_RESUME_EFFECT
// An off light cannot start its effect now; keep it for the first turn-on instead
this->previous_effect_index_ = recovered.effect;
#endif // USE_LIGHT_RESUME_EFFECT
call.set_transition_length_if_supported(0);
}
call.perform();
+33 -34
View File
@@ -11,14 +11,6 @@ import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_COLOR_MODE, CONF_STATE
from esphome.core import Lambda
from esphome.core.entity_helpers import (
RESTORE_STATE_INITIAL,
RESTORE_STATE_INVERT,
RESTORE_STATE_KEEP,
RESTORE_STATE_NONE,
restore_state_keyword,
restore_state_schema,
)
from esphome.cpp_generator import call_lambda
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
@@ -26,6 +18,12 @@ from esphome.types import ConfigType
from .automation import LIGHT_STATE_FIELDS, validate_light_state
from .types import ColorMode, LightStateRTCState
RESTORE_STATE_KEEP = "KEEP"
RESTORE_STATE_INVERT = "INVERT"
RESTORE_STATE_INITIAL = "INITIAL"
RESTORE_STATE_ALL = "ALL"
RESTORE_STATE_NONE = "NONE"
@dataclass(frozen=True)
class LegacyRestoreMode:
@@ -58,11 +56,7 @@ _STATE_STRUCT_FIELDS: tuple[tuple[str, str], ...] = tuple(
)
# Canonical struct-member order, used only to make generated code deterministic --
# these are independent field assignments, so the actual order never affects behavior.
_MEMBER_ORDER: tuple[str, ...] = (
*(member for _, member in _ALL_STATE_FIELDS),
# Not a LightStateField: its value is a name looked up in this light's effects: list
"effect",
)
_MEMBER_ORDER: tuple[str, ...] = tuple(member for _, member in _ALL_STATE_FIELDS)
# A pending `s.<member> = <value>;` statement, tagged with the member it writes.
StateStatement = tuple[str, str]
@@ -176,13 +170,12 @@ async def _process_value(value: Any, member: str) -> str:
async def _initial_state_statements(
initial_state_config: ConfigType | None, effect_index: int = 0
initial_state_config: ConfigType | None,
) -> list[StateStatement]:
"""
Create assignments for every field the user set in `initial_state:`, in canonical
struct-member order. A field given as `!lambda` is resolved and called immediately,
the same way `light.control`'s own field lambdas are. `effect_index` is the resolved
`initial_state: effect`; 0 (no effect) is the struct default and emits nothing.
the same way `light.control`'s own field lambdas are.
"""
if not initial_state_config:
return []
@@ -195,8 +188,6 @@ async def _initial_state_statements(
)
if (inferred := _inferred_color_mode(initial_state_config)) is not None:
statements.append(("color_mode", f"s.color_mode = {inferred};"))
if effect_index:
statements.append(("effect", f"s.effect = {effect_index};"))
return statements
@@ -299,12 +290,10 @@ def _keep_or(validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
except cv.Invalid:
inner = ()
return (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL, *inner)
if (
keyword := restore_state_keyword(
value, (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL)
)
) is not None:
return keyword
if isinstance(value, str):
upper = value.strip().upper()
if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL):
return upper
return validator(value)
return validate
@@ -319,12 +308,10 @@ def _validate_restore_state_state(value: Any) -> str | bool:
RESTORE_STATE_INITIAL,
*validate_light_state(SCHEMA_EXTRACT),
)
if (
keyword := restore_state_keyword(
value, (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL)
)
) is not None:
return keyword
if isinstance(value, str):
upper = value.strip().upper()
if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL):
return upper
return validate_light_state(value)
@@ -340,7 +327,19 @@ _RESTORE_STATE_FIELDS_SCHEMA = cv.Schema(
)
# A mapping of per-field overrides, `all`, or `none`, which stays the keyword for to_code
RESTORE_STATE_SCHEMA = restore_state_schema(
_RESTORE_STATE_FIELDS_SCHEMA, RESTORE_STATE_NONE
)
@schema_extractor("schema")
def RESTORE_STATE_SCHEMA(value: Any) -> ConfigType | str:
"""
The restore_state: config key can be a mapping of per-field overrides, `all`, or `none`.
"""
if value == SCHEMA_EXTRACT:
# The `all`/`none` string shorthands have no representation in the extracted
# docs schema; only the per-field mapping form is walked here.
return _RESTORE_STATE_FIELDS_SCHEMA
if isinstance(value, str):
upper = value.strip().upper()
if upper == RESTORE_STATE_ALL:
value = {}
elif upper == RESTORE_STATE_NONE:
return RESTORE_STATE_NONE
return _RESTORE_STATE_FIELDS_SCHEMA(value)
@@ -1 +0,0 @@
DOMAIN = "ln882x_spi_led_strip"
@@ -1,210 +0,0 @@
#include "led_strip.h"
#ifdef USE_LN882X
#include <cinttypes>
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
extern "C" {
// Skip the SDK register headers: their unused *_unpack inline functions copy volatile unions, which C++ rejects.
// Same as LibreTiny's sdk_private.h; the HAL headers below use nothing from them.
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
#define __REG_DMA_H__
#define __REG_GPIO_H__
#define __REG_SPI_H__
#define __REG_SYSC_AWO_H__
#define __REG_SYSC_CMP_H__
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
#include "hal/hal_clock.h"
#include "hal/hal_dma.h"
#include "hal/hal_gpio.h"
#include "hal/hal_spi.h"
}
namespace esphome::ln882x_spi_led_strip {
ESPHOME_LOG_TAG(TAG, "ln882x_spi_led_strip");
// light.py derives the SPI encoding from this APB0 clock, which the SDK's SystemInit() sets.
static constexpr uint32_t APB0_HZ = 40000000;
// Each LED data bit is sent as one SPI byte, MSB first: the leading 1-bits are the high time.
static constexpr uint8_t SPI_BITS_PER_LED_BIT = 8;
// SPI0 TX is hard-wired to DMA channel 4 (see hal_dma.h).
static constexpr uint32_t LED_STRIP_DMA_CH = DMA_CH_4;
static uint8_t spi_pattern(uint8_t high_bits) { return uint8_t(0xFF00 >> high_bits); }
static void spi_dma_init(uint8_t pin, uint8_t prescaler) {
// LibreTiny numbers pins as (port << 4) | index. Every pin the LN882x boards expose
// (PA0-PA12, PB3-PB9) can be muxed to SPI0 MOSI.
const uint32_t gpio_base = (pin >> 4) ? GPIOB_BASE : GPIOA_BASE;
const auto gpio_pin = static_cast<gpio_pin_t>(1U << (pin & 0xF));
hal_gpio_pin_afio_select(gpio_base, gpio_pin, SPI0_MOSI);
hal_gpio_pin_afio_en(gpio_base, gpio_pin, HAL_ENABLE);
// Only MOSI is routed to a pin; NSS is left unrouted with its output disabled.
spi_init_type_def spi_init{};
spi_init.spi_direction = SPI_DIRECTION_2LINES_FULLDUPLEX;
spi_init.spi_mode = SPI_MODE_MASTER;
spi_init.spi_data_size = SPI_DATASIZE_8B;
spi_init.spi_cpol = SPI_CPOL_LOW;
spi_init.spi_cpha = SPI_CPHA_1EDGE;
spi_init.spi_nss_mode = SPI_NSS_HARD;
spi_init.spi_baud_rate_prescaler = static_cast<spi_baud_rate_precaler_t>(prescaler);
spi_init.spi_first_bit = SPI_FIRST_BIT_MSB;
hal_spi_init(SPI0_BASE, &spi_init);
hal_spi_en(SPI0_BASE, HAL_ENABLE);
hal_spi_ssoe_en(SPI0_BASE, HAL_DISABLE);
dma_init_t_def dma_init{};
dma_init.dma_mem_to_mem_en = DMA_MEM_TO_MEM_DIS;
dma_init.dma_pri_lev = DMA_PRI_LEV_HIGH;
dma_init.dma_mem_size = DMA_MEM_SIZE_8_BIT;
dma_init.dma_p_size = DMA_P_SIZE_8_BIT;
dma_init.dma_mem_inc_en = DMA_MEM_INC_EN;
dma_init.dma_p_inc_en = DMA_P_INC_DIS;
dma_init.dma_circ_mode_en = DMA_CIRC_MODE_DIS;
dma_init.dma_dir = DMA_READ_FORM_MEM;
dma_init.dma_p_addr = SPI0_DATA_REG;
hal_dma_init(LED_STRIP_DMA_CH, &dma_init);
hal_dma_en(LED_STRIP_DMA_CH, HAL_DISABLE);
}
static void spi_dma_transmit(const uint8_t *data, uint16_t len) {
hal_dma_en(LED_STRIP_DMA_CH, HAL_DISABLE);
hal_spi_dma_en(SPI0_BASE, SPI_DMA_TX_EN, HAL_DISABLE);
hal_dma_set_mem_addr(LED_STRIP_DMA_CH, reinterpret_cast<uint32_t>(data));
hal_dma_set_data_num(LED_STRIP_DMA_CH, len);
hal_spi_dma_en(SPI0_BASE, SPI_DMA_TX_EN, HAL_ENABLE);
hal_dma_en(LED_STRIP_DMA_CH, HAL_ENABLE);
}
// The count reaches zero once the last byte is in the SPI data register. The tail of every
// transfer is the reset gap, so starting the next one while that byte shifts out is harmless.
static bool spi_dma_busy() { return hal_dma_get_data_num(LED_STRIP_DMA_CH) != 0; }
LN882xSPILEDStripLightOutput::LN882xSPILEDStripLightOutput(uint8_t pin, uint16_t num_leds,
light::ChannelColors channel_colors, uint8_t prescaler,
uint8_t bit0_high_bits, uint8_t bit1_high_bits,
uint16_t reset_bytes)
: color_data_bytes_(num_leds * channel_colors.bytes_per_led()),
// The tail of the DMA buffer stays zero: it is the reset gap.
dma_data_bytes_(color_data_bytes_ * SPI_BITS_PER_LED_BIT + reset_bytes),
channel_colors_(channel_colors),
num_leds_(num_leds),
pin_(pin),
prescaler_(prescaler),
bit0_high_bits_(bit0_high_bits),
bit1_high_bits_(bit1_high_bits) {}
void LN882xSPILEDStripLightOutput::setup() {
const uint32_t apb0_hz = hal_clock_get_apb0_clk();
if (apb0_hz != APB0_HZ) {
ESP_LOGE(TAG, "APB0 clock is %" PRIu32 " Hz", apb0_hz);
this->mark_failed(LOG_STR("APB0 clock is not 40 MHz"));
return;
}
// One block for the color, effect and DMA data.
RAMAllocator<uint8_t> allocator;
uint8_t *buffer = allocator.allocate(this->color_data_bytes_ + this->num_leds_ + this->dma_data_bytes_);
if (buffer == nullptr) {
this->mark_failed(LOG_STR("Allocation error"));
return;
}
memset(buffer, 0, this->color_data_bytes_ + this->num_leds_ + this->dma_data_bytes_);
this->color_data_ = buffer;
this->effect_data_ = buffer + this->color_data_bytes_;
this->dma_data_ = this->effect_data_ + this->num_leds_;
spi_dma_init(this->pin_, this->prescaler_);
}
void LN882xSPILEDStripLightOutput::dump_config() {
const uint32_t spi_bit_ns = (1000000000 / APB0_HZ) << this->prescaler_;
char channel_colors[5];
ESP_LOGCONFIG(TAG,
"LN882x SPI LED Strip:\n"
" Pin: %u\n"
" Channel colors: %s\n"
" Number of LEDs: %" PRIu16 "\n"
" Max refresh rate: %" PRIu32 " us\n"
" SPI frequency: %" PRIu32 " Hz\n"
" Bit 0 / bit 1 high: %" PRIu32 " / %" PRIu32 " ns of %" PRIu32 " ns",
this->pin_, this->channel_colors_.to_string(channel_colors), this->num_leds_, this->max_refresh_rate_,
APB0_HZ >> this->prescaler_, this->bit0_high_bits_ * spi_bit_ns, this->bit1_high_bits_ * spi_bit_ns,
SPI_BITS_PER_LED_BIT * spi_bit_ns);
}
float LN882xSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
light::LightTraits LN882xSPILEDStripLightOutput::get_traits() {
auto traits = light::LightTraits();
if (this->channel_colors_.has_white()) {
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
} else {
traits.set_supported_color_modes({light::ColorMode::RGB});
}
return traits;
}
void LN882xSPILEDStripLightOutput::clear_effect_data() {
if (this->effect_data_ != nullptr)
memset(this->effect_data_, 0, this->num_leds_);
}
void LN882xSPILEDStripLightOutput::write_state(light::LightState *state) {
if (this->is_failed())
return;
// protect from refreshing too often
const uint32_t now = micros();
if (this->max_refresh_rate_ != 0 && (now - this->last_refresh_) < this->max_refresh_rate_) {
// try again next loop iteration, so that this change won't get lost
this->schedule_show();
return;
}
if (spi_dma_busy()) {
// Busy sending the last frame, try again later.
this->schedule_show();
return;
}
this->last_refresh_ = now;
this->mark_shown_();
const uint8_t bit0 = spi_pattern(this->bit0_high_bits_);
const uint8_t bit1 = spi_pattern(this->bit1_high_bits_);
const uint8_t *color_data = this->color_data_;
uint8_t *dma_data = this->dma_data_;
for (size_t i = 0; i < this->color_data_bytes_; i++) {
const uint8_t color_byte = *(color_data++);
for (uint8_t mask = 0x80; mask != 0; mask >>= 1) {
*(dma_data++) = (color_byte & mask) ? bit1 : bit0;
}
}
spi_dma_transmit(this->dma_data_, this->dma_data_bytes_);
}
light::ESPColorView LN882xSPILEDStripLightOutput::get_view_internal(int32_t index) const {
const light::ChannelColors &colors = this->channel_colors_;
// After a failed setup() effects and partitions still write pixels, so they get a scratch pixel.
static uint8_t scratch[5];
const bool ready = this->color_data_ != nullptr;
uint8_t *led = ready ? this->color_data_ + (index * colors.bytes_per_led()) : scratch;
return {led + colors.r,
led + colors.g,
led + colors.b,
colors.has_white() ? led + colors.w : nullptr,
ready ? &this->effect_data_[index] : scratch + 4,
&this->correction_};
}
} // namespace esphome::ln882x_spi_led_strip
#endif // USE_LN882X
@@ -1,55 +0,0 @@
#pragma once
#ifdef USE_LN882X
#include <cstddef>
#include <cstdint>
#include "esphome/components/light/addressable_light.h"
#include "esphome/components/light/channel_colors.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome::ln882x_spi_led_strip {
class LN882xSPILEDStripLightOutput final : public light::AddressableLight {
public:
/// The SPI encoding comes from light.py: SPI clock APB0 / 2^prescaler, the number of high SPI bits for a
/// 0 and a 1 bit, and the zero bytes of the reset gap.
LN882xSPILEDStripLightOutput(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors, uint8_t prescaler,
uint8_t bit0_high_bits, uint8_t bit1_high_bits, uint16_t reset_bytes);
void setup() override;
void dump_config() override;
float get_setup_priority() const override;
light::LightTraits get_traits() override;
int32_t size() const override { return this->num_leds_; }
void clear_effect_data() override;
void write_state(light::LightState *state) override;
/// Set a maximum refresh rate in µs as some lights do not like being updated too often.
void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; }
protected:
light::ESPColorView get_view_internal(int32_t index) const override;
const size_t color_data_bytes_;
const size_t dma_data_bytes_;
uint8_t *color_data_{nullptr};
uint8_t *effect_data_{nullptr};
uint8_t *dma_data_{nullptr};
uint32_t last_refresh_{0};
uint32_t max_refresh_rate_{0};
const light::ChannelColors channel_colors_;
const uint16_t num_leds_;
const uint8_t pin_;
const uint8_t prescaler_;
const uint8_t bit0_high_bits_;
const uint8_t bit1_high_bits_;
};
} // namespace esphome::ln882x_spi_led_strip
#endif // USE_LN882X
@@ -1,156 +0,0 @@
from dataclasses import dataclass
from esphome import pins
import esphome.codegen as cg
from esphome.components import light
from esphome.components.const import CONF_CHANNEL_COLORS
import esphome.config_validation as cv
from esphome.const import (
CONF_CHIPSET,
CONF_MAX_REFRESH_RATE,
CONF_NUM_LEDS,
CONF_OUTPUT_ID,
CONF_PIN,
CONF_PLATFORM,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import DOMAIN
CODEOWNERS = ["@sebo-b"]
DEPENDENCIES = ["ln882x"]
ln882x_spi_led_strip_ns = cg.esphome_ns.namespace("ln882x_spi_led_strip")
LN882xSPILEDStripLightOutput = ln882x_spi_led_strip_ns.class_(
"LN882xSPILEDStripLightOutput", light.AddressableLight
)
@dataclass(frozen=True)
class LEDStripTimings:
bit0_high: int # ns
bit0_low: int
bit1_high: int
bit1_low: int
# As esp32_rmt_led_strip.
CHIPSETS = {
"WS2811": LEDStripTimings(300, 1090, 1090, 320),
"WS2812": LEDStripTimings(400, 1000, 1000, 400),
"SK6812": LEDStripTimings(300, 900, 600, 600),
"APA106": LEDStripTimings(350, 1360, 1360, 350),
"SM16703": LEDStripTimings(300, 900, 900, 300),
}
# SystemInit() in the SDK sets APB0 to 40 MHz; led_strip.cpp checks it at boot.
APB0_HZ = 40_000_000
# SPI0 runs at APB0 / 2^prescaler, prescaler 1 (/2) to 8 (/256).
SPI_PRESCALER_MIN = 1
SPI_PRESCALER_MAX = 8
# Each LED data bit is sent as one SPI byte, MSB first: the leading 1-bits are the high time.
SPI_BITS_PER_LED_BIT = 8
# At most this many high bits, so every LED bit ends with at least two low SPI bits.
SPI_HIGH_BITS_MAX = SPI_BITS_PER_LED_BIT - 2
# Low time after the data that latches it. Minimum 50 us for WS2811, 80 us for SK6812, 280 us for WS2812B-V5.
RESET_DURATION_NS = 300_000
# One DMA transfer moves at most this many bytes: the whole frame plus the reset gap.
DMA_MAX_TRANSFER = 0xFFFF
@dataclass(frozen=True)
class SPIEncoding:
prescaler: int
bit0_high_bits: int
bit1_high_bits: int
reset_bytes: int
def _spi_bit_ns(prescaler: int) -> int:
return (1_000_000_000 << prescaler) // APB0_HZ
def spi_encoding(timings: LEDStripTimings) -> SPIEncoding:
"""SPI clock and bit patterns that reproduce the chipset timings."""
# The prescaler whose 8-bit frame is closest to the bit period. Each step doubles the frame,
# so step on while the period is at least 1.5 frames (a tie takes the longer frame).
bit_period_ns = max(
timings.bit0_high + timings.bit0_low, timings.bit1_high + timings.bit1_low
)
prescaler = SPI_PRESCALER_MIN
while (
prescaler < SPI_PRESCALER_MAX
and 3 * SPI_BITS_PER_LED_BIT * _spi_bit_ns(prescaler) <= 2 * bit_period_ns
):
prescaler += 1
bit_ns = _spi_bit_ns(prescaler)
def high_bits(duration_ns: int, max_bits: int) -> int:
# Rounded half up, from 1 to max_bits.
return max(1, min(max_bits, (2 * duration_ns + bit_ns) // (2 * bit_ns)))
# Bit 0 stops one below the maximum, so bit 1 can always be longer.
bit0_high_bits = high_bits(timings.bit0_high, SPI_HIGH_BITS_MAX - 1)
bit1_high_bits = max(
high_bits(timings.bit1_high, SPI_HIGH_BITS_MAX), bit0_high_bits + 1
)
reset_bytes = -(-RESET_DURATION_NS // (SPI_BITS_PER_LED_BIT * bit_ns))
return SPIEncoding(prescaler, bit0_high_bits, bit1_high_bits, reset_bytes)
def _validate_num_leds(config: ConfigType) -> ConfigType:
reset_bytes = spi_encoding(CHIPSETS[config[CONF_CHIPSET]]).reset_bytes
bytes_per_led = len(config[CONF_CHANNEL_COLORS])
max_num_leds = (
(DMA_MAX_TRANSFER - reset_bytes) // SPI_BITS_PER_LED_BIT // bytes_per_led
)
if config[CONF_NUM_LEDS] > max_num_leds:
raise cv.Invalid(
f"The maximum number of LEDs for this configuration is {max_num_leds}.",
path=[CONF_NUM_LEDS],
)
return config
CONFIG_SCHEMA = cv.All(
light.ADDRESSABLE_LIGHT_SCHEMA.extend(
{
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(LN882xSPILEDStripLightOutput),
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
cv.Required(CONF_CHANNEL_COLORS): light.validate_channel_colors,
cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True),
cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds,
}
),
_validate_num_leds,
)
def _final_validate(config: ConfigType) -> None:
# The strip owns SPI0 and its DMA channel, so only one can exist.
lights = fv.full_config.get().get(light.DOMAIN, [])
if sum(entry.get(CONF_PLATFORM) == DOMAIN for entry in lights) > 1:
raise cv.Invalid(f"Only one {DOMAIN} is supported (it uses SPI0)")
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
encoding = spi_encoding(CHIPSETS[config[CONF_CHIPSET]])
var = cg.new_Pvariable(
config[CONF_OUTPUT_ID],
config[CONF_PIN],
config[CONF_NUM_LEDS],
light.channel_colors_struct(config[CONF_CHANNEL_COLORS]),
encoding.prescaler,
encoding.bit0_high_bits,
encoding.bit1_high_bits,
encoding.reset_bytes,
)
await light.register_light(var, config)
await cg.register_component(var, config)
cg.add_optional(var.set_max_refresh_rate, config.get(CONF_MAX_REFRESH_RATE))
+10 -14
View File
@@ -35,12 +35,12 @@ class LockTraits {
public:
LockTraits() = default;
bool get_supports_open() const { return this->flags_.supports_open; }
void set_supports_open(bool supports_open) { this->flags_.supports_open = supports_open; }
bool get_requires_code() const { return this->flags_.requires_code; }
void set_requires_code(bool requires_code) { this->flags_.requires_code = requires_code; }
bool get_assumed_state() const { return this->flags_.assumed_state; }
void set_assumed_state(bool assumed_state) { this->flags_.assumed_state = assumed_state; }
bool get_supports_open() const { return this->supports_open_; }
void set_supports_open(bool supports_open) { this->supports_open_ = supports_open; }
bool get_requires_code() const { return this->requires_code_; }
void set_requires_code(bool requires_code) { this->requires_code_ = requires_code; }
bool get_assumed_state() const { return this->assumed_state_; }
void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; }
bool supports_state(LockState state) const { return supported_states_mask_ & (1 << state); }
void set_supported_states(std::initializer_list<LockState> states) {
@@ -54,13 +54,9 @@ class LockTraits {
void add_supported_state(LockState state) { supported_states_mask_ |= (1 << state); }
protected:
// Bit-packed flags to save memory (1 byte instead of 3)
struct LockTraitFlags {
uint8_t supports_open : 1;
uint8_t requires_code : 1;
uint8_t assumed_state : 1;
uint8_t reserved : 5;
} flags_{};
bool supports_open_{false};
bool requires_code_{false};
bool assumed_state_{false};
uint8_t supported_states_mask_{(1 << LOCK_STATE_NONE) | (1 << LOCK_STATE_LOCKED) | (1 << LOCK_STATE_UNLOCKED)};
};
@@ -180,8 +176,8 @@ class Lock : public EntityBase {
*/
virtual void control(const LockCall &call) = 0;
Deduplicator<LockState> publish_dedup_; // packs with state and traits ahead of the pointer members
LazyCallbackManager<void(LockState)> state_callback_{};
Deduplicator<LockState> publish_dedup_;
ESPPreferenceObject rtc_;
};
@@ -1,29 +0,0 @@
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.types import ConfigType
CODEOWNERS = ["@KoenBreeman"]
DEPENDENCIES = ["i2c"]
DOMAIN = "m5stack_4relay"
MULTI_CONF = True
CONF_M5STACK_4RELAY_ID = "m5stack_4relay_id"
m5stack_4relay_ns = cg.esphome_ns.namespace("m5stack_4relay")
M5Stack4Relay = m5stack_4relay_ns.class_("M5Stack4Relay", cg.Component, i2c.I2CDevice)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(M5Stack4Relay),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x26))
)
async def to_code(config: ConfigType) -> None:
await i2c.new_i2c_component(config)
@@ -1,50 +0,0 @@
#include "m5stack_4relay.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::m5stack_4relay {
ESPHOME_LOG_TAG(TAG, "m5stack_4relay");
static constexpr uint8_t REG_MODE = 0x10;
static constexpr uint8_t REG_RELAY = 0x11;
static constexpr uint8_t MODE_SYNC = 1;
void M5Stack4Relay::setup() {
if (!this->write_byte(REG_MODE, MODE_SYNC) || !this->read_byte(REG_RELAY, &this->relays_)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
#ifdef USE_SWITCH
for (size_t channel = 0; channel < this->switches_.size(); channel++) {
switch_::Switch *sw = this->switches_[channel];
if (sw == nullptr)
continue;
// The chip keeps the relays across an ESP reboot, so a switch with restore mode DISABLED keeps what it
// holds. Every mode goes through control(), so inverted: applies and the first publish is the saved one.
sw->restore(((this->relays_ >> channel) & 1) != sw->is_inverted());
}
#endif
}
void M5Stack4Relay::dump_config() {
ESP_LOGCONFIG(TAG, "M5Stack 4 Relay:");
LOG_I2C_DEVICE(this);
}
bool M5Stack4Relay::set_relay(uint8_t channel, bool on) {
if (this->is_failed())
return false;
uint8_t value = this->relays_;
assign_bits(value, 1 << channel, on);
if (!this->status_comm_result(this->write_byte(REG_RELAY, value)))
return false;
this->relays_ = value;
#ifdef USE_SWITCH
if (this->switches_[channel] != nullptr)
this->switches_[channel]->publish_state(on);
#endif
return true;
}
} // namespace esphome::m5stack_4relay
@@ -1,34 +0,0 @@
#pragma once
#include <array>
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#ifdef USE_SWITCH
#include "esphome/components/switch/switch.h"
#endif
namespace esphome::m5stack_4relay {
class M5Stack4Relay : public Component, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
/// Switch relay `channel` (0-3) on or off; returns false when the write failed.
bool set_relay(uint8_t channel, bool on);
#ifdef USE_SWITCH
void register_switch(uint8_t channel, switch_::Switch *sw) { this->switches_[channel] = sw; }
#endif
protected:
uint8_t relays_{0};
#ifdef USE_SWITCH
std::array<switch_::Switch *, 4> switches_{};
#endif
};
} // namespace esphome::m5stack_4relay
@@ -1,50 +0,0 @@
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import CONF_CHANNEL, CONF_PLATFORM
import esphome.final_validate as fv
from esphome.types import ConfigType
from .. import CONF_M5STACK_4RELAY_ID, DOMAIN, M5Stack4Relay
DEPENDENCIES = ["m5stack_4relay"]
CONFIG_SCHEMA = switch.switch_schema(
switch.ApplySwitch, default_restore_mode="DISABLED"
).extend(
{
cv.GenerateID(CONF_M5STACK_4RELAY_ID): cv.use_id(M5Stack4Relay),
cv.Required(CONF_CHANNEL): cv.int_range(min=1, max=4),
}
)
def _validate_unique_channel(config: ConfigType) -> ConfigType:
# The hub has one slot per channel, so a second switch on it would take over the first
hub, channel = config[CONF_M5STACK_4RELAY_ID], config[CONF_CHANNEL]
users = [
other
for other in fv.full_config.get().get(switch.DOMAIN, [])
if other.get(CONF_PLATFORM) == DOMAIN
and other[CONF_M5STACK_4RELAY_ID] == hub
and other[CONF_CHANNEL] == channel
]
if len(users) > 1:
raise cv.Invalid(
f"Channel {channel} of {hub} is used by more than one switch",
path=[CONF_CHANNEL],
)
return config
FINAL_VALIDATE_SCHEMA = _validate_unique_channel
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_M5STACK_4RELAY_ID])
channel = config[CONF_CHANNEL] - 1
await switch.sub_switches(config, parent=hub).create(
config,
lambda sw: hub.register_switch(channel, sw),
apply=f"set_relay({channel}, state)",
)
@@ -12,7 +12,13 @@ ESPHOME_LOG_TAG(TAG, "modbus_controller.switch");
// Maximum bytes to log in verbose hex output
static constexpr size_t MODBUS_SWITCH_MAX_LOG_BYTES = 64;
void ModbusSwitch::setup() { this->restore(); }
void ModbusSwitch::setup() {
optional<bool> initial_state = Switch::get_initial_state_with_restore_mode();
if (initial_state.has_value()) {
// if it has a value, restore_mode is not "DISABLED", therefore act on the switch:
this->control(initial_state.value());
}
}
void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); }
bool ModbusSwitch::assumed_state() { return this->assumed_state_; }
-4
View File
@@ -50,10 +50,6 @@ void MQTTCoverComponent::setup() {
}
if (traits.get_supports_tilt()) {
this->subscribe(this->get_tilt_command_topic(), [this](const std::string &topic, const std::string &payload) {
if (ESPHOME_strcasecmp_P(payload.c_str(), ESPHOME_PSTR("STOP")) == 0) {
this->cover_->make_call().set_command_stop_tilt().perform();
return;
}
auto value = parse_number<float>(payload);
if (!value.has_value()) {
ESP_LOGW(TAG, "Invalid tilt value: '%s'", payload.c_str());
@@ -1,6 +1,5 @@
#include "download_buffer.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstring>
namespace esphome::online_image {
@@ -35,46 +34,6 @@ size_t DownloadBuffer::read(size_t len) {
return this->unread_;
}
void DownloadSource::compact() {
if (this->keep_read_ || this->pos_ == this->start_)
return;
this->buffer_.read(this->pos_ - this->start_);
this->start_ = this->pos_;
}
int32_t DownloadSource::read(uint8_t *buf, uint32_t len) {
const size_t offset = this->pos_ - this->start_;
const size_t n = std::min<size_t>(len, this->buffer_.unread() - offset);
memcpy(buf, this->buffer_.data(offset), n);
this->pos_ += n;
return static_cast<int32_t>(n);
}
bool DownloadSource::seek(uint32_t position) {
// Only what is still in the buffer can be read again; a decoder never skips ahead of the download
if (position < this->start_ || position > this->start_ + this->buffer_.unread())
return false;
this->pos_ = position;
return true;
}
bool DownloadBuffer::grow(size_t size) {
if (this->size_ >= size)
return true;
RAMAllocator<uint8_t> allocator;
uint8_t *buffer = allocator.allocate(size);
if (buffer == nullptr) {
ESP_LOGE(TAG, "allocation of %zu bytes failed. Biggest block in heap: %zu Bytes", size,
allocator.get_max_free_block_size());
return false;
}
memcpy(buffer, this->buffer_, this->unread_);
allocator.deallocate(this->buffer_, this->size_);
this->buffer_ = buffer;
this->size_ = size;
return true;
}
size_t DownloadBuffer::resize(size_t size) {
if (this->size_ >= size) {
// Avoid useless reallocations; if the buffer is big enough, don't reallocate.
@@ -1,6 +1,5 @@
#pragma once
#include "esphome/components/runtime_image/image_decoder.h"
#include "esphome/core/helpers.h"
#include <cstddef>
#include <cstdint>
@@ -36,8 +35,6 @@ class DownloadBuffer {
void reset() { this->unread_ = 0; }
size_t resize(size_t size);
/// Make the buffer at least size bytes, keeping the unread data. Returns false if the allocation failed.
bool grow(size_t size);
protected:
uint8_t *buffer_;
@@ -46,35 +43,4 @@ class DownloadBuffer {
size_t unread_;
};
/**
* @brief Lets a decoder read a download as it arrives, from the download buffer.
*
* The decoder reads at its own position in the buffer. Bytes it has read stay there until compact() drops
* them, and compact() keeps everything while set_keep_read(true) is in effect, so the decoder can seek back
* while it reads the headers.
*/
class DownloadSource : public runtime_image::DecodeSource {
public:
explicit DownloadSource(DownloadBuffer &buffer) : buffer_(buffer) {}
/// Start again at the beginning of a new download, keeping what is read
void reset() {
this->start_ = 0;
this->pos_ = 0;
this->keep_read_ = true;
}
void set_keep_read(bool keep) { this->keep_read_ = keep; }
/// Drop the bytes the decoder has read, to make room for more of the download
void compact();
int32_t read(uint8_t *buf, uint32_t len) override;
bool seek(uint32_t position) override;
protected:
DownloadBuffer &buffer_;
size_t start_{0}; // Position in the download of the first byte in the buffer
size_t pos_{0}; // Position in the download the decoder reads next
bool keep_read_{true};
};
} // namespace esphome::online_image
-12
View File
@@ -22,8 +22,6 @@ DEPENDENCIES = ["http_request"]
CODEOWNERS = ["@guillempages", "@clydebarrow"]
CONF_ON_DOWNLOAD_FINISHED = "on_download_finished"
CONF_ON_DOWNLOAD_STARTED = "on_download_started"
CONF_ON_DOWNLOAD_PROGRESS = "on_download_progress"
CONF_UPDATE = "update"
online_image_ns = cg.esphome_ns.namespace("online_image")
@@ -45,9 +43,7 @@ ONLINE_IMAGE_SCHEMA = (
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
cv.Schema({cv.string: cv.templatable(cv.string)})
),
cv.Optional(CONF_ON_DOWNLOAD_STARTED): automation.validate_automation({}),
cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}),
cv.Optional(CONF_ON_DOWNLOAD_PROGRESS): automation.validate_automation({}),
cv.Optional(CONF_ON_ERROR): automation.validate_automation({}),
}
)
@@ -100,17 +96,9 @@ automation.register_apply_action(
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_DOWNLOAD_STARTED, "add_on_download_started_callback"
),
automation.CallbackAutomation(
CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")]
),
automation.CallbackAutomation(
CONF_ON_DOWNLOAD_PROGRESS,
"add_on_progress_callback",
[(float, "progress"), (cg.size_t, "bytes_read")],
),
automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"),
)
+10 -144
View File
@@ -4,7 +4,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
ESPHOME_LOG_TAG(TAG, "online_image");
@@ -16,11 +15,6 @@ static const char *const IF_MODIFIED_SINCE_HEADER_NAME = "if-modified-since";
namespace esphome::online_image {
// Time one loop pass may spend reading, and decoding a JPEG as it downloads
static constexpr uint32_t PASS_BUDGET_MS = 20;
// Shortest time between two on_download_progress calls
static constexpr uint32_t PROGRESS_INTERVAL_MS = 100;
OnlineImage::OnlineImage(const std::string &url, int width, int height, runtime_image::ImageFormat format,
image::ImageType type, image::Transparency transparency, image::Image *placeholder,
uint32_t buffer_size, bool is_big_endian)
@@ -146,12 +140,8 @@ void OnlineImage::update() {
return;
}
// A JPEG of known size is decoded as it arrives, so the download buffer only has to hold its headers.
// Otherwise the decoder needs the complete image in the buffer.
this->streaming_ = total_size > 0 && total_size <= INT32_MAX && this->decoder_supports_source();
this->stream_started_ = false;
this->source_.reset();
if (!this->streaming_ && format == runtime_image::JPEG && total_size > this->download_buffer_.size() &&
// JPEG requires the complete image in the download buffer before decoding
if (format == runtime_image::JPEG && total_size > this->download_buffer_.size() &&
this->download_buffer_.resize(total_size) == 0) {
// resize() logged the failed allocation; the next update() allocates the configured buffer again
this->end_connection_();
@@ -162,8 +152,6 @@ void OnlineImage::update() {
ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size);
this->start_time_ = millis();
this->last_data_time_ = this->start_time_;
this->download_started_callback_.call();
this->report_progress_(true);
this->enable_loop();
}
@@ -181,11 +169,6 @@ void OnlineImage::loop() {
return;
}
if (this->streaming_) {
this->loop_stream_();
return;
}
// Check if download is complete — use decoder's format-specific completion check
// to handle both known content-length and chunked transfer encoding
if (this->is_decode_finished() || (this->downloader_->content_length > 0 &&
@@ -196,22 +179,15 @@ void OnlineImage::loop() {
}
// Download and decode more data
if (this->download_buffer_.free_capacity() > 0) {
const uint32_t now = App.get_loop_component_start_time();
size_t received = 0;
int len;
do {
len = this->downloader_->read(this->download_buffer_.append(),
std::min(this->download_buffer_.free_capacity(), READ_CHUNK));
if (len <= 0)
break;
this->download_buffer_.write(len);
received += len;
} while (this->download_buffer_.free_capacity() != 0 && millis() - now < PASS_BUDGET_MS);
size_t available = this->download_buffer_.free_capacity();
if (available > 0) {
// Download in chunks to avoid blocking
available = std::min(available, this->download_buffer_initial_size_);
auto len = this->downloader_->read(this->download_buffer_.append(), available);
if (received > 0) {
this->last_data_time_ = now;
this->report_progress_(false);
if (len > 0) {
this->last_data_time_ = App.get_loop_component_start_time();
this->download_buffer_.write(len);
// Feed data to decoder
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
@@ -279,114 +255,6 @@ void OnlineImage::loop() {
}
}
void OnlineImage::loop_stream_() {
const uint32_t now = App.get_loop_component_start_time();
const size_t total = this->downloader_->content_length;
if (this->stream_started_)
this->source_.compact();
if (this->downloader_->get_bytes_read() < total) {
if (this->download_buffer_.free_capacity() == 0) {
// compact() already ran, so a full buffer means the decoder read nothing in the last pass
if (now - this->last_data_time_ >= this->parent_->get_timeout()) {
ESP_LOGE(TAG, "Image does not fit in the %zu byte download buffer; increase buffer_size",
this->download_buffer_.size());
this->fail_download_();
return;
}
} else {
int len;
do {
len = this->downloader_->read(this->download_buffer_.append(),
std::min(this->download_buffer_.free_capacity(), READ_CHUNK));
if (len <= 0)
break;
this->last_data_time_ = now;
this->download_buffer_.write(len);
this->report_progress_(false);
} while (this->downloader_->get_bytes_read() < total && this->download_buffer_.free_capacity() != 0 &&
millis() - now < PASS_BUDGET_MS);
if (len < 0) {
ESP_LOGE(TAG, "Error downloading image: %d", len);
this->fail_download_();
return;
} else if (now - this->last_data_time_ >= this->parent_->get_timeout()) {
ESP_LOGE(TAG, "No data received for %" PRIu32 " ms", this->parent_->get_timeout());
this->fail_download_();
return;
}
}
}
const bool arrived = this->downloader_->get_bytes_read() >= total;
if (!this->stream_started_) {
// The decoder reads the headers when it opens the image, so wait until they are in the buffer
if (!arrived && this->download_buffer_.free_capacity() != 0)
return;
const int err = this->begin_source(&this->source_);
if (err != 0) {
// Headers cut short by a full buffer fail to parse, or make the decoder seek past the buffer. Such an image
// still decodes once it is all in memory, as it did before JPEG images were decoded as they download.
if (!arrived && (err == runtime_image::DECODE_ERROR_INVALID_TYPE || err == runtime_image::DECODE_ERROR_READ)) {
ESP_LOGW(TAG,
"The image headers do not fit the %zu byte download buffer, so the whole image is downloaded "
"before it is decoded; increase buffer_size to decode it as it downloads",
this->download_buffer_.size());
// grow() logs a failed allocation
if (this->download_buffer_.grow(total)) {
this->streaming_ = false;
} else {
this->fail_download_();
}
return;
}
ESP_LOGE(TAG, "Error decoding image: %s", runtime_image::decode_error_to_string(err));
this->fail_download_();
return;
}
this->source_.set_keep_read(false);
this->stream_started_ = true;
// Opening the image and allocating its buffer is enough for one pass
return;
}
do {
const int result = this->decode_step();
if (result == runtime_image::DECODE_STEP_MORE)
continue;
if (result == runtime_image::DECODE_STEP_DONE) {
this->finish_download_();
} else if (result == runtime_image::DECODE_STEP_NEED_DATA) {
if (!arrived)
return;
// JPEGDEC only waits for bytes short of the size it was given, so this guards against a hang
ESP_LOGE(TAG, "Download ended before the image was complete");
this->fail_download_();
} else {
ESP_LOGE(TAG, "Error decoding image: %s", runtime_image::decode_error_to_string(result));
this->fail_download_();
}
return;
} while (millis() - now < PASS_BUDGET_MS);
}
void OnlineImage::report_progress_(bool force) {
const uint32_t now = App.get_loop_component_start_time();
const size_t bytes = this->downloader_->get_bytes_read();
const size_t total = this->downloader_->content_length;
// The last bytes are always reported, so a progress bar reaches 100%
if (!force && now - this->last_progress_time_ < PROGRESS_INTERVAL_MS && (total == 0 || bytes < total))
return;
this->last_progress_time_ = now;
const float progress = total > 0 ? std::min(100.0f, bytes * 100.0f / total) : NAN;
this->download_progress_callback_.call(progress, bytes);
}
void OnlineImage::fail_download_() {
this->end_connection_();
this->download_error_callback_.call();
}
void OnlineImage::finish_download_() {
// Must be read before end_decode() ends the decoding session
bool decoder_finished = this->is_decode_finished();
@@ -420,8 +288,6 @@ void OnlineImage::end_connection_() {
this->downloader_ = nullptr;
}
this->download_buffer_.reset();
this->streaming_ = false;
this->stream_started_ = false;
this->disable_loop();
}
@@ -16,10 +16,6 @@ using t_http_codes = enum {
HTTP_CODE_NOT_FOUND = 404,
};
/// Largest download read(). A read() blocks until it has every byte it asked for, so this bounds how long one loop
/// pass waits on a slow link.
static constexpr size_t READ_CHUNK = 1460;
/**
* @brief Download an image from a given URL, and decode it using the specified decoder.
* The image will then be stored in a buffer, so that it can be re-displayed without the
@@ -74,17 +70,9 @@ class OnlineImage final : public PollingComponent,
*/
void release();
/// Called when the server has accepted the request and the image starts to download
template<typename F> void add_on_download_started_callback(F &&callback) {
this->download_started_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_finished_callback(F &&callback) {
this->download_finished_callback_.add(std::forward<F>(callback));
}
/// Called with the percentage downloaded (NAN when the server did not send the size) and the bytes read so far
template<typename F> void add_on_progress_callback(F &&callback) {
this->download_progress_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_error_callback(F &&callback) {
this->download_error_callback_.add(std::forward<F>(callback));
}
@@ -93,20 +81,12 @@ class OnlineImage final : public PollingComponent,
bool validate_url_(const std::string &url);
void end_connection_();
void finish_download_();
/// loop() for a JPEG that is decoded as it downloads
void loop_stream_();
void fail_download_();
/// Reports download progress, at most every PROGRESS_INTERVAL_MS unless force is set
void report_progress_(bool force);
CallbackManager<void(bool)> download_finished_callback_{};
CallbackManager<void()> download_error_callback_{};
LazyCallbackManager<void()> download_started_callback_{};
LazyCallbackManager<void(float, size_t)> download_progress_callback_{};
std::shared_ptr<http_request::HttpContainer> downloader_{nullptr};
DownloadBuffer download_buffer_;
DownloadSource source_{download_buffer_};
/**
* This is the *initial* size of the download buffer, not the current size.
* The download buffer can be resized at runtime; the download_buffer_initial_size_
@@ -129,9 +109,6 @@ class OnlineImage final : public PollingComponent,
uint32_t start_time_{0};
uint32_t last_data_time_{0};
uint32_t last_progress_time_{0};
bool streaming_{false}; // The decoder reads the download itself, as it arrives
bool stream_started_{false}; // The decoder has read the headers
};
} // namespace esphome::online_image
@@ -6,7 +6,7 @@ namespace esphome::output {
ESPHOME_LOG_TAG(TAG, "output.switch");
void OutputSwitch::dump_config() { LOG_SWITCH("", "Output Switch", this); }
void OutputSwitch::setup() { this->restore(false); }
void OutputSwitch::setup() { this->control(this->get_initial_state_with_restore_mode().value_or(false)); }
void OutputSwitch::write_state(bool state) {
this->output_->set_state(state);
this->publish_state(state);
@@ -1,212 +0,0 @@
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,
CONF_SESSION,
STACK_ORDER,
PacketInterface,
build_stack,
check_packet_sizes,
check_session,
check_single_peer,
layer_needing_session,
packet_interface_ns,
stack_auto_load,
stack_schema,
)
CODEOWNERS = ["@clydebarrow"]
DOMAIN = "packet_interface"
IS_PLATFORM_COMPONENT = True
CONF_PACKET_INTERFACE = "packet_interface"
# 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__)
# The layer keys that each link class does itself, by class name
_PROVIDED_LAYERS: dict[str, tuple[str, ...]] = {}
LINK_LAYER_SCHEMAS = {
cv.Optional(CONF_ADDRESSING): ADDRESSING.schema(),
**stack_schema(),
}
__all__ = [
"CONF_MAX_PACKET_SIZE",
"CONF_PACKET_INTERFACE",
"interface_has_layer",
"interface_layers",
"PacketInterface",
"final_validate_interface",
"new_packet_interface",
"packet_interface_ns",
"packet_interface_schema",
]
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 interface_has_layer(interface_id: ID, key: str) -> bool:
"""During final validation, whether the packet interface with this id has the layer, configured or done by its link."""
interface = interface_layers(interface_id)
if interface is None:
return False
return key in interface or key in _PROVIDED_LAYERS.get(
str(interface[CONF_ID].type), ()
)
def final_validate_interface(key: str) -> Callable[[ConfigType], ConfigType]:
"""Final validation for an application: a link with peers must be used through one of the peers."""
def validator(config: ConfigType) -> ConfigType:
interface_id: ID = config[key]
interface = interface_layers(interface_id)
if interface is not None and (
peers := interface.get(CONF_ADDRESSING, {}).get(CONF_PEERS)
):
raise cv.Invalid(
f"'{interface_id.id}' has peers, so use the id of one of them, such as '{peers[0][CONF_ID].id}'",
path=[key],
)
return config
return validator
def _check_provided_session(config: ConfigType) -> ConfigType:
"""A link's own session does not send packets through the layers, so none can need them from the peer."""
if key := layer_needing_session(config):
raise cv.Invalid(
f"This '{key}' needs packets from the peer, which this link does not send through its layers, "
"so it cannot be used on this link",
path=[key],
)
return config
def packet_interface_schema(
class_: MockObjClass,
default_max_packet_size: int,
max_packet_size_limit: int = 4096,
provides: tuple[str, ...] = (),
) -> cv.Schema:
"""Schema for a link platform; provides names the layer keys, such as reliability, that the link does itself."""
if provides:
_PROVIDED_LAYERS[str(class_)] = provides
link_layers = {
key: (
cv.invalid(f"This link provides '{key}' itself, so it cannot be configured")
if key in provides
else schema
)
for key, schema in LINK_LAYER_SCHEMAS.items()
}
schema = cv.Schema(
{
cv.GenerateID(): cv.declare_id(class_),
cv.Optional(
CONF_MAX_PACKET_SIZE, default=default_max_packet_size
): cv.int_range(16, max_packet_size_limit),
**link_layers,
}
).extend(cv.COMPONENT_SCHEMA)
if CONF_SESSION in provides:
schema = schema.add_extra(_check_provided_session)
return schema.add_extra(_validate_layers)
async def new_packet_interface(config: ConfigType, *args: Any) -> MockObj:
"""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(). 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
@@ -1,97 +0,0 @@
#include "basic_addressing.h"
#ifdef USE_PACKET_INTERFACE_BASIC_ADDRESSING
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.addressing");
void BasicAddressing::setup() {
this->link_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
}
void BasicAddressing::dump_config() {
ESP_LOGCONFIG(TAG,
"Basic Addressing on '%s':\n"
" Address: 0x%04X",
this->link_->get_name(), this->address_);
}
bool BasicAddressing::send(const PacketBuffer &data, const PacketMetaData &meta_data, uint16_t destination) {
if (meta_data.destination_address != 0)
destination = meta_data.destination_address;
uint8_t header[HEADER_SIZE] = {static_cast<uint8_t>(destination), static_cast<uint8_t>(destination >> 8),
static_cast<uint8_t>(this->address_), static_cast<uint8_t>(this->address_ >> 8)};
PacketBuffer packet(header, HEADER_SIZE, &data, nullptr, 0);
return this->link_->send_to_interface(packet, meta_data);
}
void BasicAddressing::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
// Lower layers deliver packets in one piece; the payload is passed up without copying
if (!data.is_contiguous()) {
ESP_LOGW(TAG, "%s: Packet split across buffers is not supported", this->link_->get_name());
return;
}
auto packet = data.as_span();
if (packet.size() < HEADER_SIZE) {
ESP_LOGV(TAG, "%s: Packet of %zu bytes is shorter than the address header, dropped", this->link_->get_name(),
packet.size());
return;
}
uint16_t destination = encode_uint16(packet[1], packet[0]);
uint16_t source = encode_uint16(packet[3], packet[2]);
if (source == this->address_ || (destination != this->address_ && destination != BROADCAST_ADDRESS))
return;
AddressingPort *source_port = nullptr;
AddressingPort *broadcast_port = nullptr;
for (auto *port : this->ports_) {
if (port->destination_ == source) {
source_port = port;
} else if (port->destination_ == BROADCAST_ADDRESS) {
broadcast_port = port;
}
}
// A broadcast goes to the broadcast port, even from a device with a port of its own, as that port's layers expect
// only its point-to-point packets; anything else goes to the source's port, or to the broadcast port if it has none
AddressingPort *target = source_port;
if (broadcast_port != nullptr && (destination == BROADCAST_ADDRESS || source_port == nullptr))
target = broadcast_port;
if (target == nullptr) {
ESP_LOGV(TAG, "%s: Packet from 0x%04X dropped", this->link_->get_name(), source);
return;
}
PacketMetaData received = meta_data;
received.source_address = source;
received.destination_address = destination;
target->on_receive_from_interface_(PacketBuffer(packet.data() + HEADER_SIZE, packet.size() - HEADER_SIZE), received);
}
AddressingPort::AddressingPort(BasicAddressing *addressing, uint16_t destination, bool only_port)
: PacketInterface(
remaining_packet_size(addressing->get_link()->get_max_packet_size(), BasicAddressing::HEADER_SIZE)),
addressing_(addressing),
destination_(destination) {
if (only_port) {
this->stack_on_(addressing->get_link(), CAPABILITY_ADDRESSING);
} else {
this->inherit_from_(addressing->get_link(), CAPABILITY_ADDRESSING);
}
// A link with no room leaves the layers above none either, so they fail their setup
addressing->add_port(this);
}
void AddressingPort::dump_config() {
ESP_LOGCONFIG(TAG,
"Addressing Port '%s':\n"
" Max Packet Size: %zu",
this->name_, this->max_packet_size_);
if (this->destination_ == BasicAddressing::BROADCAST_ADDRESS) {
ESP_LOGCONFIG(TAG, " Destination: broadcast");
} else {
ESP_LOGCONFIG(TAG, " Destination: 0x%04X", this->destination_);
}
}
} // namespace esphome::packet_interface
#endif
@@ -1,69 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_PACKET_INTERFACE_BASIC_ADDRESSING
#include "esphome/core/helpers.h"
#include "packet_interface.h"
namespace esphome::packet_interface {
class AddressingPort;
/**
* An addressing layer for channels shared by several devices, such as an RS485 bus or a LoRa radio.
*
* Each packet starts with a 16-bit destination address and a 16-bit source address, little endian.
* BROADCAST_ADDRESS sends to every device. A packet is dropped unless it is addressed to this device or broadcast, and
* a packet from this device's own address, such as an echo on a half-duplex bus, is dropped too.
*
* Above this layer are one or more ports, each the bottom of its own stack of layers. A port with a destination
* address is a point-to-point link to that device: it sends to it and receives only its packets, so the reliability
* and session layers and the rolling code, which work between two devices only, can be used above it. A port with
* the broadcast destination sends to every device, and receives every broadcast, as well as packets from devices
* that have no port of their own. Without a broadcast port, a device's broadcasts go to its own port.
*/
class BasicAddressing : public Component {
public:
static constexpr uint16_t BROADCAST_ADDRESS = 0xFFFF;
/// Destination and source addresses.
static constexpr size_t HEADER_SIZE = 4;
BasicAddressing(PacketInterface *link, uint16_t address) : link_(link), address_(address) {}
void setup() override;
void dump_config() override;
PacketInterface *get_link() const { return this->link_; }
void add_port(AddressingPort *port) { this->ports_.push_back(port); }
/// Sends \p data to \p destination, or to the destination in \p meta_data if set.
bool send(const PacketBuffer &data, const PacketMetaData &meta_data, uint16_t destination);
protected:
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
PacketInterface *link_;
const uint16_t address_;
StaticVector<AddressingPort *, PACKET_INTERFACE_ADDRESSING_PORT_COUNT> ports_;
};
/// The bottom of the stack of layers for one destination, or for broadcast, on an addressed channel.
class AddressingPort : public PacketInterface {
public:
/// With \p only_port, the port is the top of the link, so top() on the link reaches this port's stack.
AddressingPort(BasicAddressing *addressing, uint16_t destination, bool only_port);
void dump_config() override;
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override {
return this->addressing_->send(data, meta_data, this->destination_);
}
uint16_t get_destination() const { return this->destination_; }
protected:
friend class BasicAddressing;
BasicAddressing *addressing_;
const uint16_t destination_;
};
} // namespace esphome::packet_interface
#endif
@@ -1,160 +0,0 @@
#include "basic_fragmentation.h"
#ifdef USE_PACKET_INTERFACE_BASIC_FRAGMENTATION
#include <algorithm>
#include <cstring>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.fragmentation");
static constexpr uint8_t LAST_FRAGMENT = 0x80;
static constexpr uint8_t INDEX_MASK = 0x7F;
BasicFragmentation::BasicFragmentation(PacketInterface *lower, size_t max_packet_size)
: PacketInterface(max_packet_size), lower_(lower) {
this->stack_on_(lower, CAPABILITY_FRAGMENTATION);
}
size_t BasicFragmentation::fragments_for_(size_t size) const {
size_t fragment_size = this->fragment_size_();
if (fragment_size == 0)
return SIZE_MAX;
return std::max<size_t>(1, (size + fragment_size - 1) / fragment_size);
}
void BasicFragmentation::setup() {
if (this->fragment_size_() == 0) {
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
return;
}
if (this->fragments_for_(this->max_packet_size_) > MAX_FRAGMENTS) {
this->mark_failed(LOG_STR("max_packet_size needs more than 128 fragments"));
return;
}
// Checked now, while the layer below holds nothing; a failed layer below reports its own failure instead
this->queue_too_short_ =
!this->lower_->is_failed() && this->fragments_for_(this->max_packet_size_) > this->lower_->get_send_capacity();
RAMAllocator<uint8_t> allocator;
this->tx_buffer_ = allocator.allocate(this->fragment_size_() + this->max_packet_size_);
if (this->tx_buffer_ == nullptr) {
this->mark_failed(LOG_STR("Not enough memory for the packet buffers"));
return;
}
this->rx_buffer_ = this->tx_buffer_ + this->fragment_size_();
// Numbering from a random start makes it unlikely that a packet joins fragments from before a restart
this->tx_number_ = random_uint32();
this->lower_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
}
void BasicFragmentation::dump_config() {
// A link with no room for fragments fails setup, which core reports below
size_t fragments = this->fragment_size_() == 0 ? 0 : this->fragments_for_(this->max_packet_size_);
ESP_LOGCONFIG(TAG,
"Basic Fragmentation on '%s':\n"
" Max Packet Size: %zu\n"
" Fragment Size: %zu\n"
" Fragments in the Largest Packet: %zu",
this->name_, this->max_packet_size_, this->fragment_size_(), fragments);
if (this->queue_too_short_) {
ESP_LOGW(TAG, " The reliability queue is too short for the largest packets, which cannot be sent");
}
}
bool BasicFragmentation::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
if (this->tx_buffer_ == nullptr)
return false;
size_t size = data.size();
if (size > this->max_packet_size_) {
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
return false;
}
size_t fragments = this->fragments_for_(size);
if (fragments > this->lower_->get_send_capacity()) {
ESP_LOGV(TAG, "%s: No room for %zu fragments", this->name_, fragments);
return false;
}
uint8_t header[HEADER_SIZE] = {this->tx_number_++, 0};
if (fragments == 1) {
header[1] = LAST_FRAGMENT;
return this->lower_->send_to_interface(PacketBuffer(header, HEADER_SIZE, &data, nullptr, 0), meta_data);
}
size_t fragment_size = this->fragment_size_();
for (size_t index = 0; index != fragments; index++) {
size_t length = data.copy_to(this->tx_buffer_, fragment_size, index * fragment_size);
header[1] = index | (index + 1 == fragments ? LAST_FRAGMENT : 0);
PacketBuffer body(this->tx_buffer_, length);
if (!this->lower_->send_to_interface(PacketBuffer(header, HEADER_SIZE, &body, nullptr, 0), meta_data)) {
// The peer drops the part already sent when the next packet starts
ESP_LOGV(TAG, "%s: Fragment %zu of %zu not accepted", this->name_, index, fragments);
return false;
}
}
return true;
}
void BasicFragmentation::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
// Lower layers deliver packets in one piece; a packet in one fragment is passed up without copying
if (!data.is_contiguous()) {
ESP_LOGW(TAG, "%s: Packet split across buffers is not supported", this->name_);
return;
}
auto fragment = data.as_span();
if (fragment.size() < HEADER_SIZE) {
ESP_LOGV(TAG, "%s: Fragment of %zu bytes is shorter than its header, dropped", this->name_, fragment.size());
return;
}
uint8_t number = fragment[0];
uint8_t index = fragment[1] & INDEX_MASK;
bool last = (fragment[1] & LAST_FRAGMENT) != 0;
const uint8_t *payload = fragment.data() + HEADER_SIZE;
size_t length = fragment.size() - HEADER_SIZE;
if (index == 0 && last) {
// A packet in one fragment leaves a packet being joined from another device intact
if (this->rx_joining_ && meta_data.source_address == this->rx_source_) {
ESP_LOGV(TAG, "%s: Incomplete packet %u dropped", this->name_, this->rx_number_);
this->rx_joining_ = false;
}
if (length > this->max_packet_size_) {
ESP_LOGV(TAG, "%s: Packet %u is larger than %zu, dropped", this->name_, number, this->max_packet_size_);
return;
}
this->on_receive_from_interface_(PacketBuffer(payload, length), meta_data);
return;
}
if (index == 0) {
if (this->rx_joining_) {
ESP_LOGV(TAG, "%s: Incomplete packet %u dropped", this->name_, this->rx_number_);
}
this->rx_joining_ = true;
this->rx_number_ = number;
this->rx_source_ = meta_data.source_address;
this->rx_next_index_ = 0;
this->rx_length_ = 0;
} else if (this->rx_joining_ && meta_data.source_address != this->rx_source_) {
// Only this fragment is lost, so of two devices sending at once, the one that started last still gets through
ESP_LOGV(TAG, "%s: Fragment %u from 0x%04X dropped", this->name_, index, meta_data.source_address);
return;
} else if (!this->rx_joining_ || number != this->rx_number_ || index != this->rx_next_index_) {
ESP_LOGV(TAG, "%s: Fragment %u of packet %u dropped", this->name_, index, number);
this->rx_joining_ = false;
return;
}
if (this->rx_length_ + length > this->max_packet_size_) {
ESP_LOGV(TAG, "%s: Packet %u is larger than %zu, dropped", this->name_, number, this->max_packet_size_);
this->rx_joining_ = false;
return;
}
memcpy(this->rx_buffer_ + this->rx_length_, payload, length);
this->rx_length_ += length;
this->rx_next_index_++;
if (last) {
this->rx_joining_ = false;
this->on_receive_from_interface_(PacketBuffer(this->rx_buffer_, this->rx_length_), meta_data);
}
}
} // namespace esphome::packet_interface
#endif
@@ -1,60 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_PACKET_INTERFACE_BASIC_FRAGMENTATION
#include "packet_interface.h"
namespace esphome::packet_interface {
/**
* A fragmentation layer: packets up to this layer's max_packet_size are split into fragments that fit the layer
* below, and joined again at the other end.
*
* Each fragment starts with a 2 byte header: an 8-bit packet number, then the fragment's index in bits 0-6, with bit
* 7 set on the last fragment. A packet that fits in one fragment still has the header, and is sent and passed up
* without being copied.
*
* A packet is started only if the layer below can take all of its fragments now (get_send_capacity()), so a full
* reliability queue does not leave the peer with part of a packet. The receiver joins one packet at a time, and drops
* it when a fragment is missing or arrives with another packet number, or when the same source, or another one,
* starts a new packet of several fragments. Other fragments from another source address are dropped, leaving the
* packet being joined intact. Above a reliability layer, fragments arrive in order and none are missing.
*/
class BasicFragmentation : public PacketInterface {
public:
/// Packet number, then index and last flag.
static constexpr size_t HEADER_SIZE = 2;
/// The index has 7 bits.
static constexpr size_t MAX_FRAGMENTS = 128;
BasicFragmentation(PacketInterface *lower, size_t max_packet_size);
void setup() override;
void dump_config() override;
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
protected:
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
size_t fragment_size_() const {
size_t lower_size = this->lower_->get_max_packet_size();
return lower_size > HEADER_SIZE ? lower_size - HEADER_SIZE : 0;
}
size_t fragments_for_(size_t size) const;
PacketInterface *lower_;
// One fragment being sent, and the packet being joined; one block allocated in setup(), and tx_buffer_ stays null
// if that fails
uint8_t *tx_buffer_{nullptr};
uint8_t *rx_buffer_{nullptr};
size_t rx_length_{0};
uint16_t rx_source_{0};
uint8_t tx_number_{0};
uint8_t rx_number_{0};
uint8_t rx_next_index_{0};
bool rx_joining_{false};
bool queue_too_short_{false};
};
} // namespace esphome::packet_interface
#endif
@@ -1,135 +0,0 @@
#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
@@ -1,61 +0,0 @@
#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
@@ -1,365 +0,0 @@
#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
@@ -1,99 +0,0 @@
#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
@@ -1,258 +0,0 @@
"""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 layer_needing_session(config: ConfigType) -> str | None:
"""The key of the first layer in this stack that needs packets back from the peer, if any."""
for kind in STACK_ORDER:
if (layer := config.get(kind.key)) is not None and kind.get_type(
layer
).needs_session(layer):
return kind.key
return None
def check_session(config: ConfigType) -> None:
"""Layers that need packets back from the peer need a session layer, whose keep-alives send them."""
if CONF_SESSION not in config and (key := layer_needing_session(config)):
raise cv.Invalid(
f"This '{key}' needs packets from the peer, so it needs a '{CONF_SESSION}' layer, whose "
"keep-alives make sure they are sent",
path=[key],
)
def check_single_peer(config: ConfigType, destination: int) -> None:
"""Layers that keep state for one peer need a destination other than broadcast."""
if destination != BROADCAST_ADDRESS:
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 check_broadcast_link(config: ConfigType) -> None:
"""On a link that every device hears, layers that keep state for one peer need an addressing layer."""
if CONF_ADDRESSING in config:
return
for kind in STACK_ORDER:
if (layer := config.get(kind.key)) is not None and kind.get_type(
layer
).single_peer(layer):
raise cv.Invalid(
f"This '{kind.key}' works between two devices only, but every device hears this link, so it needs "
f"'{CONF_ADDRESSING}' with a '{CONF_DESTINATION}'",
path=[kind.key],
)
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)
@@ -1,132 +0,0 @@
"""Addressing layer with 16-bit addresses, carrying one stack of layers or one for each of several peers."""
from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import (
ADDRESSING,
BROADCAST_ADDRESS,
CONF_DESTINATION,
CONF_PEERS,
PacketInterface,
check_session,
check_single_peer,
packet_interface_ns,
remaining_packet_size,
stack_schema,
)
CONF_PORT_ID = "port_id"
# The destination and source addresses
HEADER_SIZE = 4
BasicAddressing = packet_interface_ns.class_("BasicAddressing", cg.Component)
AddressingPort = packet_interface_ns.class_("AddressingPort", PacketInterface)
_request_port_slot = cg.slot_counter("PACKET_INTERFACE_ADDRESSING_PORT_COUNT")
def _destination(value: Any) -> int:
"""A device address, or broadcast."""
if isinstance(value, str) and value.lower() == "broadcast":
return BROADCAST_ADDRESS
return cv.hex_int_range(1, BROADCAST_ADDRESS)(value)
def _validate_peer(config: ConfigType) -> ConfigType:
check_session(config)
check_single_peer(config, config[CONF_DESTINATION])
return config
def _unique_destinations(peers: list[ConfigType]) -> list[ConfigType]:
destinations = [peer[CONF_DESTINATION] for peer in peers]
if len(set(destinations)) != len(destinations):
raise cv.Invalid("Each peer must have a different destination")
return peers
def _not_own_address(config: ConfigType) -> ConfigType:
"""Packets from this device's own address are dropped as echoes, so it cannot be a destination."""
address = config[CONF_ADDRESS]
if config.get(CONF_DESTINATION) == address:
raise cv.Invalid(
"Must not be this device's own address", path=[CONF_DESTINATION]
)
for index, peer in enumerate(config.get(CONF_PEERS, [])):
if peer[CONF_DESTINATION] == address:
raise cv.Invalid(
"Must not be this device's own address",
path=[CONF_PEERS, index, CONF_DESTINATION],
)
return config
# A function, as each peer holds the other layers, whose schemas exist only once every layer type is registered
def _basic_addressing_schema() -> cv.Schema:
peer_schema = cv.All(
cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(AddressingPort),
cv.Required(CONF_DESTINATION): _destination,
**stack_schema(),
}
),
_validate_peer,
)
return cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BasicAddressing),
cv.GenerateID(CONF_PORT_ID): cv.declare_id(AddressingPort),
cv.Required(CONF_ADDRESS): cv.hex_int_range(1, BROADCAST_ADDRESS - 1),
cv.Optional(CONF_DESTINATION): _destination,
cv.Optional(CONF_PEERS): cv.All(
cv.ensure_list(peer_schema), cv.Length(min=1), _unique_destinations
),
}
).extend(cv.COMPONENT_SCHEMA),
cv.has_at_most_one_key(CONF_DESTINATION, CONF_PEERS),
_not_own_address,
)
async def _new_port(
port_id: ID, addressing: MockObj, destination: int, only_port: bool
) -> MockObj:
_request_port_slot(str(addressing))
port = cg.new_Pvariable(port_id, addressing, destination, only_port)
await cg.register_component(port, {})
return port
@ADDRESSING.register(
"basic",
schema_factory=_basic_addressing_schema,
packet_size=lambda config, size: remaining_packet_size(size, HEADER_SIZE),
)
async def build_basic_addressing(
config: ConfigType, link: MockObj
) -> list[tuple[MockObj, ConfigType | None]]:
cg.add_define("USE_PACKET_INTERFACE_BASIC_ADDRESSING")
hub = cg.new_Pvariable(config[CONF_ID], link, config[CONF_ADDRESS])
await cg.register_component(hub, config)
if (peers := config.get(CONF_PEERS)) is None:
port = await _new_port(
config[CONF_PORT_ID], hub, config[CONF_DESTINATION], True
)
return [(port, None)]
stacks = []
# Each peer has its own stack, named after the peer for logs and saved preferences
for peer in peers:
port = await _new_port(peer[CONF_ID], hub, peer[CONF_DESTINATION], False)
cg.add(port.set_name(peer[CONF_ID].id))
stacks.append((port, peer))
return stacks
@@ -1,56 +0,0 @@
"""Fragmentation layer that splits packets larger than the layers below allow, and joins them again."""
import math
import esphome.codegen as cg
from esphome.components.const import CONF_MAX_PACKET_SIZE
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import FRAGMENTATION, PacketInterface, packet_interface_ns
# The packet number, and the fragment's index with the flag on the last fragment
HEADER_SIZE = 2
MAX_FRAGMENTS = 128
BasicFragmentation = packet_interface_ns.class_("BasicFragmentation", PacketInterface)
BASIC_FRAGMENTATION_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(BasicFragmentation),
# The packet being joined is held in a buffer of this size
cv.Optional(CONF_MAX_PACKET_SIZE, default=1024): cv.int_range(16, 16384),
}
).extend(cv.COMPONENT_SCHEMA)
def _check_lower(config: ConfigType, size: int, capacity: int | None) -> None:
"""The largest packet must fit in the fragments that the layer below can take at once."""
fragment_size = size - HEADER_SIZE
if fragment_size <= 0:
raise cv.Invalid("The layers below leave no room for fragments")
fragments = max(1, math.ceil(config[CONF_MAX_PACKET_SIZE] / fragment_size))
limit = MAX_FRAGMENTS if capacity is None else min(MAX_FRAGMENTS, capacity)
if fragments > limit:
raise cv.Invalid(
f"A packet of {config[CONF_MAX_PACKET_SIZE]} bytes needs {fragments} fragments of {fragment_size} bytes, "
f"but at most {limit} can be sent at once",
path=[CONF_MAX_PACKET_SIZE],
)
# Fragments from different devices are told apart by the addressing layer's source address, so of two devices sending
# fragmented packets at once, one still gets through; without addressing, the receiver can drop both
@FRAGMENTATION.register(
"basic",
schema=BASIC_FRAGMENTATION_SCHEMA,
packet_size=lambda config, size: config[CONF_MAX_PACKET_SIZE],
check_lower=_check_lower,
)
async def build_basic_fragmentation(config: ConfigType, lower: MockObj) -> MockObj:
cg.add_define("USE_PACKET_INTERFACE_BASIC_FRAGMENTATION")
var = cg.new_Pvariable(config[CONF_ID], lower, config[CONF_MAX_PACKET_SIZE])
await cg.register_component(var, config)
return var
@@ -1,71 +0,0 @@
"""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
@@ -1,68 +0,0 @@
"""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
@@ -1,61 +0,0 @@
"""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
@@ -1,45 +0,0 @@
#pragma once
#include <cstdint>
namespace esphome::packet_interface {
/**
* Decides whether a half-duplex radio link may send while its radio reports that it is receiving a packet.
*
* Sending is refused while the radio is busy, so the packet being received is not lost. A busy period starts when a
* send first finds the radio busy, and ends when a send finds it free or end_busy() is called. Once a busy period has
* lasted max_busy_ms, sends go ahead, so a busy flag that never clears cannot stop sending for good, however often
* sends are tried. Code generation sets max_busy_ms from the time the radio takes to receive the largest packet.
*/
class ListenBeforeTalk {
public:
static constexpr uint32_t DEFAULT_MAX_BUSY_MS = 2000;
void set_max_busy(uint32_t max_busy_ms) { this->max_busy_ms_ = max_busy_ms; }
uint32_t get_max_busy() const { return this->max_busy_ms_; }
/// True if a packet may be sent at \p now; \p busy says whether the radio is receiving.
bool may_send(bool busy, uint32_t now) {
if (!busy) {
this->busy_ = false;
return true;
}
if (!this->busy_) {
this->busy_ = true;
this->busy_since_ms_ = now;
}
return now - this->busy_since_ms_ >= this->max_busy_ms_;
}
/// Ends the busy period, when the radio is seen to be free other than by a send.
void end_busy() { this->busy_ = false; }
/// How long the current busy period has lasted at \p now.
uint32_t busy_for(uint32_t now) const { return now - this->busy_since_ms_; }
protected:
uint32_t busy_since_ms_{0};
uint32_t max_busy_ms_{DEFAULT_MAX_BUSY_MS};
bool busy_{false};
};
} // namespace esphome::packet_interface
@@ -1,173 +0,0 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <span>
#include <vector>
namespace esphome::packet_interface {
/**
* A buffer class that efficiently represents a composite buffer with three sections:
* - pre_buffer: A pointer to uint8_t data with a length
* - mid_buffer: A reference to another PacketBuffer (optional)
* - post_buffer: A pointer to uint8_t data with a length
*
* This allows efficient zero-copy composition of buffers without allocating new memory.
*
* WARNING: PacketBuffer holds raw pointers to data. The user must ensure that the data remains valid
* for the lifetime of the PacketBuffer instance. Storing PacketBuffer instances beyond the scope of the data they
* reference will lead to dangling pointers and undefined behavior, so to keep a packet, copy it with copy_to() into a
* buffer allocated in setup().
*/
class PacketBuffer {
public:
PacketBuffer() = default;
// Disable copy semantics - PacketBuffer contains pointers and copying would create dangling references
PacketBuffer(const PacketBuffer &) = delete;
PacketBuffer &operator=(const PacketBuffer &) = delete;
// Enable move semantics
PacketBuffer(PacketBuffer &&) = default;
PacketBuffer &operator=(PacketBuffer &&) = default;
/**
* Construct from a single buffer.
*/
PacketBuffer(const uint8_t *data, size_t length) : pre_buffer_(data), pre_length_(length) {}
/**
* Construct from a vector.
*/
explicit PacketBuffer(const std::vector<uint8_t> &vec) : pre_buffer_(vec.data()), pre_length_(vec.size()) {}
// A temporary vector would leave the buffer dangling
explicit PacketBuffer(std::vector<uint8_t> &&) = delete;
/**
* Construct with pre and post buffers.
*/
PacketBuffer(const uint8_t *pre_data, size_t pre_length, const uint8_t *post_data, size_t post_length)
: pre_buffer_(pre_data), pre_length_(pre_length), post_buffer_(post_data), post_length_(post_length) {}
/**
* Construct with pre, mid, and post buffers.
*/
PacketBuffer(const uint8_t *pre_data, size_t pre_length, const PacketBuffer *mid_buffer, const uint8_t *post_data,
size_t post_length)
: pre_buffer_(pre_data),
pre_length_(pre_length),
mid_buffer_(mid_buffer),
post_buffer_(post_data),
post_length_(post_length) {}
/**
* Get the total size of the buffer.
*/
size_t size() const {
size_t total = this->pre_length_ + this->post_length_;
if (this->mid_buffer_ != nullptr) {
total += this->mid_buffer_->size();
}
return total;
}
/**
* Check if the buffer is empty.
*/
bool empty() const { return this->size() == 0; }
/**
* Check if all the data is held in the pre-buffer, so it can be read as one span.
*/
bool is_contiguous() const { return this->mid_buffer_ == nullptr && this->post_length_ == 0; }
/**
* Get the data as one span. Only valid when is_contiguous() is true.
*/
std::span<const uint8_t> as_span() const { return {this->pre_buffer_, this->pre_length_}; }
/**
* Access a byte at a specific position.
* @param index The position to access
* @return The byte at the specified position, or 0 past the end
*/
uint8_t operator[](size_t index) const {
if (index < this->pre_length_) {
return this->pre_buffer_[index];
}
index -= this->pre_length_;
if (this->mid_buffer_ != nullptr) {
size_t mid_size = this->mid_buffer_->size();
if (index < mid_size) {
return (*this->mid_buffer_)[index];
}
index -= mid_size;
}
return index < this->post_length_ ? this->post_buffer_[index] : 0;
}
/**
* Copy data from the buffer to a destination pointer.
* @param dest Destination pointer
* @param count Number of bytes to copy
* @param offset Offset in the source buffer to start copying from
* @return Number of bytes actually copied
*/
size_t copy_to(uint8_t *dest, size_t count, size_t offset = 0) const {
size_t total_size = this->size();
if (offset >= total_size) {
return 0;
}
size_t available = total_size - offset;
size_t to_copy = count < available ? count : available;
size_t copied = 0;
// Copy from pre_buffer
if (offset < this->pre_length_) {
size_t pre_offset = offset;
size_t pre_available = this->pre_length_ - pre_offset;
size_t pre_copy = to_copy < pre_available ? to_copy : pre_available;
std::memcpy(dest, this->pre_buffer_ + pre_offset, pre_copy);
copied += pre_copy;
dest += pre_copy;
offset += pre_copy;
}
// Copy from mid_buffer
size_t mid_size = this->mid_buffer_ != nullptr ? this->mid_buffer_->size() : 0;
if (copied < to_copy && offset < this->pre_length_ + mid_size) {
size_t mid_offset = offset - this->pre_length_;
size_t mid_available = mid_size - mid_offset;
size_t mid_copy = (to_copy - copied) < mid_available ? (to_copy - copied) : mid_available;
this->mid_buffer_->copy_to(dest, mid_copy, mid_offset);
copied += mid_copy;
dest += mid_copy;
offset += mid_copy;
}
// Copy from post_buffer
if ((copied < to_copy) && (this->post_length_ > 0)) {
size_t post_offset = offset - this->pre_length_ - mid_size;
size_t post_available = this->post_length_ - post_offset;
size_t post_copy = (to_copy - copied) < post_available ? (to_copy - copied) : post_available;
std::memcpy(dest, this->post_buffer_ + post_offset, post_copy);
copied += post_copy;
}
return copied;
}
private:
const uint8_t *pre_buffer_{nullptr};
size_t pre_length_{0};
const PacketBuffer *mid_buffer_{nullptr};
const uint8_t *post_buffer_{nullptr};
size_t post_length_{0};
};
} // namespace esphome::packet_interface
@@ -1,39 +0,0 @@
#include "packet_interface.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface");
// Packets in a row that cannot be understood before the mismatch is logged
static constexpr uint8_t MISMATCH_COUNT = 10;
void PacketInterface::dump_config() {
ESP_LOGCONFIG(TAG,
"Packet Interface '%s':\n"
" Max Packet Size: %zu",
this->name_, this->get_max_packet_size());
}
void PacketInterface::on_receive_from_interface_(const PacketBuffer &data, const PacketMetaData &meta_data) {
#ifdef ESPHOME_LOG_HAS_VERBOSE
static constexpr size_t LOG_BYTES = 24;
uint8_t head[LOG_BYTES];
size_t len = data.copy_to(head, LOG_BYTES);
ESP_LOGV_HEX(TAG, head, len, LOG_BYTES, "%s: received %zu bytes %s", this->name_, data.size());
#endif
this->callback_.call(data, meta_data);
}
void PacketInterface::note_received_(bool understood) {
if (understood) {
this->not_understood_count_ = 0;
} else if (this->not_understood_count_ < MISMATCH_COUNT) {
this->not_understood_count_++;
if (this->not_understood_count_ == MISMATCH_COUNT) {
ESP_LOGW(TAG, "%s: %u packets in a row could not be understood; check that both ends have the same layers",
this->name_, MISMATCH_COUNT);
}
}
}
} // namespace esphome::packet_interface
@@ -1,149 +0,0 @@
#pragma once
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <utility>
#include "esphome/core/string_ref.h"
#include "esphome/core/helpers.h"
#include "esphome/core/component.h"
#include "packet_buffer.h"
namespace esphome::packet_interface {
/// The room left for a packet of \p size bytes after \p used bytes, or 0 if it is too small.
constexpr size_t remaining_packet_size(size_t size, size_t used) { return size > used ? size - used : 0; }
/// What a stack of layers provides, as bits of PacketInterface::get_capabilities().
enum Capability : uint8_t {
CAPABILITY_ENCRYPTION = 1 << 0,
CAPABILITY_RELIABILITY = 1 << 1,
CAPABILITY_SESSION = 1 << 2,
CAPABILITY_ADDRESSING = 1 << 3,
CAPABILITY_FRAGMENTATION = 1 << 4,
};
/**
* A struct to hold metadata about a packet.
* @param info Additional information about the packet
* @param source_address With an addressing layer, the sender of a received packet; 0 if not known
* @param destination_address With an addressing layer, the destination of a packet; when sending, 0 uses the
* configured destination
* @param rssi The RSSI of the packet
* @param snr The SNR of the packet
* @param mac_address The MAC address of the sender/receiver
*/
class PacketMetaData {
public:
StringRef info{};
uint16_t source_address{0};
uint16_t destination_address{0};
float rssi{NAN};
float snr{NAN};
uint8_t mac_address[MAC_ADDRESS_SIZE]{};
};
/**
* A class that will transport data to and from an abstract channel.
*
* The operations defined are:
*
* Transmit: the function send_to_interface() takes a PacketBuffer of bytes, and returns when all data is
* written or buffered for transmission.
*
* Receive: The callbacks for an instance of this class are called with currently received data.
*/
class PacketInterface : public Component {
public:
/**
*
* Method to be implemented by child classes to send data to the interface.
*
* @param data The data to be sent
* @param meta_data Metadata about the packet to be sent
* @return True if the data was queued for transmission, false if it could not be sent
*/
virtual bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) = 0;
/// The largest packet this layer accepts; fixed when the layer is constructed.
size_t get_max_packet_size() const { return this->max_packet_size_; }
/// How many packets send_to_interface() would accept now, so a layer above can tell before sending a series.
virtual size_t get_send_capacity() const { return SIZE_MAX; }
/// The capabilities of this layer and every layer below it.
uint8_t get_capabilities() const { return this->capabilities_; }
bool has_capability(Capability capability) const { return (this->capabilities_ & capability) != 0; }
/**
* With CAPABILITY_SESSION, the callback is called with true when the peer can be reached, including after it
* restarts, and with false when it is lost. The session layer is always the top layer, so register on top().
* A reliability layer calls it with false when it resets the link and discards data, which the session layer above
* reports as the session lost.
*/
template<typename F> void add_on_session_state_callback(F &&callback) {
this->session_callback_.add(std::forward<F>(callback));
}
/// The topmost layer stacked on this one, or this one if nothing is stacked on it.
PacketInterface *top() {
PacketInterface *layer = this;
while (layer->upper_ != nullptr)
layer = layer->upper_;
return layer;
}
/// Name shown in log messages; a layer takes the name of the link it is stacked on.
void set_name(const char *name) { this->name_ = name; }
const char *get_name() const { return this->name_; }
/**
* Add a listener that will be called when data is received.
*
* @param callback The callback to be called when data is received.
*/
template<typename F> void add_packet_interface_listener(F &&callback) {
this->callback_.add(std::forward<F>(callback));
}
void dump_config() override;
protected:
/// @param max_packet_size The largest packet the layer accepts, set from the layer below or the layer's own buffer
explicit PacketInterface(size_t max_packet_size) : max_packet_size_(max_packet_size) {}
virtual ~PacketInterface() = default;
/**
* This function should be called by implementing classes with received data to be passed to any listeners.
*
* @param data The received data
* @param meta_data Metadata about the received packet
*/
void on_receive_from_interface_(const PacketBuffer &data, const PacketMetaData &meta_data);
/**
* Called by a layer for each received packet it understood, or could not make sense of. A run of packets that
* cannot be understood usually means the peer has different layers configured, which is logged once per run.
*/
void note_received_(bool understood);
/// Called by a layer's constructor to record itself as the layer above \p lower, adding its own capability.
void stack_on_(PacketInterface *lower, Capability capability) {
lower->upper_ = this;
this->inherit_from_(lower, capability);
}
/// Takes the name and capabilities of `lower` without becoming its top, for one of several stacks on a link.
void inherit_from_(PacketInterface *lower, Capability capability) {
this->name_ = lower->name_;
this->capabilities_ = lower->capabilities_ | capability;
// A link that has a session of its own, such as TCP, reports it to whatever is stacked above, as does a
// reliability layer that discards data; the session layer handles the latter itself
if ((lower->has_capability(CAPABILITY_SESSION) || lower->has_capability(CAPABILITY_RELIABILITY)) &&
capability != CAPABILITY_SESSION) {
lower->add_on_session_state_callback([this](bool connected) { this->session_callback_.call(connected); });
}
}
LazyCallbackManager<void(const PacketBuffer &, const PacketMetaData &)> callback_;
LazyCallbackManager<void(bool)> session_callback_;
PacketInterface *upper_{nullptr};
const char *name_{""};
const size_t max_packet_size_;
uint8_t capabilities_{0};
uint8_t not_understood_count_{0};
};
} // namespace esphome::packet_interface
@@ -1,35 +0,0 @@
import esphome.codegen as cg
from esphome.components.packet_transport import (
PacketTransport,
new_packet_transport,
transport_schema,
)
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
CONF_PACKET_INTERFACE,
PacketInterface,
final_validate_interface,
packet_interface_ns,
)
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["packet_interface"]
PacketInterfaceTransport = packet_interface_ns.class_(
"PacketInterfaceTransport", PacketTransport
)
CONFIG_SCHEMA = transport_schema(PacketInterfaceTransport).extend(
{
cv.GenerateID(CONF_PACKET_INTERFACE): cv.use_id(PacketInterface),
}
)
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])
await new_packet_transport(config, interface)
@@ -1,29 +0,0 @@
#include "packet_interface_transport.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.transport");
void PacketInterfaceTransport::setup() {
PacketTransport::setup();
this->interface_ = this->interface_->top();
this->interface_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &) { this->receive_(data); });
}
void PacketInterfaceTransport::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;
}
this->process_(data.as_span());
}
void PacketInterfaceTransport::send_packet(const std::vector<uint8_t> &buf) const {
if (!this->interface_->send_to_interface(PacketBuffer(buf), {}))
ESP_LOGD(TAG, "Failed to send packet of %zu bytes", buf.size());
}
} // namespace esphome::packet_interface
@@ -1,29 +0,0 @@
#pragma once
#include "esphome/components/packet_interface/packet_interface.h"
#include "esphome/components/packet_transport/packet_transport.h"
namespace esphome::packet_interface {
/**
* A transport protocol adapter that bridges PacketInterface to PacketTransport.
* This allows packet_transport functionality to be used with any PacketInterface implementation.
*/
class PacketInterfaceTransport : public packet_transport::PacketTransport {
public:
explicit PacketInterfaceTransport(PacketInterface *interface) : interface_(interface) {}
void setup() override;
float get_setup_priority() const override { return setup_priority::PROCESSOR; }
protected:
// implementing PacketTransport virtual methods
size_t get_max_packet_size() override { return this->interface_->top()->get_max_packet_size(); }
void send_packet(const std::vector<uint8_t> &buf) const override;
void receive_(const PacketBuffer &data);
PacketInterface *interface_;
};
} // namespace esphome::packet_interface
@@ -1,54 +0,0 @@
#include "radio_link.h"
#include <cinttypes>
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.radio");
bool RadioLinkBase::check_size_(size_t size) const {
if (size <= this->max_packet_size_)
return true;
ESP_LOGW(TAG, "Packet of %zu bytes is larger than %zu", size, this->max_packet_size_);
return false;
}
bool RadioLinkBase::may_send_(bool busy) {
uint32_t now = App.get_loop_component_start_time();
if (busy)
this->enable_loop();
if (!this->listen_before_talk_.may_send(busy, now))
return false;
if (busy) {
// The limit is longer than any packet takes, so the radio's busy indication is stuck or the channel is jammed
ESP_LOGW(TAG, "Radio busy for %" PRIu32 "ms, sending anyway", this->listen_before_talk_.busy_for(now));
}
return true;
}
bool RadioLinkBase::skip_empty_() const {
ESP_LOGV(TAG, "Empty packet not sent");
return true;
}
void RadioLinkBase::radio_not_ready_(bool failed) {
if (!failed || this->failed_warned_)
return;
this->failed_warned_ = true;
ESP_LOGW(TAG, "%s: The radio failed to set up, so nothing can be sent", this->name_);
}
void RadioLinkBase::radio_free_() {
this->listen_before_talk_.end_busy();
this->disable_loop();
}
void RadioLinkBase::receive_(const std::vector<uint8_t> &packet, float rssi, float snr) {
PacketMetaData meta_data;
meta_data.rssi = rssi;
meta_data.snr = snr;
this->on_receive_from_interface_(PacketBuffer(packet.data(), packet.size()), meta_data);
}
} // namespace esphome::packet_interface
@@ -1,87 +0,0 @@
#pragma once
#include <vector>
#include "esphome/components/packet_interface/listen_before_talk.h"
#include "esphome/components/packet_interface/packet_interface.h"
namespace esphome::packet_interface {
/// The parts of RadioLink that do not depend on the radio.
class RadioLinkBase : public PacketInterface {
public:
void set_max_busy(uint32_t max_busy_ms) { this->listen_before_talk_.set_max_busy(max_busy_ms); }
protected:
explicit RadioLinkBase(size_t max_packet_size) : PacketInterface(max_packet_size) {}
/// False if a packet of this size is too large.
bool check_size_(size_t size) const;
/// False if sending must wait, as the radio is busy; starts loop() to watch the radio.
bool may_send_(bool busy);
/// Returns true for an empty packet, which the radios cannot send, so there is nothing to retry.
bool skip_empty_() const;
/// Warns once when a send finds the radio failed; until its setup has run, sends are refused quietly.
void radio_not_ready_(bool failed);
/// Ends the busy period when loop() finds the radio free.
void radio_free_();
void receive_(const std::vector<uint8_t> &packet, float rssi, float snr);
ListenBeforeTalk listen_before_talk_;
bool failed_warned_{false};
};
/**
* A packet interface link over a packet radio: each packet is one radio packet, with the RSSI and SNR of received
* packets in the metadata. Sending waits for the radio to finish transmitting. A radio cannot hear while it sends, so
* sending is refused while the radio is receiving a packet. A reliability layer sends the packet again shortly, and
* entity_transport after a delay; other callers drop it, logging at debug level at most. While a busy period lasts,
* loop() watches the radio and ends the period as soon as the radio is free, so a later reception starts a new one.
*
* Radio provides is_receiving(), register_listener(Listener *) and transmit_packet(const uint8_t *, size_t), which
* returns Error::NONE on success. Listener declares on_packet(packet, rssi, snr). MaxPacketSize is the radio's limit.
*/
template<typename Radio, typename Listener, typename Error, size_t MaxPacketSize>
class RadioLink : public RadioLinkBase, public Listener {
public:
RadioLink(Radio *radio, size_t max_packet_size) : RadioLinkBase(max_packet_size), radio_(radio) {}
void setup() override {
this->radio_->register_listener(this);
this->disable_loop();
}
// Runs only during a busy period; a busy flag that never clears keeps it running. A radio that failed meanwhile
// is not read again, as reading it may wait on the radio every time
void loop() override {
if (!this->radio_->is_ready() || !this->radio_->is_receiving())
this->radio_free_();
}
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override {
// The radio is set up after the layers above, which may send from their own setup
if (!this->radio_->is_ready()) {
this->radio_not_ready_(this->radio_->is_failed());
return false;
}
size_t size = data.size();
if (size == 0)
return this->skip_empty_();
if (!this->check_size_(size) || !this->may_send_(this->radio_->is_receiving()))
return false;
if (data.is_contiguous()) {
auto span = data.as_span();
return this->radio_->transmit_packet(span.data(), span.size()) == Error::NONE;
}
uint8_t buf[MaxPacketSize];
data.copy_to(buf, size);
return this->radio_->transmit_packet(buf, size) == Error::NONE;
}
void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) override {
this->receive_(packet, rssi, snr);
}
protected:
Radio *radio_;
};
} // namespace esphome::packet_interface
@@ -1,138 +0,0 @@
"""Shared code generation for the packet interface links over packet radios, such as the SX126x and SX127x."""
from collections.abc import Callable
import math
import esphome.codegen as cg
from esphome.components.const import CONF_MAX_PACKET_SIZE
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core import CORE, EsphomeError
from esphome.cpp_generator import MockObjClass
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import new_packet_interface, packet_interface_schema
from .layers import CONF_ADDRESSING, CONF_PEERS, STACK_ORDER
# The radios' payload length register is 8 bits
MAX_PACKET_SIZE = 255
# Listen before talk holds a send back for at most the airtime of the largest packet, and never less than this
MIN_MAX_BUSY_MS = 2000
# Room for the preamble detection and the radio's own delays
MARGIN = 1.25
def packet_airtime_ms(radio: ConfigType, size: int) -> float:
"""The time a radio configured as in radio takes to send a packet of size bytes.
LoRa follows the Semtech formula for an explicit header; the low data rate optimization is assumed when a symbol
takes more than 16 ms, as the drivers enable it. Other modulations count the preamble, sync word, length byte,
payload and CRC at the bitrate, treating the preamble size as bytes, which gives the longer time.
"""
if str(radio["modulation"]) == "LORA":
spreading_factor = radio["spreading_factor"]
bandwidth_hz = (
float(str(radio["bandwidth"]).removesuffix("kHz").replace("_", ".")) * 1000
)
coding_rate = int(str(radio["coding_rate"])[-1]) - 4
symbol_s = (1 << spreading_factor) / bandwidth_hz
low_data_rate = 1 if symbol_s > 0.016 else 0
crc = 16 if radio.get("crc_enable", False) else 0
payload_symbols = 8 + max(
math.ceil(
(8 * size - 4 * spreading_factor + 28 + crc)
/ (4 * (spreading_factor - 2 * low_data_rate))
)
* (coding_rate + 4),
0,
)
return (radio["preamble_size"] + 4.25 + payload_symbols) * symbol_s * 1000
bytes_sent = (
radio["preamble_size"] + len(radio.get("sync_value", [])) + 1 + size + 2
)
return bytes_sent * 8 / radio["bitrate"] * 1000
def max_busy_ms(radio: ConfigType, max_packet_size: int) -> int:
"""How long listen before talk may hold a send back."""
return max(
MIN_MAX_BUSY_MS, math.ceil(packet_airtime_ms(radio, max_packet_size) * MARGIN)
)
def radio_link_schema(
class_: MockObjClass, radio_id_key: str, radio_class: MockObjClass
) -> cv.Schema:
"""Schema for a radio link, referring to its radio by radio_id_key."""
return packet_interface_schema(class_, MAX_PACKET_SIZE, MAX_PACKET_SIZE).extend(
{cv.GenerateID(radio_id_key): cv.use_id(radio_class)}
)
def _radio_config(config: ConfigType, radio_id_key: str) -> ConfigType:
full_config = fv.full_config.get()
return full_config.get_config_for_path(
full_config.get_path_for_id(config[radio_id_key])[:-1]
)
def _two_way_layer(config: ConfigType) -> str | None:
"""The first layer on the link or in a peer that keeps state for one peer, so needs packets back from it."""
for stack in (config, *config.get(CONF_ADDRESSING, {}).get(CONF_PEERS, [])):
for kind in STACK_ORDER:
if (layer := stack.get(kind.key)) is not None and kind.get_type(
layer
).single_peer(layer):
return kind.key
return None
def radio_link_final_validate(
radio_id_key: str,
payload_length_key: str,
rx_start_key: str,
check: Callable[[ConfigType, ConfigType], None] | None = None,
) -> Callable[[ConfigType], ConfigType]:
"""Final validation for a radio link: packets vary in length, the radio listens after sending when a layer needs
replies, and check(link, radio) checks the rest.
"""
def validator(config: ConfigType) -> ConfigType:
radio = _radio_config(config, radio_id_key)
if radio[payload_length_key] != 0:
raise cv.Invalid(
f"Packets vary in length, so the radio's '{payload_length_key}' must be 0",
path=[radio_id_key],
)
if not radio[rx_start_key] and (layer := _two_way_layer(config)) is not None:
raise cv.Invalid(
f"This '{layer}' needs packets from the peer, but the radio sleeps after sending unless "
f"'{rx_start_key}' is true",
path=[radio_id_key],
)
if check is not None:
check(config, radio)
return config
return validator
async def new_radio_link(
config: ConfigType, radio_id_key: str, radio_domain: str
) -> None:
"""Create a radio link, with its listen before talk limit worked out from the radio's settings."""
radio = await cg.get_variable(config[radio_id_key])
var = await new_packet_interface(config, radio, config[CONF_MAX_PACKET_SIZE])
radio_config = next(
(
conf
for conf in CORE.config[radio_domain]
if conf[CONF_ID] == config[radio_id_key]
),
None,
)
if radio_config is None:
raise EsphomeError(f"No {radio_domain} radio with id '{config[radio_id_key]}'")
cg.add(var.set_max_busy(max_busy_ms(radio_config, config[CONF_MAX_PACKET_SIZE])))
@@ -1,182 +0,0 @@
#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
@@ -1,61 +0,0 @@
#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
@@ -205,10 +205,8 @@ async def register_packet_transport(var: MockObj, config: ConfigType) -> set[str
return providers
async def new_packet_transport(
config: ConfigType, *args: Any
) -> tuple[MockObj, set[str]]:
var = cg.new_Pvariable(config[CONF_ID], *args)
async def new_packet_transport(config: ConfigType) -> tuple[MockObj, set[str]]:
var = cg.new_Pvariable(config[CONF_ID])
cg.add(var.set_platform_name(config[CONF_PLATFORM]))
providers = await register_packet_transport(var, config)
return var, providers
@@ -547,11 +547,9 @@ void PacketTransport::dump_config() {
ESP_LOGCONFIG(TAG,
"Packet Transport:\n"
" Platform: %s\n"
" Max Packet Size: %zu\n"
" Encrypted: %s\n"
" Ping-pong: %s",
this->platform_name_, this->get_max_packet_size(), YESNO(this->is_encrypted_()),
YESNO(this->ping_pong_enable_));
this->platform_name_, YESNO(this->is_encrypted_()), YESNO(this->ping_pong_enable_));
#ifdef USE_SENSOR
for (const auto &sensor : this->sensors_) {
ESP_LOGCONFIG(TAG, " Sensor: %s", sensor.id);
+1 -11
View File
@@ -92,17 +92,7 @@ class JPEGFormat(Format):
def actions(self) -> None:
cg.add_define("USE_RUNTIME_IMAGE_JPEG")
# 1.8.4 plus decodeBegin()/decodeRows(), so an image can be decoded a few rows at a time and
# can wait for input that has not arrived yet, and fixes for: input that arrives in short
# reads, garbled RGB8888 output of grayscale images, and two ARM NEON faults seen on arm64 hosts
# (swapped red and blue in the 4:2:0 RGB8888 path, writes past the row on clipped MCUs). It also
# uses the ESP32-S3 SIMD colour conversion in ESP-IDF builds, not only Arduino ones, and is built
# with -O2.
cg.add_library(
"JPEGDEC",
"1.8.4",
"https://github.com/esphome-libs/JPEGDEC#1.8.4-esphome.3",
)
cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4")
if CORE.is_host:
# JPEGDEC's host detection checks __MACH__/__LINUX__, but gcc only
# predefines the lowercase __linux__; without this a Linux host
@@ -1,6 +1,4 @@
#pragma once
#include <cstdint>
#include "esphome/core/color.h"
#include "image_format.h"
@@ -11,14 +9,6 @@ enum DecodeError : int {
DECODE_ERROR_UNSUPPORTED_FORMAT = -2,
DECODE_ERROR_OUT_OF_MEMORY = -3,
DECODE_ERROR_INTERNAL_DECODER_ERROR = -4,
DECODE_ERROR_READ = -5,
};
/// Results of ImageDecoder::decode_step() other than a DecodeError
enum DecodeStep : int {
DECODE_STEP_DONE = 0,
DECODE_STEP_MORE = 1,
DECODE_STEP_NEED_DATA = 2, // The source has nothing more yet; call again once more has arrived
};
constexpr const char *decode_error_to_string(int error) {
@@ -31,8 +21,6 @@ constexpr const char *decode_error_to_string(int error) {
return "Out of memory";
case DECODE_ERROR_INTERNAL_DECODER_ERROR:
return "Internal decoder error";
case DECODE_ERROR_READ:
return "Read error";
default:
return "Unknown error";
}
@@ -40,19 +28,6 @@ constexpr const char *decode_error_to_string(int error) {
class RuntimeImage;
/// Input that a decoder reads itself as it goes, so the whole image never has to be in memory at once
class DecodeSource {
public:
virtual ~DecodeSource() = default;
/**
* Read up to len bytes at the current position. Returns the number read, which is less than len, even 0, when
* no more has arrived yet or at the end of the input, or negative on error.
*/
virtual int32_t read(uint8_t *buf, uint32_t len) = 0;
/// Move to a position counted from the start. Returns false on error.
virtual bool seek(uint32_t position) = 0;
};
/**
* @brief Class to abstract decoding different image formats.
*/
@@ -113,28 +88,6 @@ class ImageDecoder {
*/
virtual int decode(uint8_t *buffer, size_t size) = 0;
/// Whether this decoder can read a DecodeSource itself, a step at a time, instead of being fed data.
virtual bool supports_source() const { return false; }
/**
* @brief Start decoding from a source the decoder reads itself. Call after prepare(), in place of decode().
*
* The image headers (everything before the image data) must be readable from the source when this is called;
* only decode_step() can wait for more to arrive. The source must hold expected_size bytes in the end, and stay
* valid until the session ends.
* @return int 0 on success, or a {@see DecodeError} value.
*/
virtual int begin_source(DecodeSource *source) { return DECODE_ERROR_UNSUPPORTED_FORMAT; }
/**
* @brief Decode a little more of an image started with begin_source(), keeping each call short.
*
* @return int DECODE_STEP_MORE while there is more to do, DECODE_STEP_NEED_DATA when the source has not
* delivered enough to go on, DECODE_STEP_DONE when the image is complete, or a {@see DecodeError}
* value.
*/
virtual int decode_step() { return DECODE_ERROR_UNSUPPORTED_FORMAT; }
/**
* @brief Request the image to be resized once the actual dimensions are known.
* Called by the callback functions, to be able to access the parent Image class.
@@ -159,9 +112,6 @@ class ImageDecoder {
*/
void draw(int x, int y, int w, int h, const Color &color);
/// Bytes of the image the decoder has used so far
size_t get_decoded_bytes() const { return this->decoded_bytes_; }
/**
* @brief Check if the decoder has finished processing.
*
+20 -132
View File
@@ -135,24 +135,13 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
return DECODE_ERROR_INVALID_TYPE;
}
const int err = this->start_();
if (err != 0)
return err;
if (!this->jpeg_.decode(0, 0, this->scale_option_()))
return this->fail_();
this->decoded_bytes_ = size;
this->jpeg_.close();
return size;
}
int JpegDecoder::start_() {
auto jpeg_type = this->jpeg_.getJPEGType();
if (jpeg_type == JPEG_MODE_INVALID) {
ESP_LOGE(TAG, "Unsupported JPEG image");
return DECODE_ERROR_INVALID_TYPE;
} else if (jpeg_type == JPEG_MODE_PROGRESSIVE) {
ESP_LOGE(TAG, "Progressive JPEG images not supported");
return DECODE_ERROR_UNSUPPORTED_FORMAT;
return DECODE_ERROR_INVALID_TYPE;
}
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp());
@@ -165,133 +154,32 @@ int JpegDecoder::start_() {
} else {
this->jpeg_.setPixelType(RGB8888);
}
const int width = this->jpeg_.getWidth();
const int height = this->jpeg_.getHeight();
if (!this->set_size(width, height)) {
if (!this->set_size(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
return DECODE_ERROR_OUT_OF_MEMORY;
}
// JPEGDEC can decode at 1/2, 1/4 or 1/8 size for much less work. Use the smallest size that still has at least as
// many pixels as the buffer, so a large image shown small is not decoded in full only to be thrown away.
const int buffer_width = this->image_->get_buffer_width();
const int buffer_height = this->image_->get_buffer_height();
this->scale_ = 1;
while (this->scale_ < JPEG_SCALE_EIGHTH && width / (this->scale_ * 2) >= buffer_width &&
height / (this->scale_ * 2) >= buffer_height) {
this->scale_ *= 2;
}
const int decoded_width = (width + this->scale_ - 1) / this->scale_;
const int decoded_height = (height + this->scale_ - 1) / this->scale_;
this->x_scale_ = static_cast<double>(buffer_width) / decoded_width;
this->y_scale_ = static_cast<double>(buffer_height) / decoded_height;
if (this->scale_ != 1) {
ESP_LOGD(TAG, "Decoding at 1/%d size, %d x %d", this->scale_, decoded_width, decoded_height);
}
if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(decoded_width, decoded_height)) {
if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
return DECODE_ERROR_OUT_OF_MEMORY;
}
return 0;
}
int JpegDecoder::fail_() {
const bool read_failed = this->source_ != nullptr && this->source_failed_;
const int error = this->jpeg_.getLastError();
this->jpeg_.close();
this->source_ = nullptr;
if (read_failed) {
ESP_LOGE(TAG, "Reading the image failed");
return DECODE_ERROR_READ;
}
ESP_LOGE(TAG, "Error while decoding: %d", error);
switch (error) {
case JPEG_ERROR_MEMORY:
return DECODE_ERROR_OUT_OF_MEMORY;
case JPEG_UNSUPPORTED_FEATURE:
return DECODE_ERROR_UNSUPPORTED_FORMAT;
case JPEG_INVALID_FILE:
case JPEG_INVALID_PARAMETER:
return DECODE_ERROR_INVALID_TYPE;
case JPEG_DECODE_ERROR:
default:
return DECODE_ERROR_INTERNAL_DECODER_ERROR;
}
}
void JpegDecoder::reset() {
// A decode from a source that was given up part way still has the image open
if (this->source_ != nullptr) {
if (!this->jpeg_.decode(0, 0, 0)) {
auto error = this->jpeg_.getLastError();
ESP_LOGE(TAG, "Error while decoding: %d", error);
this->jpeg_.close();
this->source_ = nullptr;
switch (error) {
case JPEG_ERROR_MEMORY:
return DECODE_ERROR_OUT_OF_MEMORY;
case JPEG_UNSUPPORTED_FEATURE:
return DECODE_ERROR_UNSUPPORTED_FORMAT;
case JPEG_INVALID_FILE:
case JPEG_INVALID_PARAMETER:
return DECODE_ERROR_INVALID_TYPE;
case JPEG_DECODE_ERROR:
default:
return DECODE_ERROR_INTERNAL_DECODER_ERROR;
}
}
ImageDecoder::reset();
}
int32_t JpegDecoder::source_read(JPEGFILE *file, uint8_t *buf, int32_t len) {
auto *decoder = static_cast<JpegDecoder *>(file->fHandle);
const int32_t n = decoder->source_->read(buf, len);
if (n < 0) {
// JPEGDEC has no way to report a failed read, so remember it and give it no data
decoder->source_failed_ = true;
return 0;
}
file->iPos += n;
return n;
}
int32_t JpegDecoder::source_seek(JPEGFILE *file, int32_t position) {
auto *decoder = static_cast<JpegDecoder *>(file->fHandle);
if (position < 0 || !decoder->source_->seek(position)) {
decoder->source_failed_ = true;
return file->iPos;
}
file->iPos = position;
return position;
}
int JpegDecoder::begin_source(DecodeSource *source) {
if (this->expected_size_ == 0 || this->expected_size_ > INT32_MAX) {
ESP_LOGE(TAG, "Image size %zu cannot be read from a source", this->expected_size_);
return DECODE_ERROR_INVALID_TYPE;
}
if (!source->seek(0)) {
ESP_LOGE(TAG, "Could not read the image");
return DECODE_ERROR_READ;
}
this->source_ = source;
this->source_failed_ = false;
if (!this->jpeg_.open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
// The caller may recover, for example by reading the whole image first, so it reports the failure
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
this->source_ = nullptr;
return this->source_failed_ ? DECODE_ERROR_READ : DECODE_ERROR_INVALID_TYPE;
}
const int err = this->start_();
if (err != 0) {
this->jpeg_.close();
this->source_ = nullptr;
return err;
}
if (!this->jpeg_.decodeBegin(0, 0, this->scale_option_()))
return this->fail_();
return 0;
}
int HOT JpegDecoder::decode_step() {
if (this->source_ == nullptr)
return DECODE_ERROR_INVALID_TYPE;
const int result = this->jpeg_.decodeRows(1);
// JPEGDEC may decode on with the missing data, so check the read before its result
if (this->source_failed_)
return this->fail_();
if (result == JPEG_DECODE_MORE)
return DECODE_STEP_MORE;
if (result == JPEG_DECODE_NEED_DATA)
return DECODE_STEP_NEED_DATA;
if (result == 0)
return this->fail_();
this->decoded_bytes_ = this->expected_size_;
this->decoded_bytes_ = size;
this->jpeg_.close();
this->source_ = nullptr;
return DECODE_STEP_DONE;
return size;
}
} // namespace esphome::runtime_image
@@ -22,41 +22,19 @@ class JpegDecoder : public ImageDecoder {
~JpegDecoder() override {}
int HOT decode(uint8_t *buffer, size_t size) override;
void reset() override;
/// The source is read as the decode needs it, through JPEGDEC's own 2 KB buffer
bool supports_source() const override { return true; }
int begin_source(DecodeSource *source) override;
/// Decodes one row of MCUs (8 or 16 pixel rows), or waits part way through one for more of the source
int HOT decode_step() override;
bool uses_direct_rgb565() const { return this->direct_rgb565_; }
/// JPEGDEC decodes the image at 1/get_scale() of its size: 1, 2, 4 or 8
int get_scale() const { return this->scale_; }
void draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels);
protected:
bool prepare_rgb565_scaling_(int width, int height);
/// Set up an opened image for decoding. Returns 0, or a DecodeError.
int start_();
/// The JPEGDEC option for scale_: JPEG_SCALE_HALF, _QUARTER and _EIGHTH are the divisors themselves
int scale_option_() const { return this->scale_ == 1 ? 0 : this->scale_; }
/// The DecodeError for a failed read of the source or JPEGDEC's last error, closing the image
int fail_();
static int32_t source_read(JPEGFILE *file, uint8_t *buf, int32_t len);
static int32_t source_seek(JPEGFILE *file, int32_t position);
static void source_close(void *handle) {}
JPEGDEC jpeg_{};
FixedVector<uint16_t> x_boundaries_{};
FixedVector<uint16_t> y_boundaries_{};
int source_width_{0};
int source_height_{0};
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
bool direct_rgb565_{false};
bool source_failed_{false};
};
} // namespace esphome::runtime_image
@@ -239,25 +239,6 @@ int RuntimeImage::feed_data(uint8_t *data, size_t len) {
return consumed;
}
int RuntimeImage::begin_source(DecodeSource *source) {
if (!this->decoder_supports_source()) {
ESP_LOGE(TAG, "Decoder cannot read a source");
return DECODE_ERROR_UNSUPPORTED_FORMAT;
}
return this->decoder_->begin_source(source);
}
int RuntimeImage::decode_step() {
if (!this->is_decoding()) {
ESP_LOGE(TAG, "No decoder initialized");
return DECODE_ERROR_INVALID_TYPE;
}
const int result = this->decoder_->decode_step();
if (result == DECODE_STEP_DONE)
this->decoded_bytes_ = this->decoder_->get_decoded_bytes();
return result;
}
bool RuntimeImage::end_decode() {
if (!this->is_decoding()) {
return false;
@@ -76,22 +76,6 @@ class RuntimeImage : public image::Image {
*/
int feed_data(uint8_t *data, size_t len);
/// Whether the active decoder can read a DecodeSource itself (see begin_source()).
bool decoder_supports_source() const { return this->is_decoding() && this->decoder_->supports_source(); }
/**
* @brief Decode from a source the decoder reads itself, in place of feed_data().
*
* Call after begin_decode() when decoder_supports_source() is true, once the image headers can be read from the
* source, then call decode_step() until it
* returns DECODE_STEP_DONE, and finish with end_decode().
* @return 0 on success, or a DecodeError value.
*/
int begin_source(DecodeSource *source);
/// Decode a little more from the source; see ImageDecoder::decode_step().
int decode_step();
/**
* @brief Complete the decoding process.
*
+38 -393
View File
@@ -1,226 +1,79 @@
#include "rx8111.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::rx8111 {
ESPHOME_LOG_TAG(TAG, "rx8111");
// Indexed by the TSEL value stored in timer_clock_
PROGMEM_STRING_TABLE(TimerClockNames, "4096 Hz", "64 Hz", "1 Hz", "1/60 Hz");
static constexpr uint8_t RX8111_REG_SEC = 0x10;
static constexpr uint8_t RX8111_REG_MIN_ALARM = 0x17;
static constexpr uint8_t RX8111_REG_HOUR_ALARM = 0x18;
static constexpr uint8_t RX8111_REG_DAY_ALARM = 0x19;
static constexpr uint8_t RX8111_REG_EXTENSION = 0x1D;
static constexpr uint8_t RX8111_REG_FLAG = 0x1E;
static constexpr uint8_t RX8111_REG_TIMER0 = 0x1A;
static constexpr uint8_t RX8111_REG_CONTROL = 0x1F;
static constexpr uint8_t RX8111_REG_SEC_ALARM = 0x2C;
static constexpr uint8_t RX8111_ALARM_DISABLED = 0x80;
static constexpr uint8_t RX8111_EXTENSION_WADA = 0x08;
static constexpr uint8_t RX8111_CONTROL_AIE = 0x08;
static constexpr uint8_t RX8111_CONTROL_TIE = 0x10;
static constexpr uint8_t RX8111_EXTENSION_TE = 0x10;
static constexpr uint8_t RX8111_EXTENSION_TSEL = 0x03;
static constexpr uint8_t RX8111_FLAG_TF = 0x10;
static constexpr uint8_t RX8111_FLAG_AF = 0x08;
static constexpr uint8_t RX8111_FLAG_POR = 0x80;
static constexpr uint8_t RX8111_FLAG_VLF = 0x02;
static constexpr uint8_t RX8111_FLAG_XST = 0x01;
static constexpr uint8_t RX8111_CTRL_STOP = 0x01;
// FLAG bits are write-zero-to-clear; keep UF/TF/AF/EVF even if they latch during the write.
static constexpr uint8_t RX8111_KEEP_EVENT_FLAGS = 0x3C;
static constexpr uint32_t RX8111_EVENT_INTERVAL_ID = 0;
static constexpr uint32_t RX8111_BOOT_ALARM_RETRY_ID = 1;
static constexpr uint32_t RX8111_BOOT_TIMER_RETRY_ID = 2;
static constexpr uint8_t RX8111_BOOT_MAX_ATTEMPTS = 3;
void RX8111Component::setup() {
this->configure_boot_alarm_();
this->configure_boot_timer_();
this->read_time();
if (this->alarm_enabled_ || this->watching_()) {
this->start_event_polling_();
}
}
void RX8111Component::setup() { this->read_time(); }
void RX8111Component::configure_boot_alarm_() {
bool success = true;
if (this->alarm_enabled_) {
if (this->alarm_second_ != ALARM_FIELD_UNSET || this->alarm_minute_ != ALARM_FIELD_UNSET ||
this->alarm_hour_ != ALARM_FIELD_UNSET || this->alarm_weekday_ != ALARM_FIELD_UNSET ||
this->alarm_day_ != ALARM_FIELD_UNSET) {
success = this->program_alarm_(this->alarm_second_, this->alarm_minute_, this->alarm_hour_, this->alarm_weekday_,
this->alarm_day_, true);
if (success) {
// Only the first successful boot program can introduce a boot wake-up edge.
if (!this->boot_alarm_event_seen_)
this->alarm_flag_seen_ = false;
this->start_event_polling_();
}
} else {
// Preserve an alarm programmed by an action before a reboot or deep sleep.
uint8_t control;
success = this->read_byte(RX8111_REG_CONTROL, &control);
if (success) {
this->alarm_armed_ = (control & RX8111_CONTROL_AIE) != 0;
if (this->alarm_armed_)
this->start_event_polling_();
}
}
} else if (this->alarm_explicitly_configured_) {
success = this->set_alarm_interrupt_(false);
}
this->boot_config_result_(true, success);
}
void RX8111Component::configure_boot_timer_() {
bool success = true;
// An omitted timer setting leaves battery-backed state alone; explicit false stops it.
if (this->timer_configured_) {
const bool start_timer = this->timer_enabled_;
if (start_timer) {
success = this->configure_timer_();
} else {
uint8_t extension, control;
success = this->disable_timer_(extension, control);
}
if (success && !start_timer && this->timer_count_ != 0) {
this->timer_retained_event_pending_ = true;
this->start_event_polling_();
}
}
this->boot_config_result_(false, success);
}
void RX8111Component::boot_config_result_(bool alarm, bool success) {
auto &attempts = alarm ? this->boot_alarm_attempts_ : this->boot_timer_attempts_;
const uint8_t warning = alarm ? WARN_ALARM : WARN_TIMER;
++attempts;
if (success) {
this->clear_warning_(warning);
return;
}
this->set_warning_(warning, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
if (attempts >= RX8111_BOOT_MAX_ATTEMPTS)
return;
this->set_timeout(alarm ? RX8111_BOOT_ALARM_RETRY_ID : RX8111_BOOT_TIMER_RETRY_ID, this->boot_retry_delay_ms_,
[this, alarm]() {
if (alarm) {
this->configure_boot_alarm_();
} else {
this->configure_boot_timer_();
}
});
}
// A runtime action supersedes pending boot programming of the same feature
void RX8111Component::cancel_boot_retry_(bool alarm) {
this->cancel_timeout(alarm ? RX8111_BOOT_ALARM_RETRY_ID : RX8111_BOOT_TIMER_RETRY_ID);
}
void RX8111Component::check_event_flags_() {
if (this->watching_() && !this->poll_event_flags_())
this->set_warning_(WARN_EVENT_POLL, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
// A callback or a runtime action may have stopped the last watched feature
if (!this->watching_() && this->event_polling_started_) {
this->cancel_interval(RX8111_EVENT_INTERVAL_ID);
this->event_polling_started_ = false;
}
}
void RX8111Component::start_event_polling_() {
if (this->event_polling_started_)
return;
this->set_interval(RX8111_EVENT_INTERVAL_ID, this->event_poll_interval_, [this]() { this->check_event_flags_(); });
this->event_polling_started_ = true;
}
void RX8111Component::update() {
this->read_time();
this->check_event_flags_();
}
void RX8111Component::set_warning_(uint8_t bit, const LogString *message) {
this->warnings_ |= bit;
this->status_set_warning(message);
}
void RX8111Component::clear_warning_(uint8_t bit) {
if ((this->warnings_ & bit) == 0)
return;
this->warnings_ &= ~bit;
if (this->warnings_ == 0)
this->status_clear_warning();
}
void RX8111Component::update() { this->read_time(); }
void RX8111Component::dump_config() {
ESP_LOGCONFIG(TAG, "RX8111:");
LOG_I2C_DEVICE(this);
ESP_LOGCONFIG(TAG,
" Alarm interrupt: %s\n"
" Wake-up timer: %s",
ONOFF(this->alarm_enabled_), ONOFF(this->timer_enabled_));
if (this->timer_enabled_) {
ESP_LOGCONFIG(TAG, " Count: %" PRIu32 ", clock: %s", this->timer_count_,
LOG_STR_ARG(TimerClockNames::get_log_str(this->timer_clock_, TimerClockNames::LAST_INDEX)));
}
RealTimeClock::dump_config();
}
void RX8111Component::read_time() {
ESPTime rtc_time;
if (!this->read_calendar_(rtc_time))
return;
ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month,
rtc_time.hour, rtc_time.minute, rtc_time.second);
time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp);
}
bool RX8111Component::read_calendar_(ESPTime &rtc_time) {
uint8_t flags;
if (!this->read_byte(RX8111_REG_FLAG, &flags)) {
this->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (flags & (RX8111_FLAG_VLF | RX8111_FLAG_XST | RX8111_FLAG_POR)) {
ESP_LOGW(TAG, "RTC time invalidated by power loss or oscillator stop; write trusted time");
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC time invalidated; write trusted time"));
return false;
this->status_set_warning(LOG_STR("RTC time invalidated; write trusted time"));
return;
}
uint8_t buf[7];
// I2C access freezes the RX8111 counter, yielding a coherent calendar snapshot.
if (!this->read_bytes(RX8111_REG_SEC, buf, sizeof(buf))) {
this->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
// decode_bcd_calendar() masks the same bits; the check here catches a corrupted register
static constexpr uint8_t CALENDAR_MASKS[7] = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF};
bool valid = true;
for (size_t i = 0; i < sizeof(buf); i++) {
if (i == 3)
continue; // Weekday uses one-hot encoding, not BCD.
buf[i] &= CALENDAR_MASKS[i];
valid = valid && (buf[i] & 0x0F) <= 9 && (buf[i] >> 4) <= 9;
if ((buf[i] & 0x0F) > 9 || (buf[i] >> 4) > 9) {
ESP_LOGW(TAG, "Invalid BCD in RTC time");
this->status_set_warning(LOG_STR("Invalid RTC time"));
return;
}
}
const uint8_t onehot = buf[3];
valid = valid && onehot != 0 && onehot <= 0x40 && (onehot & (onehot - 1)) == 0;
if (valid) {
rtc_time = time::decode_bcd_calendar<time::EPSON_BCD_CALENDAR>(buf);
valid = rtc_time.is_valid(true, false) && rtc_time.second < 60 &&
ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == rtc_time.day_of_week;
if (onehot == 0 || onehot > 0x40 || (onehot & (onehot - 1)) != 0) {
ESP_LOGW(TAG, "Invalid RTC weekday");
this->status_set_warning(LOG_STR("Invalid RTC time"));
return;
}
if (!valid) {
ESPTime rtc_time = time::decode_bcd_calendar<time::EPSON_BCD_CALENDAR>(buf);
if (!rtc_time.is_valid(true, false) || rtc_time.second >= 60) {
ESP_LOGW(TAG, "Invalid RTC time, not syncing to system clock");
this->set_warning_(WARN_CALENDAR, LOG_STR("Invalid RTC time"));
return false;
this->status_set_warning(LOG_STR("Invalid RTC time"));
return;
}
this->clear_warning_(WARN_CALENDAR);
return true;
if (!this->status_comm_result(ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == rtc_time.day_of_week,
LOG_STR("Invalid RTC time"))) {
ESP_LOGW(TAG, "RTC weekday does not match calendar date");
return;
}
ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month,
rtc_time.hour, rtc_time.minute, rtc_time.second);
time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp);
}
void RX8111Component::write_time() {
@@ -230,7 +83,7 @@ void RX8111Component::write_time() {
return;
}
if (!this->set_stop_bit_(true)) {
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
@@ -240,233 +93,25 @@ void RX8111Component::write_time() {
// Always try to restart the counter, even after a failed calendar write.
const bool restarted = this->set_stop_bit_(false);
if (!written || !restarted) {
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)) {
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
if (!this->status_comm_result(this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)))
return;
}
this->clear_warning_(WARN_WRITE_TIME);
this->clear_warning_(WARN_CALENDAR);
ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour,
now.minute, now.second);
}
bool RX8111Component::set_stop_bit_(bool stop) {
return this->update_register(RX8111_REG_CONTROL, RX8111_CTRL_STOP, stop ? RX8111_CTRL_STOP : 0);
}
bool RX8111Component::set_alarm_interrupt_(bool enabled) {
return this->update_register(RX8111_REG_CONTROL, RX8111_CONTROL_AIE, enabled ? RX8111_CONTROL_AIE : 0);
}
bool RX8111Component::program_alarm_(uint8_t second, uint8_t minute, uint8_t hour, uint8_t weekday, uint8_t day,
bool boot) {
uint8_t extension;
if (!this->read_byte(RX8111_REG_EXTENSION, &extension))
return false;
if (weekday != ALARM_FIELD_UNSET) {
extension &= ~RX8111_EXTENSION_WADA;
} else if (day != ALARM_FIELD_UNSET) {
extension |= RX8111_EXTENSION_WADA;
}
const uint8_t fields[] = {
second == ALARM_FIELD_UNSET ? RX8111_ALARM_DISABLED : uint8_to_bcd(second),
minute == ALARM_FIELD_UNSET ? RX8111_ALARM_DISABLED : uint8_to_bcd(minute),
hour == ALARM_FIELD_UNSET ? RX8111_ALARM_DISABLED : uint8_to_bcd(hour),
weekday != ALARM_FIELD_UNSET ? static_cast<uint8_t>(1U << (weekday - 1))
: day != ALARM_FIELD_UNSET ? uint8_to_bcd(day)
: RX8111_ALARM_DISABLED,
};
// Disable the interrupt during partial programming; keep AF (past events) latched.
if (!this->set_alarm_interrupt_(false))
return false;
this->alarm_armed_ = false;
uint8_t flags;
if (!this->read_byte(RX8111_REG_FLAG, &flags))
return false;
if (!boot)
this->alarm_flag_seen_ = (flags & RX8111_FLAG_AF) != 0;
if (!this->write_byte(RX8111_REG_SEC_ALARM, fields[0]) || !this->write_bytes(RX8111_REG_MIN_ALARM, &fields[1], 3) ||
!this->write_byte(RX8111_REG_EXTENSION, extension))
return false;
if (!this->set_alarm_interrupt_(this->alarm_enabled_))
return false;
this->alarm_armed_ = true;
return true;
}
bool RX8111Component::set_alarm(uint16_t second, uint16_t minute, uint16_t hour, uint16_t weekday, uint16_t day) {
if (!this->alarm_enabled_) {
ESP_LOGE(TAG, "Alarm interrupt disabled; set alarm_enabled: true");
return false;
}
constexpr uint16_t unset = ALARM_FIELD_UNSET;
if ((second != unset && second > 59) || (minute != unset && minute > 59) || (hour != unset && hour > 23) ||
(weekday != unset && (weekday < 1 || weekday > 7)) || (day != unset && (day < 1 || day > 31)) ||
(weekday != unset && day != unset) ||
(second == unset && minute == unset && hour == unset && weekday == unset && day == unset)) {
ESP_LOGE(TAG, "Invalid alarm fields");
return false;
}
this->cancel_boot_retry_(true);
if (!this->program_alarm_(second, minute, hour, weekday, day)) {
this->set_warning_(WARN_ALARM, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->clear_warning_(WARN_ALARM);
this->boot_alarm_event_seen_ = true;
this->start_event_polling_();
return true;
}
bool RX8111Component::schedule_alarm_in(uint32_t seconds) {
if (!this->alarm_enabled_ || seconds == 0 || seconds >= 28U * 86400U) {
ESP_LOGE(TAG, "Relative alarm requires alarm_enabled and a duration of 1 second to less than 28 days");
return false;
}
ESPTime now;
if (!this->read_calendar_(now)) {
ESP_LOGE(TAG, "Cannot read valid RTC time for relative alarm");
return false;
}
auto target = ESPTime::from_epoch_utc(now.timestamp + seconds);
if (!target.is_valid() || target.year > 2099) {
ESP_LOGE(TAG, "Relative alarm target is outside RTC year range");
return false;
}
return this->set_alarm(target.second, target.minute, target.hour, ALARM_FIELD_UNSET, target.day_of_month);
}
bool RX8111Component::unset_alarm() {
this->cancel_boot_retry_(true);
if (!this->set_alarm_interrupt_(false)) {
this->set_warning_(WARN_ALARM, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->alarm_armed_ = false;
// Minute, hour and day alarm registers are contiguous
static constexpr uint8_t DISABLED_FIELDS[3] = {RX8111_ALARM_DISABLED, RX8111_ALARM_DISABLED, RX8111_ALARM_DISABLED};
if (!this->write_byte(RX8111_REG_SEC_ALARM, RX8111_ALARM_DISABLED) ||
!this->write_bytes(RX8111_REG_MIN_ALARM, DISABLED_FIELDS, sizeof(DISABLED_FIELDS))) {
this->set_warning_(WARN_ALARM, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->clear_warning_(WARN_ALARM);
return true;
}
bool RX8111Component::clear_alarm_flag() {
if (!this->write_byte(RX8111_REG_FLAG, static_cast<uint8_t>(~RX8111_FLAG_AF))) {
this->set_warning_(WARN_ALARM_FLAG_CLEAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->alarm_flag_seen_ = false;
this->clear_warning_(WARN_ALARM_FLAG_CLEAR);
return true;
}
// Clears TE and TIE; returns the register values read so callers can build on them
bool RX8111Component::disable_timer_(uint8_t &extension, uint8_t &control) {
return this->read_byte(RX8111_REG_EXTENSION, &extension) &&
this->write_byte(RX8111_REG_EXTENSION, extension & ~RX8111_EXTENSION_TE) &&
this->read_byte(RX8111_REG_CONTROL, &control) &&
this->write_byte(RX8111_REG_CONTROL, control & ~RX8111_CONTROL_TIE);
}
bool RX8111Component::configure_timer_(bool clear_stale_flag) {
// Stop counting before updating the 24-bit reload value; preserve the boot flag unless explicitly starting.
uint8_t extension;
uint8_t control;
if (!this->disable_timer_(extension, control))
return false;
const uint8_t count[] = {static_cast<uint8_t>(this->timer_count_), static_cast<uint8_t>(this->timer_count_ >> 8),
static_cast<uint8_t>(this->timer_count_ >> 16)};
if (!this->write_bytes(RX8111_REG_TIMER0, count, sizeof(count)) ||
!this->write_byte(RX8111_REG_EXTENSION,
(extension & ~(RX8111_EXTENSION_TE | RX8111_EXTENSION_TSEL)) | this->timer_clock_) ||
(clear_stale_flag && !this->clear_timer_flag()) ||
!this->write_byte(RX8111_REG_CONTROL, control | RX8111_CONTROL_TIE))
return false;
return this->write_byte(RX8111_REG_EXTENSION,
(extension & ~RX8111_EXTENSION_TSEL) | this->timer_clock_ | RX8111_EXTENSION_TE);
}
bool RX8111Component::start_timer() {
if (this->timer_count_ == 0 || this->timer_count_ > 0xFFFFFF) {
ESP_LOGW(TAG, "Cannot start timer without a valid timer_count");
if (!this->read_byte(RX8111_REG_CONTROL, &control))
return false;
if (stop) {
control |= RX8111_CTRL_STOP;
} else {
control &= ~RX8111_CTRL_STOP;
}
this->cancel_boot_retry_(false);
if (!this->configure_timer_(true)) {
// A partial configuration can leave either enable bit set. Disable both if the bus permits it;
// otherwise keep watching a timer that may still be running.
uint8_t extension, control;
if (this->disable_timer_(extension, control))
this->timer_enabled_ = false;
this->set_warning_(WARN_TIMER, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->clear_warning_(WARN_TIMER);
this->timer_enabled_ = true;
this->timer_retained_event_pending_ = false;
this->start_event_polling_();
return true;
}
bool RX8111Component::clear_timer_flag() {
if (!this->write_byte(RX8111_REG_FLAG, static_cast<uint8_t>(~RX8111_FLAG_TF))) {
this->set_warning_(WARN_TIMER_FLAG_CLEAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->timer_flag_seen_ = false;
this->timer_retained_event_pending_ = false;
this->clear_warning_(WARN_TIMER_FLAG_CLEAR);
return true;
}
bool RX8111Component::stop_timer() {
this->cancel_boot_retry_(false);
uint8_t extension, control;
if (!this->disable_timer_(extension, control)) {
this->set_warning_(WARN_TIMER, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->timer_enabled_ = false;
this->clear_warning_(WARN_TIMER);
return true;
}
bool RX8111Component::poll_event_flags_() {
uint8_t flags;
if (!this->read_byte(RX8111_REG_FLAG, &flags))
return false;
this->clear_warning_(WARN_EVENT_POLL);
const bool alarm_active = (flags & RX8111_FLAG_AF) != 0;
const bool timer_active = (flags & RX8111_FLAG_TF) != 0;
// alarm_armed_ is only ever set while the alarm is enabled
const bool timer_watched = this->timer_enabled_ || this->timer_retained_event_pending_;
if (alarm_active && this->alarm_armed_)
this->boot_alarm_event_seen_ = true;
const bool alarm_rising = alarm_active && !this->alarm_flag_seen_ && this->alarm_armed_;
const bool timer_rising = timer_active && !this->timer_flag_seen_ && timer_watched;
const bool deliver_stopped_event = timer_rising && this->timer_retained_event_pending_;
// Record eligible edges before callbacks, which may clear flags synchronously.
// Leave flags unseen while boot configuration is still waiting to enable delivery.
if (this->alarm_armed_)
this->alarm_flag_seen_ = alarm_active;
if (timer_watched)
this->timer_flag_seen_ = timer_active;
this->timer_retained_event_pending_ = false;
if (alarm_rising)
this->alarm_callbacks_.call();
// An alarm callback may stop the timer before its callback is delivered.
if (timer_rising && this->timer_flag_seen_ && (this->timer_enabled_ || deliver_stopped_event))
this->timer_callbacks_.call();
return true;
return this->write_byte(RX8111_REG_CONTROL, control);
}
} // namespace esphome::rx8111
-87
View File
@@ -1,15 +1,10 @@
#pragma once
#include "esphome/core/helpers.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/time/real_time_clock.h"
namespace esphome::rx8111 {
namespace testing {
class RX8111Test;
} // namespace testing
class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice {
public:
void setup() override;
@@ -17,91 +12,9 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
void dump_config() override;
void read_time();
void write_time();
void set_alarm_enabled(bool enabled) { this->alarm_enabled_ = enabled; }
void set_alarm_explicitly_configured(bool configured) { this->alarm_explicitly_configured_ = configured; }
void set_alarm_second(uint8_t value) { this->alarm_second_ = value; }
void set_alarm_minute(uint8_t value) { this->alarm_minute_ = value; }
void set_alarm_hour(uint8_t value) { this->alarm_hour_ = value; }
void set_alarm_weekday(uint8_t value) { this->alarm_weekday_ = value; }
void set_alarm_day(uint8_t value) { this->alarm_day_ = value; }
void set_timer_count(uint32_t count) { this->timer_count_ = count; }
void set_timer_clock(uint8_t clock) { this->timer_clock_ = clock; }
void set_timer_enabled(bool enabled) { this->timer_enabled_ = enabled; }
void set_timer_configured(bool configured) { this->timer_configured_ = configured; }
void set_event_poll_interval(uint32_t interval) { this->event_poll_interval_ = interval; }
template<typename F> void add_on_alarm_callback(F &&callback) {
this->alarm_callbacks_.add(std::forward<F>(callback));
}
bool set_alarm(uint16_t second, uint16_t minute, uint16_t hour, uint16_t weekday, uint16_t day);
bool schedule_alarm_in(uint32_t seconds);
bool unset_alarm();
bool clear_alarm_flag();
bool clear_timer_flag();
bool start_timer();
bool stop_timer();
template<typename F> void add_on_timer_callback(F &&callback) {
this->timer_callbacks_.add(std::forward<F>(callback));
}
protected:
friend class testing::RX8111Test;
static constexpr uint32_t BOOT_RETRY_DELAY_MS = 1000;
// set_alarm() and the boot alarm ignore a field set to this value
static constexpr uint8_t ALARM_FIELD_UNSET = 0xFF;
// One bit per fault source, so a recovery in one path cannot hide another path's fault
enum Warning : uint8_t {
WARN_CALENDAR = 1 << 0,
WARN_EVENT_POLL = 1 << 1,
WARN_ALARM = 1 << 2,
WARN_TIMER = 1 << 3,
WARN_WRITE_TIME = 1 << 4,
WARN_ALARM_FLAG_CLEAR = 1 << 5,
WARN_TIMER_FLAG_CLEAR = 1 << 6,
};
bool set_stop_bit_(bool stop);
bool read_calendar_(ESPTime &rtc_time);
void set_warning_(uint8_t bit, const LogString *message);
void clear_warning_(uint8_t bit);
bool watching_() const { return this->alarm_armed_ || this->timer_enabled_ || this->timer_retained_event_pending_; }
void check_event_flags_();
bool program_alarm_(uint8_t second, uint8_t minute, uint8_t hour, uint8_t weekday, uint8_t day, bool boot = false);
bool set_alarm_interrupt_(bool enabled);
bool disable_timer_(uint8_t &extension, uint8_t &control);
bool configure_timer_(bool clear_stale_flag = false);
void start_event_polling_();
bool poll_event_flags_();
void configure_boot_alarm_();
void configure_boot_timer_();
void boot_config_result_(bool alarm, bool success);
void cancel_boot_retry_(bool alarm);
LazyCallbackManager<void()> alarm_callbacks_;
LazyCallbackManager<void()> timer_callbacks_;
uint32_t timer_count_{0};
uint32_t event_poll_interval_{1000};
uint32_t boot_retry_delay_ms_{BOOT_RETRY_DELAY_MS};
uint8_t warnings_{0};
uint8_t timer_clock_{2}; // 1 Hz
uint8_t boot_alarm_attempts_{0};
uint8_t boot_timer_attempts_{0};
uint8_t alarm_second_{ALARM_FIELD_UNSET};
uint8_t alarm_minute_{ALARM_FIELD_UNSET};
uint8_t alarm_hour_{ALARM_FIELD_UNSET};
uint8_t alarm_weekday_{ALARM_FIELD_UNSET};
uint8_t alarm_day_{ALARM_FIELD_UNSET};
bool timer_enabled_{false};
bool timer_configured_{false};
bool timer_flag_seen_{false};
bool timer_retained_event_pending_{false};
bool event_polling_started_{false};
bool boot_alarm_event_seen_{false};
bool alarm_enabled_{false};
bool alarm_explicitly_configured_{false};
bool alarm_flag_seen_{false};
bool alarm_armed_{false};
};
} // namespace esphome::rx8111
+5 -176
View File
@@ -4,14 +4,6 @@ from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c, time
import esphome.config_validation as cv
from esphome.const import (
CONF_DAY,
CONF_DURATION,
CONF_HOUR,
CONF_ID,
CONF_MINUTE,
CONF_SECOND,
)
from esphome.types import ConfigType
CODEOWNERS = ["@techdregs"]
@@ -19,186 +11,23 @@ DEPENDENCIES = ["i2c"]
rx8111_ns = cg.esphome_ns.namespace("rx8111")
RX8111Component = rx8111_ns.class_("RX8111Component", time.RealTimeClock, i2c.I2CDevice)
TIMER_CLOCKS = {"4096hz": 0, "64hz": 1, "1hz": 2, "1_60hz": 3}
# set_alarm() ignores a field set to this value
ALARM_FIELD_UNSET = 0xFF
CONF_ALARM_SECOND = "alarm_second"
CONF_ALARM_MINUTE = "alarm_minute"
CONF_ALARM_HOUR = "alarm_hour"
CONF_ALARM_WEEKDAY = "alarm_weekday"
CONF_ALARM_DAY = "alarm_day"
CONF_ALARM_ENABLED = "alarm_enabled"
CONF_ON_ALARM = "on_alarm"
CONF_TIMER_COUNT = "timer_count"
CONF_TIMER_CLOCK = "timer_clock"
CONF_TIMER_ENABLED = "timer_enabled"
CONF_ON_TIMER = "on_timer"
CONF_WEEKDAY = "weekday"
CONF_EVENT_POLL_INTERVAL = "event_poll_interval"
_ALARM_FIELDS = (
CONF_ALARM_SECOND,
CONF_ALARM_MINUTE,
CONF_ALARM_HOUR,
CONF_ALARM_WEEKDAY,
CONF_ALARM_DAY,
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_ALARM, "add_on_alarm_callback"),
automation.CallbackAutomation(CONF_ON_TIMER, "add_on_timer_callback"),
)
def _validate_alarm_config(config: ConfigType) -> ConfigType:
"""Rules that depend on the derived alarm_enabled and timer_enabled defaults."""
if CONF_ON_ALARM in config and not config.get(
CONF_ALARM_ENABLED, any(f in config for f in _ALARM_FIELDS)
):
raise cv.Invalid(
"on_alarm requires alarm_enabled: true or alarm matching fields"
)
if CONF_TIMER_COUNT not in config and (
config.get(CONF_TIMER_ENABLED) or CONF_ON_TIMER in config
):
raise cv.Invalid("timer_enabled or on_timer requires timer_count")
return config
_validate_alarm_options = cv.All(
cv.has_at_most_one_key(CONF_ALARM_DAY, CONF_ALARM_WEEKDAY), _validate_alarm_config
)
CONFIG_SCHEMA = cv.All(
time.TIME_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(RX8111Component),
cv.Optional(CONF_ALARM_SECOND): cv.int_range(min=0, max=59),
cv.Optional(CONF_ALARM_MINUTE): cv.int_range(min=0, max=59),
cv.Optional(CONF_ALARM_HOUR): cv.int_range(min=0, max=23),
cv.Optional(CONF_ALARM_WEEKDAY): cv.int_range(min=1, max=7),
cv.Optional(CONF_ALARM_DAY): cv.int_range(min=1, max=31),
cv.Optional(CONF_ALARM_ENABLED): cv.boolean,
cv.Optional(CONF_ON_ALARM): automation.validate_automation({}),
cv.Optional(CONF_TIMER_COUNT): cv.int_range(min=1, max=0xFFFFFF),
cv.Optional(CONF_TIMER_CLOCK, default="1hz"): cv.enum(
TIMER_CLOCKS, lower=True
),
cv.Optional(CONF_TIMER_ENABLED): cv.boolean,
cv.Optional(CONF_ON_TIMER): automation.validate_automation({}),
# Below 500 ms a scheduler interval costs more than it saves
cv.Optional(CONF_EVENT_POLL_INTERVAL, default="1s"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(milliseconds=500)),
),
}
).extend(i2c.i2c_device_schema(0x32)),
_validate_alarm_options,
)
CONFIG_SCHEMA = time.TIME_SCHEMA.extend(
{cv.GenerateID(): cv.declare_id(RX8111Component)}
).extend(i2c.i2c_device_schema(0x32))
RX8111_ACTION_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(RX8111Component)}
)
# Actions that call one argument-less method on the component
for _method in (
"write_time",
"read_time",
"clear_alarm_flag",
"clear_timer_flag",
"unset_alarm",
"stop_timer",
"start_timer",
):
automation.register_apply_action(
f"rx8111.{_method}", RX8111_ACTION_SCHEMA, automation.ApplyCall(f"{_method}()")
)
_SET_ALARM_FIELDS = (CONF_SECOND, CONF_MINUTE, CONF_HOUR, CONF_WEEKDAY, CONF_DAY)
def _fill_unset_alarm_fields(config: ConfigType) -> ConfigType:
"""Marks the fields not given as unset, so set_alarm() always gets all five."""
for field in _SET_ALARM_FIELDS:
config.setdefault(field, ALARM_FIELD_UNSET)
return config
_validate_set_alarm = cv.All(
cv.has_at_least_one_key(*_SET_ALARM_FIELDS),
cv.has_at_most_one_key(CONF_WEEKDAY, CONF_DAY),
_fill_unset_alarm_fields,
)
automation.register_apply_action(
"rx8111.set_alarm",
cv.All(
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(RX8111Component),
cv.Optional(CONF_SECOND): cv.templatable(cv.int_range(min=0, max=59)),
cv.Optional(CONF_MINUTE): cv.templatable(cv.int_range(min=0, max=59)),
cv.Optional(CONF_HOUR): cv.templatable(cv.int_range(min=0, max=23)),
cv.Optional(CONF_WEEKDAY): cv.templatable(cv.int_range(min=1, max=7)),
cv.Optional(CONF_DAY): cv.templatable(cv.int_range(min=1, max=31)),
}
),
_validate_set_alarm,
),
automation.ApplyCall(
"set_alarm({}, {}, {}, {}, {})",
(
(CONF_SECOND, cg.uint16),
(CONF_MINUTE, cg.uint16),
(CONF_HOUR, cg.uint16),
(CONF_WEEKDAY, cg.uint16),
(CONF_DAY, cg.uint16),
),
),
"rx8111.write_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("write_time()")
)
# The alarm registers hold a day of month, so the target must stay inside the current month cycle
_ALARM_DURATION = cv.All(
cv.positive_time_period_seconds,
cv.Range(
min=cv.TimePeriod(seconds=1), max=cv.TimePeriod(days=28), max_included=False
),
)
automation.register_apply_action(
"rx8111.schedule_alarm_in",
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(RX8111Component),
cv.Required(CONF_DURATION): cv.templatable(_ALARM_DURATION),
}
),
automation.ApplyField(CONF_DURATION, "schedule_alarm_in", cg.uint32),
"rx8111.read_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("read_time()")
)
async def to_code(config: ConfigType) -> None:
var = await i2c.new_i2c_component(config)
await time.register_time(var, config)
cg.add(var.set_event_poll_interval(config[CONF_EVENT_POLL_INTERVAL]))
cg.add_optional(var.set_alarm_second, config.get(CONF_ALARM_SECOND))
cg.add_optional(var.set_alarm_minute, config.get(CONF_ALARM_MINUTE))
cg.add_optional(var.set_alarm_hour, config.get(CONF_ALARM_HOUR))
cg.add_optional(var.set_alarm_weekday, config.get(CONF_ALARM_WEEKDAY))
cg.add_optional(var.set_alarm_day, config.get(CONF_ALARM_DAY))
cg.add(
var.set_alarm_enabled(
config.get(CONF_ALARM_ENABLED, any(f in config for f in _ALARM_FIELDS))
)
)
cg.add(var.set_alarm_explicitly_configured(CONF_ALARM_ENABLED in config))
if (count := config.get(CONF_TIMER_COUNT)) is not None:
cg.add(var.set_timer_count(count))
cg.add(var.set_timer_clock(config[CONF_TIMER_CLOCK]))
# Validation guarantees on_timer comes with a timer_count
cg.add(var.set_timer_enabled(config.get(CONF_TIMER_ENABLED, count is not None)))
cg.add(var.set_timer_configured(CONF_TIMER_ENABLED in config or count is not None))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@@ -1,48 +0,0 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS
from esphome.types import ConfigType
CODEOWNERS = ["@KoenBreeman"]
DEPENDENCIES = ["i2c"]
DOMAIN = "seeed_multi_channel_relay"
MULTI_CONF = True
CONF_SEEED_MULTI_CHANNEL_RELAY_ID = "seeed_multi_channel_relay_id"
seeed_multi_channel_relay_ns = cg.esphome_ns.namespace("seeed_multi_channel_relay")
SeeedMultiChannelRelay = seeed_multi_channel_relay_ns.class_(
"SeeedMultiChannelRelay", cg.Component, i2c.I2CDevice
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(SeeedMultiChannelRelay),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x11))
)
async def to_code(config: ConfigType) -> None:
await i2c.new_i2c_component(config)
automation.register_apply_action(
"seeed_multi_channel_relay.change_address",
cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(SeeedMultiChannelRelay),
cv.Required(CONF_ADDRESS): cv.templatable(
cv.hex_int_range(min=0x08, max=0x77)
),
},
key=CONF_ADDRESS,
),
automation.ApplyField(CONF_ADDRESS, "change_address", cg.uint8),
)
Loaded 100 of 395 files, more files were not shown because too many files have changed in this diff. Show more