Merge pull request #12488 from esphome/bump-2025.12.0b3

2025.12.0b3
This commit is contained in:
Jonathan Swoboda
2025-12-14 19:17:58 -05:00
committed by GitHub
43 changed files with 575 additions and 155 deletions

View File

@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2025.12.0b2
PROJECT_NUMBER = 2025.12.0b3
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a

View File

@@ -65,12 +65,6 @@ void CaptivePortal::start() {
this->base_->init();
if (!this->initialized_) {
this->base_->add_handler(this);
#ifdef USE_ESP32
// Enable LRU socket purging to handle captive portal detection probe bursts
// OS captive portal detection makes many simultaneous HTTP requests which can
// exhaust sockets. LRU purging automatically closes oldest idle connections.
this->base_->get_server()->set_lru_purge_enable(true);
#endif
}
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();

View File

@@ -40,10 +40,6 @@ class CaptivePortal : public AsyncWebHandler, public Component {
void end() {
this->active_ = false;
this->disable_loop(); // Stop processing DNS requests
#ifdef USE_ESP32
// Disable LRU socket purging now that captive portal is done
this->base_->get_server()->set_lru_purge_enable(false);
#endif
this->base_->deinit();
if (this->dns_server_ != nullptr) {
this->dns_server_->stop();

View File

@@ -1,9 +1,17 @@
from esphome import automation
from esphome import automation, pins
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import spi
from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET
import esphome.config_validation as cv
from esphome.const import CONF_CHANNEL, CONF_FREQUENCY, CONF_ID, CONF_WAIT_TIME
from esphome.const import (
CONF_CHANNEL,
CONF_DATA,
CONF_FREQUENCY,
CONF_ID,
CONF_WAIT_TIME,
)
from esphome.core import ID
CODEOWNERS = ["@lygris", "@gabest11"]
DEPENDENCIES = ["spi"]
@@ -29,7 +37,6 @@ CONF_MANCHESTER = "manchester"
CONF_NUM_PREAMBLE = "num_preamble"
CONF_SYNC1 = "sync1"
CONF_SYNC0 = "sync0"
CONF_PKTLEN = "pktlen"
CONF_MAGN_TARGET = "magn_target"
CONF_MAX_LNA_GAIN = "max_lna_gain"
CONF_MAX_DVGA_GAIN = "max_dvga_gain"
@@ -41,6 +48,12 @@ CONF_FILTER_LENGTH_ASK_OOK = "filter_length_ask_ook"
CONF_FREEZE = "freeze"
CONF_HYST_LEVEL = "hyst_level"
# Packet mode config keys
CONF_PACKET_MODE = "packet_mode"
CONF_PACKET_LENGTH = "packet_length"
CONF_WHITENING = "whitening"
CONF_GDO0_PIN = "gdo0_pin"
# Enums
SyncMode = ns.enum("SyncMode", True)
SYNC_MODE = {
@@ -167,7 +180,6 @@ CONFIG_MAP = {
CONF_NUM_PREAMBLE: cv.int_range(min=0, max=7),
CONF_SYNC1: cv.hex_uint8_t,
CONF_SYNC0: cv.hex_uint8_t,
CONF_PKTLEN: cv.uint8_t,
CONF_MAGN_TARGET: cv.enum(MAGN_TARGET, upper=False),
CONF_MAX_LNA_GAIN: cv.enum(MAX_LNA_GAIN, upper=False),
CONF_MAX_DVGA_GAIN: cv.enum(MAX_DVGA_GAIN, upper=False),
@@ -179,13 +191,36 @@ CONFIG_MAP = {
CONF_FREEZE: cv.enum(FREEZE, upper=False),
CONF_WAIT_TIME: cv.enum(WAIT_TIME, upper=False),
CONF_HYST_LEVEL: cv.enum(HYST_LEVEL, upper=False),
CONF_PACKET_MODE: cv.boolean,
CONF_PACKET_LENGTH: cv.uint8_t,
CONF_CRC_ENABLE: cv.boolean,
CONF_WHITENING: cv.boolean,
}
CONFIG_SCHEMA = (
cv.Schema({cv.GenerateID(): cv.declare_id(CC1101Component)})
def _validate_packet_mode(config):
if config.get(CONF_PACKET_MODE, False):
if CONF_GDO0_PIN not in config:
raise cv.Invalid("gdo0_pin is required when packet_mode is enabled")
if CONF_PACKET_LENGTH not in config:
raise cv.Invalid("packet_length is required when packet_mode is enabled")
if config[CONF_PACKET_LENGTH] > 64:
raise cv.Invalid("packet_length must be <= 64 (FIFO size)")
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(CC1101Component),
cv.Optional(CONF_GDO0_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True),
}
)
.extend({cv.Optional(key): validator for key, validator in CONFIG_MAP.items()})
.extend(cv.COMPONENT_SCHEMA)
.extend(spi.spi_device_schema(cs_pin_required=True))
.extend(spi.spi_device_schema(cs_pin_required=True)),
_validate_packet_mode,
)
@@ -198,12 +233,29 @@ async def to_code(config):
if key in config:
cg.add(getattr(var, f"set_{key}")(config[key]))
if CONF_GDO0_PIN in config:
gdo0_pin = await cg.gpio_pin_expression(config[CONF_GDO0_PIN])
cg.add(var.set_gdo0_pin(gdo0_pin))
if CONF_ON_PACKET in config:
await automation.build_automation(
var.get_packet_trigger(),
[
(cg.std_vector.template(cg.uint8), "x"),
(cg.float_, "rssi"),
(cg.uint8, "lqi"),
],
config[CONF_ON_PACKET],
)
# Actions
BeginTxAction = ns.class_("BeginTxAction", automation.Action)
BeginRxAction = ns.class_("BeginRxAction", automation.Action)
ResetAction = ns.class_("ResetAction", automation.Action)
SetIdleAction = ns.class_("SetIdleAction", automation.Action)
SendPacketAction = ns.class_(
"SendPacketAction", automation.Action, cg.Parented.template(CC1101Component)
)
CC1101_ACTION_SCHEMA = cv.Schema(
maybe_simple_id({cv.GenerateID(CONF_ID): cv.use_id(CC1101Component)})
@@ -218,3 +270,42 @@ async def cc1101_action_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
def validate_raw_data(value):
if isinstance(value, str):
return value.encode("utf-8")
if isinstance(value, list):
return cv.Schema([cv.hex_uint8_t])(value)
raise cv.Invalid(
"data must either be a string wrapped in quotes or a list of bytes"
)
SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(CC1101Component),
cv.Required(CONF_DATA): cv.templatable(validate_raw_data),
},
key=CONF_DATA,
)
@automation.register_action(
"cc1101.send_packet", SendPacketAction, SEND_PACKET_ACTION_SCHEMA
)
async def send_packet_action_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
data = config[CONF_DATA]
if isinstance(data, bytes):
data = list(data)
if cg.is_template(data):
templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8))
cg.add(var.set_data_template(templ))
else:
# Generate static array in flash to avoid RAM copy
arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8)
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data))
cg.add(var.set_data_static(arr, len(data)))
return var

View File

@@ -143,6 +143,11 @@ void CC1101Component::setup() {
return;
}
// Setup GDO0 pin if configured
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->setup();
}
this->initialized_ = true;
for (uint8_t i = 0; i <= static_cast<uint8_t>(Register::TEST0); i++) {
@@ -151,8 +156,69 @@ void CC1101Component::setup() {
}
this->write_(static_cast<Register>(i));
}
this->write_(Register::PATABLE, this->pa_table_, sizeof(this->pa_table_));
this->set_output_power(this->output_power_requested_);
this->strobe_(Command::RX);
// Defer pin mode setup until after all components have completed setup()
// This handles the case where remote_transmitter runs after CC1101 and changes pin mode
if (this->gdo0_pin_ != nullptr) {
this->defer([this]() { this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); });
}
}
void CC1101Component::loop() {
if (this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO) || this->gdo0_pin_ == nullptr ||
!this->gdo0_pin_->digital_read()) {
return;
}
// Read state
this->read_(Register::RXBYTES);
uint8_t rx_bytes = this->state_.NUM_RXBYTES;
bool overflow = this->state_.RXFIFO_OVERFLOW;
if (overflow || rx_bytes == 0) {
ESP_LOGW(TAG, "RX FIFO overflow, flushing");
this->enter_idle_();
this->strobe_(Command::FRX);
this->strobe_(Command::RX);
this->wait_for_state_(State::RX);
return;
}
// Read packet
uint8_t payload_length;
if (this->state_.LENGTH_CONFIG == static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_VARIABLE)) {
this->read_(Register::FIFO, &payload_length, 1);
} else {
payload_length = this->state_.PKTLEN;
}
if (payload_length == 0 || payload_length > 64) {
ESP_LOGW(TAG, "Invalid payload length: %u", payload_length);
this->enter_idle_();
this->strobe_(Command::FRX);
this->strobe_(Command::RX);
this->wait_for_state_(State::RX);
return;
}
this->packet_.resize(payload_length);
this->read_(Register::FIFO, this->packet_.data(), payload_length);
// Read status and trigger
uint8_t status[2];
this->read_(Register::FIFO, status, 2);
int8_t rssi_raw = static_cast<int8_t>(status[0]);
float rssi = (rssi_raw * RSSI_STEP) - RSSI_OFFSET;
bool crc_ok = (status[1] & STATUS_CRC_OK_MASK) != 0;
uint8_t lqi = status[1] & STATUS_LQI_MASK;
if (this->state_.CRC_EN == 0 || crc_ok) {
this->packet_trigger_->trigger(this->packet_, rssi, lqi);
}
// Return to rx
this->enter_idle_();
this->strobe_(Command::FRX);
this->strobe_(Command::RX);
this->wait_for_state_(State::RX);
}
void CC1101Component::dump_config() {
@@ -177,9 +243,12 @@ void CC1101Component::dump_config() {
}
void CC1101Component::begin_tx() {
// Ensure Packet Format is 3 (Async Serial), use GDO0 as input during TX
// Ensure Packet Format is 3 (Async Serial)
this->write_(Register::PKTCTRL0, 0x32);
ESP_LOGV(TAG, "Beginning TX sequence");
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT);
}
this->strobe_(Command::TX);
if (!this->wait_for_state_(State::TX, 50)) {
ESP_LOGW(TAG, "Timed out waiting for TX state!");
@@ -188,6 +257,9 @@ void CC1101Component::begin_tx() {
void CC1101Component::begin_rx() {
ESP_LOGV(TAG, "Beginning RX sequence");
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT);
}
this->strobe_(Command::RX);
}
@@ -201,20 +273,6 @@ void CC1101Component::set_idle() {
this->enter_idle_();
}
void CC1101Component::set_gdo0_config(uint8_t value) {
this->state_.GDO0_CFG = value;
if (this->initialized_) {
this->write_(Register::IOCFG0);
}
}
void CC1101Component::set_gdo2_config(uint8_t value) {
this->state_.GDO2_CFG = value;
if (this->initialized_) {
this->write_(Register::IOCFG2);
}
}
bool CC1101Component::wait_for_state_(State target_state, uint32_t timeout_ms) {
uint32_t start = millis();
while (millis() - start < timeout_ms) {
@@ -282,6 +340,33 @@ void CC1101Component::read_(Register reg, uint8_t *buffer, size_t length) {
this->disable();
}
CC1101Error CC1101Component::transmit_packet(const std::vector<uint8_t> &packet) {
if (this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO)) {
return CC1101Error::PARAMS;
}
// Write packet
this->enter_idle_();
this->strobe_(Command::FTX);
if (this->state_.LENGTH_CONFIG == static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_VARIABLE)) {
this->write_(Register::FIFO, static_cast<uint8_t>(packet.size()));
}
this->write_(Register::FIFO, packet.data(), packet.size());
this->strobe_(Command::TX);
if (!this->wait_for_state_(State::IDLE, 1000)) {
ESP_LOGW(TAG, "TX timeout");
this->enter_idle_();
this->strobe_(Command::RX);
this->wait_for_state_(State::RX);
return CC1101Error::TIMEOUT;
}
// Return to rx
this->strobe_(Command::RX);
this->wait_for_state_(State::RX);
return CC1101Error::NONE;
}
// Setters
void CC1101Component::set_output_power(float value) {
this->output_power_requested_ = value;
@@ -428,6 +513,7 @@ void CC1101Component::set_modulation_type(Modulation value) {
this->state_.PA_POWER = value == Modulation::MODULATION_ASK_OOK ? 1 : 0;
if (this->initialized_) {
this->enter_idle_();
this->set_output_power(this->output_power_requested_);
this->write_(Register::MDMCFG2);
this->write_(Register::FREND0);
this->strobe_(Command::RX);
@@ -462,13 +548,6 @@ void CC1101Component::set_sync0(uint8_t value) {
}
}
void CC1101Component::set_pktlen(uint8_t value) {
this->state_.PKTLEN = value;
if (this->initialized_) {
this->write_(Register::PKTLEN);
}
}
void CC1101Component::set_magn_target(MagnTarget value) {
this->state_.MAGN_TARGET = static_cast<uint8_t>(value);
if (this->initialized_) {
@@ -546,4 +625,50 @@ void CC1101Component::set_hyst_level(HystLevel value) {
}
}
void CC1101Component::set_packet_mode(bool value) {
this->state_.PKT_FORMAT =
static_cast<uint8_t>(value ? PacketFormat::PACKET_FORMAT_FIFO : PacketFormat::PACKET_FORMAT_ASYNC_SERIAL);
if (value) {
// Configure GDO0 for FIFO status (asserts on RX FIFO threshold or end of packet)
this->state_.GDO0_CFG = 0x01;
// Set max RX FIFO threshold to ensure we only trigger on end-of-packet
this->state_.FIFO_THR = 15;
} else {
// Configure GDO0 for serial data (async serial mode)
this->state_.GDO0_CFG = 0x0D;
}
if (this->initialized_) {
this->write_(Register::PKTCTRL0);
this->write_(Register::IOCFG0);
this->write_(Register::FIFOTHR);
}
}
void CC1101Component::set_packet_length(uint8_t value) {
if (value == 0) {
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_VARIABLE);
} else {
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_FIXED);
this->state_.PKTLEN = value;
}
if (this->initialized_) {
this->write_(Register::PKTCTRL0);
this->write_(Register::PKTLEN);
}
}
void CC1101Component::set_crc_enable(bool value) {
this->state_.CRC_EN = value ? 1 : 0;
if (this->initialized_) {
this->write_(Register::PKTCTRL0);
}
}
void CC1101Component::set_whitening(bool value) {
this->state_.WHITE_DATA = value ? 1 : 0;
if (this->initialized_) {
this->write_(Register::PKTCTRL0);
}
}
} // namespace esphome::cc1101

View File

@@ -5,9 +5,12 @@
#include "esphome/components/spi/spi.h"
#include "esphome/core/automation.h"
#include "cc1101defs.h"
#include <vector>
namespace esphome::cc1101 {
enum class CC1101Error { NONE = 0, TIMEOUT, PARAMS, CRC_ERROR, FIFO_OVERFLOW };
class CC1101Component : public Component,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW,
spi::CLOCK_PHASE_LEADING, spi::DATA_RATE_1MHZ> {
@@ -15,6 +18,7 @@ class CC1101Component : public Component,
CC1101Component();
void setup() override;
void loop() override;
void dump_config() override;
// Actions
@@ -24,8 +28,7 @@ class CC1101Component : public Component,
void set_idle();
// GDO Pin Configuration
void set_gdo0_config(uint8_t value);
void set_gdo2_config(uint8_t value);
void set_gdo0_pin(InternalGPIOPin *pin) { this->gdo0_pin_ = pin; }
// Configuration Setters
void set_output_power(float value);
@@ -48,7 +51,6 @@ class CC1101Component : public Component,
void set_num_preamble(uint8_t value);
void set_sync1(uint8_t value);
void set_sync0(uint8_t value);
void set_pktlen(uint8_t value);
// AGC settings
void set_magn_target(MagnTarget value);
@@ -63,6 +65,16 @@ class CC1101Component : public Component,
void set_wait_time(WaitTime value);
void set_hyst_level(HystLevel value);
// Packet mode settings
void set_packet_mode(bool value);
void set_packet_length(uint8_t value);
void set_crc_enable(bool value);
void set_whitening(bool value);
// Packet mode operations
CC1101Error transmit_packet(const std::vector<uint8_t> &packet);
Trigger<std::vector<uint8_t>, float, uint8_t> *get_packet_trigger() const { return this->packet_trigger_; }
protected:
uint16_t chip_id_{0};
bool initialized_{false};
@@ -73,6 +85,13 @@ class CC1101Component : public Component,
CC1101State state_;
// GDO pin for packet reception
InternalGPIOPin *gdo0_pin_{nullptr};
// Packet handling
Trigger<std::vector<uint8_t>, float, uint8_t> *packet_trigger_{new Trigger<std::vector<uint8_t>, float, uint8_t>()};
std::vector<uint8_t> packet_;
// Low-level Helpers
uint8_t strobe_(Command cmd);
void write_(Register reg);
@@ -107,4 +126,28 @@ template<typename... Ts> class SetIdleAction : public Action<Ts...>, public Pare
void play(const Ts &...x) override { this->parent_->set_idle(); }
};
template<typename... Ts> class SendPacketAction : public Action<Ts...>, public Parented<CC1101Component> {
public:
void set_data_template(std::function<std::vector<uint8_t>(Ts...)> func) { this->data_func_ = func; }
void set_data_static(const uint8_t *data, size_t len) {
this->data_static_ = data;
this->data_static_len_ = len;
}
void play(const Ts &...x) override {
if (this->data_func_) {
auto data = this->data_func_(x...);
this->parent_->transmit_packet(data);
} else if (this->data_static_ != nullptr) {
std::vector<uint8_t> data(this->data_static_, this->data_static_ + this->data_static_len_);
this->parent_->transmit_packet(data);
}
}
protected:
std::function<std::vector<uint8_t>(Ts...)> data_func_{};
const uint8_t *data_static_{nullptr};
size_t data_static_len_{0};
};
} // namespace esphome::cc1101

View File

@@ -6,6 +6,12 @@ namespace esphome::cc1101 {
static constexpr float XTAL_FREQUENCY = 26000000;
static constexpr float RSSI_OFFSET = 74.0f;
static constexpr float RSSI_STEP = 0.5f;
static constexpr uint8_t STATUS_CRC_OK_MASK = 0x80;
static constexpr uint8_t STATUS_LQI_MASK = 0x7F;
static constexpr uint8_t BUS_BURST = 0x40;
static constexpr uint8_t BUS_READ = 0x80;
static constexpr uint8_t BUS_WRITE = 0x00;
@@ -134,6 +140,10 @@ enum class SyncMode : uint8_t {
SYNC_MODE_15_16,
SYNC_MODE_16_16,
SYNC_MODE_30_32,
SYNC_MODE_NONE_CS,
SYNC_MODE_15_16_CS,
SYNC_MODE_16_16_CS,
SYNC_MODE_30_32_CS,
};
enum class Modulation : uint8_t {
@@ -218,6 +228,19 @@ enum class HystLevel : uint8_t {
HYST_LEVEL_HIGH,
};
enum class PacketFormat : uint8_t {
PACKET_FORMAT_FIFO,
PACKET_FORMAT_SYNC_SERIAL,
PACKET_FORMAT_RANDOM_TX,
PACKET_FORMAT_ASYNC_SERIAL,
};
enum class LengthConfig : uint8_t {
LENGTH_CONFIG_FIXED,
LENGTH_CONFIG_VARIABLE,
LENGTH_CONFIG_INFINITE,
};
struct __attribute__((packed)) CC1101State {
// Byte array accessors for bulk SPI transfers
uint8_t *regs() { return reinterpret_cast<uint8_t *>(this); }

View File

@@ -7,9 +7,11 @@ BYTE_ORDER_LITTLE = "little_endian"
BYTE_ORDER_BIG = "big_endian"
CONF_COLOR_DEPTH = "color_depth"
CONF_CRC_ENABLE = "crc_enable"
CONF_DRAW_ROUNDING = "draw_rounding"
CONF_ENABLED = "enabled"
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
CONF_ON_PACKET = "on_packet"
CONF_ON_RECEIVE = "on_receive"
CONF_ON_STATE_CHANGE = "on_state_change"
CONF_REQUEST_HEADERS = "request_headers"

View File

@@ -13,7 +13,7 @@ static const char *const TAG = "espnow.transport";
bool ESPNowTransport::should_send() { return this->parent_ != nullptr && !this->parent_->is_failed(); }
void ESPNowTransport::setup() {
packet_transport::PacketTransport::setup();
PacketTransport::setup();
if (this->parent_ == nullptr) {
ESP_LOGE(TAG, "ESPNow component not set");
@@ -26,15 +26,10 @@ void ESPNowTransport::setup() {
this->peer_address_[2], this->peer_address_[3], this->peer_address_[4], this->peer_address_[5]);
// Register received handler
this->parent_->register_received_handler(static_cast<ESPNowReceivedPacketHandler *>(this));
this->parent_->register_received_handler(this);
// Register broadcasted handler
this->parent_->register_broadcasted_handler(static_cast<ESPNowBroadcastedHandler *>(this));
}
void ESPNowTransport::update() {
packet_transport::PacketTransport::update();
this->updated_ = true;
this->parent_->register_broadcasted_handler(this);
}
void ESPNowTransport::send_packet(const std::vector<uint8_t> &buf) const {

View File

@@ -18,7 +18,6 @@ class ESPNowTransport : public packet_transport::PacketTransport,
public ESPNowBroadcastedHandler {
public:
void setup() override;
void update() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
void set_peer_address(peer_address_t address) {

View File

@@ -434,10 +434,13 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
# Check all used pins against RMII reserved pins
for pin_list in pins.PIN_SCHEMA_REGISTRY.pins_used.values():
for pin_path, _, pin_config in pin_list:
for pin_path, pin_device, pin_config in pin_list:
pin_num = pin_config.get(CONF_NUMBER)
if pin_num not in rmii_pins:
continue
# Skip if pin is not directly on ESP, but at some expander (device set to something else than 'None')
if pin_device is not None:
continue
# Found a conflict - show helpful error message
pin_function = rmii_pins[pin_num]
component_path = ".".join(str(p) for p in pin_path)

View File

@@ -176,17 +176,22 @@ async def register_packet_transport(var, config):
if encryption := provider.get(CONF_ENCRYPTION):
cg.add(var.set_provider_encryption(name, hash_encryption_key(encryption)))
is_provider = False
for sens_conf in config.get(CONF_SENSORS, ()):
is_provider = True
sens_id = sens_conf[CONF_ID]
sensor = await cg.get_variable(sens_id)
bcst_id = sens_conf.get(CONF_BROADCAST_ID, sens_id.id)
cg.add(var.add_sensor(bcst_id, sensor))
for sens_conf in config.get(CONF_BINARY_SENSORS, ()):
is_provider = True
sens_id = sens_conf[CONF_ID]
sensor = await cg.get_variable(sens_id)
bcst_id = sens_conf.get(CONF_BROADCAST_ID, sens_id.id)
cg.add(var.add_binary_sensor(bcst_id, sensor))
if is_provider:
cg.add(var.set_is_provider(True))
if encryption := config.get(CONF_ENCRYPTION):
cg.add(var.set_encryption_key(hash_encryption_key(encryption)))
return providers

View File

@@ -263,6 +263,7 @@ void PacketTransport::flush_() {
xxtea::encrypt((uint32_t *) (encode_buffer.data() + header_len), len / 4,
(uint32_t *) this->encryption_key_.data());
}
ESP_LOGVV(TAG, "Sending packet %s", format_hex_pretty(encode_buffer.data(), encode_buffer.size()).c_str());
this->send_packet(encode_buffer);
}
@@ -316,6 +317,9 @@ void PacketTransport::send_data_(bool all) {
}
void PacketTransport::update() {
// resend all sensors if required
if (this->is_provider_)
this->send_data_(true);
if (!this->ping_pong_enable_) {
return;
}
@@ -551,7 +555,7 @@ void PacketTransport::loop() {
if (this->resend_ping_key_)
this->send_ping_pong_request_();
if (this->updated_) {
this->send_data_(this->resend_data_);
this->send_data_(false);
}
}

View File

@@ -91,6 +91,7 @@ class PacketTransport : public PollingComponent {
}
}
void set_is_provider(bool is_provider) { this->is_provider_ = is_provider; }
void set_encryption_key(std::vector<uint8_t> key) { this->encryption_key_ = std::move(key); }
void set_rolling_code_enable(bool enable) { this->rolling_code_enable_ = enable; }
void set_ping_pong_enable(bool enable) { this->ping_pong_enable_ = enable; }
@@ -129,7 +130,7 @@ class PacketTransport : public PollingComponent {
uint32_t ping_pong_recyle_time_{};
uint32_t last_key_time_{};
bool resend_ping_key_{};
bool resend_data_{};
bool is_provider_{};
const char *name_{};
ESPPreferenceObject pref_{};

View File

@@ -1,6 +1,7 @@
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import spi
from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET
import esphome.config_validation as cv
from esphome.const import CONF_BUSY_PIN, CONF_DATA, CONF_FREQUENCY, CONF_ID
from esphome.core import ID, TimePeriod
@@ -14,7 +15,6 @@ CONF_SX126X_ID = "sx126x_id"
CONF_BANDWIDTH = "bandwidth"
CONF_BITRATE = "bitrate"
CONF_CODING_RATE = "coding_rate"
CONF_CRC_ENABLE = "crc_enable"
CONF_CRC_INVERTED = "crc_inverted"
CONF_CRC_SIZE = "crc_size"
CONF_CRC_POLYNOMIAL = "crc_polynomial"
@@ -23,7 +23,6 @@ CONF_DEVIATION = "deviation"
CONF_DIO1_PIN = "dio1_pin"
CONF_HW_VERSION = "hw_version"
CONF_MODULATION = "modulation"
CONF_ON_PACKET = "on_packet"
CONF_PA_POWER = "pa_power"
CONF_PA_RAMP = "pa_ramp"
CONF_PAYLOAD_LENGTH = "payload_length"

View File

@@ -12,12 +12,6 @@ void SX126xTransport::setup() {
this->parent_->register_listener(this);
}
void SX126xTransport::update() {
PacketTransport::update();
this->updated_ = true;
this->resend_data_ = true;
}
void SX126xTransport::send_packet(const std::vector<uint8_t> &buf) const { this->parent_->transmit_packet(buf); }
void SX126xTransport::on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) { this->process_(packet); }

View File

@@ -11,7 +11,6 @@ namespace sx126x {
class SX126xTransport : public packet_transport::PacketTransport, public Parented<SX126x>, public SX126xListener {
public:
void setup() override;
void update() override;
void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }

View File

@@ -1,6 +1,7 @@
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import spi
from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET
import esphome.config_validation as cv
from esphome.const import CONF_DATA, CONF_FREQUENCY, CONF_ID
from esphome.core import ID
@@ -16,11 +17,9 @@ CONF_BANDWIDTH = "bandwidth"
CONF_BITRATE = "bitrate"
CONF_BITSYNC = "bitsync"
CONF_CODING_RATE = "coding_rate"
CONF_CRC_ENABLE = "crc_enable"
CONF_DEVIATION = "deviation"
CONF_DIO0_PIN = "dio0_pin"
CONF_MODULATION = "modulation"
CONF_ON_PACKET = "on_packet"
CONF_PA_PIN = "pa_pin"
CONF_PA_POWER = "pa_power"
CONF_PA_RAMP = "pa_ramp"

View File

@@ -12,12 +12,6 @@ void SX127xTransport::setup() {
this->parent_->register_listener(this);
}
void SX127xTransport::update() {
PacketTransport::update();
this->updated_ = true;
this->resend_data_ = true;
}
void SX127xTransport::send_packet(const std::vector<uint8_t> &buf) const { this->parent_->transmit_packet(buf); }
void SX127xTransport::on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) { this->process_(packet); }

View File

@@ -11,7 +11,6 @@ namespace sx127x {
class SX127xTransport : public packet_transport::PacketTransport, public Parented<SX127x>, public SX127xListener {
public:
void setup() override;
void update() override;
void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }

View File

@@ -55,12 +55,6 @@ void UARTTransport::loop() {
}
}
void UARTTransport::update() {
this->updated_ = true;
this->resend_data_ = true;
PacketTransport::update();
}
/**
* Write a byte to the UART bus. If the byte is a flag or control byte, it will be escaped.
* @param byte The byte to write.

View File

@@ -23,7 +23,6 @@ static const uint8_t CONTROL_BYTE = 0x7D;
class UARTTransport : public packet_transport::PacketTransport, public UARTDevice {
public:
void loop() override;
void update() override;
float get_setup_priority() const override { return setup_priority::PROCESSOR; }
protected:

View File

@@ -8,29 +8,14 @@ namespace udp {
static const char *const TAG = "udp_transport";
bool UDPTransport::should_send() { return this->should_broadcast_ && network::is_connected(); }
bool UDPTransport::should_send() { return network::is_connected(); }
void UDPTransport::setup() {
PacketTransport::setup();
this->should_broadcast_ = this->ping_pong_enable_;
#ifdef USE_SENSOR
this->should_broadcast_ |= !this->sensors_.empty();
#endif
#ifdef USE_BINARY_SENSOR
this->should_broadcast_ |= !this->binary_sensors_.empty();
#endif
if (this->should_broadcast_)
this->parent_->set_should_broadcast();
if (!this->providers_.empty() || this->is_encrypted_()) {
this->parent_->add_listener([this](std::vector<uint8_t> &buf) { this->process_(buf); });
}
}
void UDPTransport::update() {
PacketTransport::update();
this->updated_ = true;
this->resend_data_ = this->should_broadcast_;
}
void UDPTransport::send_packet(const std::vector<uint8_t> &buf) const { this->parent_->send_packet(buf); }
} // namespace udp
} // namespace esphome

View File

@@ -12,14 +12,12 @@ namespace udp {
class UDPTransport : public packet_transport::PacketTransport, public Parented<UDPComponent> {
public:
void setup() override;
void update() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
protected:
void send_packet(const std::vector<uint8_t> &buf) const override;
bool should_send() override;
bool should_broadcast_{false};
size_t get_max_packet_size() override { return MAX_PACKET_SIZE; }
};

View File

@@ -117,18 +117,6 @@ void AsyncWebServer::end() {
}
}
void AsyncWebServer::set_lru_purge_enable(bool enable) {
if (this->lru_purge_enable_ == enable) {
return; // No change needed
}
this->lru_purge_enable_ = enable;
// If server is already running, restart it with new config
if (this->server_) {
this->end();
this->begin();
}
}
void AsyncWebServer::begin() {
if (this->server_) {
this->end();
@@ -136,8 +124,11 @@ void AsyncWebServer::begin() {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = this->port_;
config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; };
// Enable LRU purging if requested (e.g., by captive portal to handle probe bursts)
config.lru_purge_enable = this->lru_purge_enable_;
// Always enable LRU purging to handle socket exhaustion gracefully.
// When max sockets is reached, the oldest connection is closed to make room for new ones.
// This prevents "httpd_accept_conn: error in accept (23)" errors.
// See: https://github.com/esphome/esphome/issues/12464
config.lru_purge_enable = true;
// Use custom close function that shuts down before closing to prevent lwIP race conditions
config.close_fn = AsyncWebServer::safe_close_with_shutdown;
if (httpd_start(&this->server_, &config) == ESP_OK) {

View File

@@ -199,13 +199,11 @@ class AsyncWebServer {
return *handler;
}
void set_lru_purge_enable(bool enable);
httpd_handle_t get_server() { return this->server_; }
protected:
uint16_t port_{};
httpd_handle_t server_{};
bool lru_purge_enable_{false};
static esp_err_t request_handler(httpd_req_t *r);
static esp_err_t request_post_handler(httpd_req_t *r);
esp_err_t request_handler_(AsyncWebServerRequest *request) const;

View File

@@ -205,6 +205,21 @@ static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500;
/// While connecting, WiFi can't beacon the AP properly, so needs longer cooldown
static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000;
/// Timeout for WiFi scan operations
/// This is a fallback in case we don't receive a scan done callback from the WiFi driver.
/// Normal scans complete via callback; this only triggers if something goes wrong.
static constexpr uint32_t WIFI_SCAN_TIMEOUT_MS = 31000;
/// Timeout for WiFi connection attempts
/// This is a fallback in case we don't receive connection success/failure callbacks.
/// Some platforms (especially LibreTiny/Beken) can take 30-60 seconds to connect,
/// particularly with fast_connect enabled where no prior scan provides channel info.
/// Do not lower this value - connection failures are detected via callbacks, not timeout.
/// If this timeout fires prematurely while a connection is still in progress, it causes
/// cascading failures: the subsequent scan will also fail because the WiFi driver is
/// still busy with the previous connection attempt.
static constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 46000;
static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) {
switch (phase) {
case WiFiRetryPhase::INITIAL_CONNECT:
@@ -1035,7 +1050,7 @@ __attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res)
void WiFiComponent::check_scanning_finished() {
if (!this->scan_done_) {
if (millis() - this->action_started_ > 30000) {
if (millis() - this->action_started_ > WIFI_SCAN_TIMEOUT_MS) {
ESP_LOGE(TAG, "Scan timeout");
this->retry_connect();
}
@@ -1184,8 +1199,9 @@ void WiFiComponent::check_connecting_finished() {
}
uint32_t now = millis();
if (now - this->action_started_ > 30000) {
ESP_LOGW(TAG, "Connection timeout");
if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) {
ESP_LOGW(TAG, "Connection timeout, aborting connection attempt");
this->wifi_disconnect_();
this->retry_connect();
return;
}
@@ -1405,6 +1421,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) {
// without disrupting the captive portal/improv connection
if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) {
this->restart_adapter();
} else {
// Even when skipping full restart, disconnect to clear driver state
// Without this, platforms like LibreTiny may think we're still connecting
this->wifi_disconnect_();
}
// Clear scan flag - we're starting a new retry cycle
this->did_scan_this_cycle_ = false;

View File

@@ -720,6 +720,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_STOP) {
ESP_LOGV(TAG, "STA stop");
s_sta_started = false;
s_sta_connecting = false;
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_AUTHMODE_CHANGE) {
const auto &it = data->data.sta_authmode_change;

View File

@@ -291,6 +291,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_
}
case ESPHOME_EVENT_ID_WIFI_STA_STOP: {
ESP_LOGV(TAG, "STA stop");
s_sta_connecting = false;
break;
}
case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: {
@@ -322,7 +323,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_
// wifi_sta_connect_status_() to return IDLE. The main loop then sees
// "Unknown connection status 0" (wifi_component.cpp check_connecting_finished)
// and calls retry_connect(), aborting a connection that may succeed moments later.
// Real connection failures will have ssid/bssid populated, or we'll hit the 30s timeout.
// Real connection failures will have ssid/bssid populated, or we'll hit the connection timeout.
if (it.ssid_len == 0 && s_sta_connecting) {
ESP_LOGV(TAG, "Ignoring disconnect event with empty ssid while connecting (reason=%s)",
get_disconnect_reason_str(it.reason));
@@ -527,7 +528,12 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) {
network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {WiFi.softAPIP()}; }
#endif // USE_WIFI_AP
bool WiFiComponent::wifi_disconnect_() { return WiFi.disconnect(); }
bool WiFiComponent::wifi_disconnect_() {
// Clear connecting flag first so disconnect events aren't ignored
// and wifi_sta_connect_status_() returns IDLE instead of CONNECTING
s_sta_connecting = false;
return WiFi.disconnect();
}
bssid_t WiFiComponent::wifi_bssid() {
bssid_t bssid{};

View File

@@ -16,7 +16,12 @@ class WiFiSignalSensor : public sensor::Sensor, public PollingComponent {
#ifdef USE_WIFI_LISTENERS
void setup() override { wifi::global_wifi_component->add_connect_state_listener(this); }
#endif
void update() override { this->publish_state(wifi::global_wifi_component->wifi_rssi()); }
void update() override {
int8_t rssi = wifi::global_wifi_component->wifi_rssi();
if (rssi != wifi::WIFI_RSSI_DISCONNECTED) {
this->publish_state(rssi);
}
}
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }

View File

@@ -1010,14 +1010,14 @@ def validate_config(
result.add_error(err)
return result
CORE.raw_config = config
# 1.1. Merge packages
if CONF_PACKAGES in config:
from esphome.components.packages import merge_packages
config = merge_packages(config)
CORE.raw_config = config
# 1.2. Resolve !extend and !remove and check for REPLACEME
# After this step, there will not be any Extend or Remove values in the config anymore
try:

View File

@@ -71,6 +71,7 @@ from esphome.const import (
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
SCHEDULER_DONT_RUN,
TYPE_GIT,
TYPE_LOCAL,
VALID_SUBSTITUTIONS_CHARACTERS,
@@ -894,7 +895,7 @@ def time_period_in_minutes_(value):
def update_interval(value):
if value == "never":
return 4294967295 # uint32_t max
return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN)
return positive_time_period_milliseconds(value)
@@ -2009,7 +2010,7 @@ def polling_component_schema(default_update_interval):
if default_update_interval is None:
return COMPONENT_SCHEMA.extend(
{
Required(CONF_UPDATE_INTERVAL): default_update_interval,
Required(CONF_UPDATE_INTERVAL): update_interval,
}
)
assert isinstance(default_update_interval, str)

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2025.12.0b2"
__version__ = "2025.12.0b3"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -1,16 +1,16 @@
#pragma once
// Platform-agnostic macros for PROGMEM string handling
// On ESP32 (both Arduino and IDF): Use plain strings (no PROGMEM)
// On ESP8266/Arduino: Use Arduino's F() macro for PROGMEM strings
// On other platforms: Use plain strings (no PROGMEM)
#ifdef USE_ESP32
#define ESPHOME_F(string_literal) (string_literal)
#define ESPHOME_PGM_P const char *
#define ESPHOME_strncpy_P strncpy
#else
// ESP8266 and other Arduino platforms use Arduino macros
#ifdef USE_ESP8266
// ESP8266 uses Arduino macros
#define ESPHOME_F(string_literal) F(string_literal)
#define ESPHOME_PGM_P PGM_P
#define ESPHOME_strncpy_P strncpy_P
#else
#define ESPHOME_F(string_literal) (string_literal)
#define ESPHOME_PGM_P const char *
#define ESPHOME_strncpy_P strncpy
#endif

View File

@@ -164,8 +164,24 @@ def websocket_method(name):
return wrap
class CheckOriginMixin:
"""Mixin to handle WebSocket origin checks for reverse proxy setups."""
def check_origin(self, origin: str) -> bool:
if "ESPHOME_TRUSTED_DOMAINS" not in os.environ:
return super().check_origin(origin)
trusted_domains = [
s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",")
]
url = urlparse(origin)
if url.hostname in trusted_domains:
return True
_LOGGER.info("check_origin %s, domain is not trusted", origin)
return False
@websocket_class
class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler):
class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler):
"""Base class for ESPHome websocket commands."""
def __init__(
@@ -183,18 +199,6 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler):
# use Popen() with a reading thread instead
self._use_popen = os.name == "nt"
def check_origin(self, origin):
if "ESPHOME_TRUSTED_DOMAINS" not in os.environ:
return super().check_origin(origin)
trusted_domains = [
s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",")
]
url = urlparse(origin)
if url.hostname in trusted_domains:
return True
_LOGGER.info("check_origin %s, domain is not trusted", origin)
return False
def open(self, *args: str, **kwargs: str) -> None:
"""Handle new WebSocket connection."""
# Ensure messages from the subprocess are sent immediately
@@ -601,7 +605,7 @@ DASHBOARD_SUBSCRIBER = DashboardSubscriber()
@websocket_class
class DashboardEventsWebSocket(tornado.websocket.WebSocketHandler):
class DashboardEventsWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler):
"""WebSocket handler for real-time dashboard events."""
_event_listeners: list[Callable[[], None]] | None = None

View File

@@ -322,8 +322,8 @@ def perform_ota(
hash_func, nonce_size, hash_name = _AUTH_METHODS[auth]
perform_auth(sock, password, hash_func, nonce_size, hash_name)
# Set higher timeout during upload
sock.settimeout(30.0)
# Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures
sock.settimeout(90.0)
upload_size = len(upload_contents)
upload_size_encoded = [

View File

@@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages
import esphome.config as config_module
from esphome.config import resolve_extend_remove
from esphome.config_helpers import Extend, Remove
import esphome.config_validation as cv
@@ -33,6 +34,7 @@ from esphome.const import (
CONF_VARS,
CONF_WIFI,
)
from esphome.core import CORE
from esphome.util import OrderedDict
# Test strings
@@ -991,3 +993,35 @@ def test_package_merge_invalid(invalid_package) -> None:
with pytest.raises(cv.Invalid):
merge_packages(config)
def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None:
"""Test that CORE.raw_config contains esphome section from merged package.
This is a regression test for the bug where CORE.raw_config was set before
packages were merged, causing KeyError when components accessed
CORE.raw_config[CONF_ESPHOME] and the esphome section came from a package.
"""
# Create a config where esphome section comes from a package
test_config = OrderedDict()
test_config[CONF_PACKAGES] = {
"base": {
CONF_ESPHOME: {CONF_NAME: TEST_DEVICE_NAME},
}
}
test_config["esp32"] = {"board": "esp32dev"}
# Set up CORE for the test
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("# test config")
CORE.reset()
CORE.config_path = test_yaml
# Call validate_config - this should merge packages and set CORE.raw_config
config_module.validate_config(test_config, {})
# Verify that CORE.raw_config contains the esphome section from the package
assert CONF_ESPHOME in CORE.raw_config, (
"CORE.raw_config should contain esphome section after package merge"
)
assert CORE.raw_config[CONF_ESPHOME][CONF_NAME] == TEST_DEVICE_NAME

View File

@@ -1,13 +1,26 @@
cc1101:
id: transceiver
cs_pin: ${cs_pin}
gdo0_pin: ${gdo0_pin}
frequency: 433.92MHz
if_frequency: 153kHz
filter_bandwidth: 203kHz
channel: 0
channel_spacing: 200kHz
symbol_rate: 5000
modulation_type: ASK/OOK
symbol_rate: 4800
modulation_type: GFSK
packet_mode: true
packet_length: 8
crc_enable: true
whitening: false
sync_mode: "16/16"
sync0: 0x91
sync1: 0xD3
num_preamble: 2
on_packet:
then:
- lambda: |-
ESP_LOGD("cc1101", "packet %s rssi %.1f dBm lqi %u", format_hex(x).c_str(), rssi, lqi);
button:
- platform: template
@@ -18,3 +31,7 @@ button:
- cc1101.begin_rx: transceiver
- cc1101.set_idle: transceiver
- cc1101.reset: transceiver
- cc1101.send_packet:
data: [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]
- cc1101.send_packet: !lambda |-
return {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};

View File

@@ -1,8 +1,8 @@
substitutions:
cs_pin: GPIO5
gdo0_pin: GPIO4
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -1,8 +1,8 @@
substitutions:
cs_pin: GPIO5
gdo0_pin: GPIO4
packages:
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml
<<: !include common.yaml

View File

@@ -62,7 +62,7 @@ packet_transport:
sensors:
- temp_sensor
providers:
- name: test_provider
- name: test-provider
encryption:
key: "0123456789abcdef0123456789abcdef"
@@ -71,6 +71,6 @@ sensor:
id: temp_sensor
- platform: packet_transport
provider: test_provider
provider: test-provider
remote_id: temp_sensor
id: remote_temp

View File

@@ -0,0 +1,15 @@
<<: !include common-lan8720.yaml
sn74hc165:
- id: sn74hc165_hub
clock_pin: GPIO13
data_pin: GPIO14
load_pin: GPIO15
sr_count: 3
binary_sensor:
- platform: gpio
pin:
sn74hc165: sn74hc165_hub
number: 19
id: relay_2

View File

@@ -1567,3 +1567,90 @@ async def test_dashboard_yaml_loading_with_packages_and_secrets(
# If we get here, secret resolution worked!
assert "esphome" in config
assert config["esphome"]["name"] == "test-download-secrets"
@pytest.mark.asyncio
async def test_websocket_check_origin_default_same_origin(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket uses default same-origin check when ESPHOME_TRUSTED_DOMAINS not set."""
# Ensure ESPHOME_TRUSTED_DOMAINS is not set
env = os.environ.copy()
env.pop("ESPHOME_TRUSTED_DOMAINS", None)
with patch.dict(os.environ, env, clear=True):
from tornado.httpclient import HTTPRequest
url = f"ws://127.0.0.1:{dashboard.port}/events"
# Same origin should work (default Tornado behavior)
request = HTTPRequest(
url, headers={"Origin": f"http://127.0.0.1:{dashboard.port}"}
)
ws = await websocket_connect(request)
try:
msg = await ws.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "initial_state"
finally:
ws.close()
@pytest.mark.asyncio
async def test_websocket_check_origin_trusted_domain(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket accepts connections from trusted domains."""
with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}):
from tornado.httpclient import HTTPRequest
url = f"ws://127.0.0.1:{dashboard.port}/events"
request = HTTPRequest(url, headers={"Origin": "https://trusted.example.com"})
ws = await websocket_connect(request)
try:
# Should receive initial state
msg = await ws.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "initial_state"
finally:
ws.close()
@pytest.mark.asyncio
async def test_websocket_check_origin_untrusted_domain(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket rejects connections from untrusted domains."""
with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}):
from tornado.httpclient import HTTPRequest
url = f"ws://127.0.0.1:{dashboard.port}/events"
request = HTTPRequest(url, headers={"Origin": "https://untrusted.example.com"})
with pytest.raises(HTTPClientError) as exc_info:
await websocket_connect(request)
# Should get HTTP 403 Forbidden due to origin check failure
assert exc_info.value.code == 403
@pytest.mark.asyncio
async def test_websocket_check_origin_multiple_trusted_domains(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket accepts connections from multiple trusted domains."""
with patch.dict(
os.environ,
{"ESPHOME_TRUSTED_DOMAINS": "first.example.com, second.example.com"},
):
from tornado.httpclient import HTTPRequest
url = f"ws://127.0.0.1:{dashboard.port}/events"
# Test second domain in list (with space after comma)
request = HTTPRequest(url, headers={"Origin": "https://second.example.com"})
ws = await websocket_connect(request)
try:
msg = await ws.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "initial_state"
finally:
ws.close()