From ede64a9f47f182e6ed4aa83d1c219e2319c21c79 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Dec 2025 03:47:15 +1000 Subject: [PATCH 01/15] [packet_transport] Ensure retransmission at update intervals (#12472) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../packet_transport/espnow_transport.cpp | 11 +++-------- .../espnow/packet_transport/espnow_transport.h | 1 - esphome/components/packet_transport/__init__.py | 5 +++++ .../packet_transport/packet_transport.cpp | 6 +++++- .../packet_transport/packet_transport.h | 3 ++- .../packet_transport/sx126x_transport.cpp | 6 ------ .../sx126x/packet_transport/sx126x_transport.h | 1 - .../packet_transport/sx127x_transport.cpp | 6 ------ .../sx127x/packet_transport/sx127x_transport.h | 1 - .../uart/packet_transport/uart_transport.cpp | 6 ------ .../uart/packet_transport/uart_transport.h | 1 - .../udp/packet_transport/udp_transport.cpp | 17 +---------------- .../udp/packet_transport/udp_transport.h | 2 -- tests/components/espnow/common.yaml | 4 ++-- 14 files changed, 18 insertions(+), 52 deletions(-) diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index d30e9447a0..c1252acc9d 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -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(this)); + this->parent_->register_received_handler(this); // Register broadcasted handler - this->parent_->register_broadcasted_handler(static_cast(this)); -} - -void ESPNowTransport::update() { - packet_transport::PacketTransport::update(); - this->updated_ = true; + this->parent_->register_broadcasted_handler(this); } void ESPNowTransport::send_packet(const std::vector &buf) const { diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 3629fad2cd..d85119db7d 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -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) { diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 43da7740fe..1930e45e85 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -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 diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index 37e5f3d9e1..da7f5f8bff 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -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); } } diff --git a/esphome/components/packet_transport/packet_transport.h b/esphome/components/packet_transport/packet_transport.h index a2370e9749..86ec564fce 100644 --- a/esphome/components/packet_transport/packet_transport.h +++ b/esphome/components/packet_transport/packet_transport.h @@ -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 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_{}; diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp index 2cfc4b700e..59d80bd297 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp @@ -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 &buf) const { this->parent_->transmit_packet(buf); } void SX126xTransport::on_packet(const std::vector &packet, float rssi, float snr) { this->process_(packet); } diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.h b/esphome/components/sx126x/packet_transport/sx126x_transport.h index 755d30417d..640c6a76f9 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.h +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.h @@ -11,7 +11,6 @@ namespace sx126x { class SX126xTransport : public packet_transport::PacketTransport, public Parented, public SX126xListener { public: void setup() override; - void update() override; void on_packet(const std::vector &packet, float rssi, float snr) override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp index b1d014bb96..893726e816 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp @@ -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 &buf) const { this->parent_->transmit_packet(buf); } void SX127xTransport::on_packet(const std::vector &packet, float rssi, float snr) { this->process_(packet); } diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.h b/esphome/components/sx127x/packet_transport/sx127x_transport.h index e27b7f8d57..6208372971 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.h +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.h @@ -11,7 +11,6 @@ namespace sx127x { class SX127xTransport : public packet_transport::PacketTransport, public Parented, public SX127xListener { public: void setup() override; - void update() override; void on_packet(const std::vector &packet, float rssi, float snr) override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/uart/packet_transport/uart_transport.cpp b/esphome/components/uart/packet_transport/uart_transport.cpp index 4a9aa0fe47..6b8eae611c 100644 --- a/esphome/components/uart/packet_transport/uart_transport.cpp +++ b/esphome/components/uart/packet_transport/uart_transport.cpp @@ -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. diff --git a/esphome/components/uart/packet_transport/uart_transport.h b/esphome/components/uart/packet_transport/uart_transport.h index e84bed95e6..1c92af536e 100644 --- a/esphome/components/uart/packet_transport/uart_transport.h +++ b/esphome/components/uart/packet_transport/uart_transport.h @@ -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: diff --git a/esphome/components/udp/packet_transport/udp_transport.cpp b/esphome/components/udp/packet_transport/udp_transport.cpp index b92e0d64df..f3e33573a5 100644 --- a/esphome/components/udp/packet_transport/udp_transport.cpp +++ b/esphome/components/udp/packet_transport/udp_transport.cpp @@ -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 &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 &buf) const { this->parent_->send_packet(buf); } } // namespace udp } // namespace esphome diff --git a/esphome/components/udp/packet_transport/udp_transport.h b/esphome/components/udp/packet_transport/udp_transport.h index c87eb62780..8d01ae0909 100644 --- a/esphome/components/udp/packet_transport/udp_transport.h +++ b/esphome/components/udp/packet_transport/udp_transport.h @@ -12,14 +12,12 @@ namespace udp { class UDPTransport : public packet_transport::PacketTransport, public Parented { 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 &buf) const override; bool should_send() override; - bool should_broadcast_{false}; size_t get_max_packet_size() override { return MAX_PACKET_SIZE; } }; diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index 895ffb9d15..b724af54e0 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -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 From 786d7266f519f0dfb7654fc9a8472dffe1a87687 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:47:52 -0500 Subject: [PATCH 02/15] [core] Fix polling_component_schema and type consistency (#12478) Co-authored-by: Claude --- esphome/config_validation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c52b791120..08fffa6cec 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -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) From af04eaaba046b15bcd3551fe1612ddc07f875284 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 12:19:58 -0600 Subject: [PATCH 03/15] [wifi] Fix premature connection timeout on LibreTiny/Beken --- esphome/components/wifi/wifi_component.cpp | 19 +++++++++++++++++-- .../wifi/wifi_component_libretiny.cpp | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index d46916bfd9..7b8148c033 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -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 = 61000; + 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,7 +1199,7 @@ void WiFiComponent::check_connecting_finished() { } uint32_t now = millis(); - if (now - this->action_started_ > 30000) { + if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) { ESP_LOGW(TAG, "Connection timeout"); this->retry_connect(); return; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 4fd64bdfa3..a48b2dec69 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -322,7 +322,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)); From cfc0d8bdfc1bba66d0b4b90cf3f4edb3859d96e5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Dec 2025 13:22:55 -0500 Subject: [PATCH 04/15] [cc1101] Add packet mode support (#12474) Co-authored-by: Claude --- esphome/components/cc1101/__init__.py | 105 +++++++++++- esphome/components/cc1101/cc1101.cpp | 171 +++++++++++++++++--- esphome/components/cc1101/cc1101.h | 49 +++++- esphome/components/cc1101/cc1101defs.h | 23 +++ esphome/components/const/__init__.py | 2 + esphome/components/sx126x/__init__.py | 3 +- esphome/components/sx127x/__init__.py | 3 +- tests/components/cc1101/common.yaml | 21 ++- tests/components/cc1101/test.esp32-idf.yaml | 2 +- tests/components/cc1101/test.esp8266.yaml | 2 +- 10 files changed, 340 insertions(+), 41 deletions(-) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index e6b31b84f8..1971817fb1 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -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 diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index 3cbf09ded8..5b6eb545bc 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -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(Register::TEST0); i++) { @@ -151,8 +156,69 @@ void CC1101Component::setup() { } this->write_(static_cast(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(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(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(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 &packet) { + if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { + return CC1101Error::PARAMS; + } + + // Write packet + this->enter_idle_(); + this->strobe_(Command::FTX); + if (this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE)) { + this->write_(Register::FIFO, static_cast(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(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(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(LengthConfig::LENGTH_CONFIG_VARIABLE); + } else { + this->state_.LENGTH_CONFIG = static_cast(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 diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 65aeb2ea82..b896f7e974 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -5,9 +5,12 @@ #include "esphome/components/spi/spi.h" #include "esphome/core/automation.h" #include "cc1101defs.h" +#include namespace esphome::cc1101 { +enum class CC1101Error { NONE = 0, TIMEOUT, PARAMS, CRC_ERROR, FIFO_OVERFLOW }; + class CC1101Component : public Component, public spi::SPIDevice { @@ -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 &packet); + Trigger, 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, float, uint8_t> *packet_trigger_{new Trigger, float, uint8_t>()}; + std::vector packet_; + // Low-level Helpers uint8_t strobe_(Command cmd); void write_(Register reg); @@ -107,4 +126,28 @@ template class SetIdleAction : public Action, public Pare void play(const Ts &...x) override { this->parent_->set_idle(); } }; +template class SendPacketAction : public Action, public Parented { + public: + void set_data_template(std::function(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 data(this->data_static_, this->data_static_ + this->data_static_len_); + this->parent_->transmit_packet(data); + } + } + + protected: + std::function(Ts...)> data_func_{}; + const uint8_t *data_static_{nullptr}; + size_t data_static_len_{0}; +}; + } // namespace esphome::cc1101 diff --git a/esphome/components/cc1101/cc1101defs.h b/esphome/components/cc1101/cc1101defs.h index afeb5f1d77..1bc42f5859 100644 --- a/esphome/components/cc1101/cc1101defs.h +++ b/esphome/components/cc1101/cc1101defs.h @@ -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(this); } diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 12a69551f5..fcfafa0c1a 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -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" diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 1eb83b7a33..4641db6483 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -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" diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 77cb61f7f8..b569a75972 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -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" diff --git a/tests/components/cc1101/common.yaml b/tests/components/cc1101/common.yaml index 9373ca43e1..93f03e582e 100644 --- a/tests/components/cc1101/common.yaml +++ b/tests/components/cc1101/common.yaml @@ -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}; diff --git a/tests/components/cc1101/test.esp32-idf.yaml b/tests/components/cc1101/test.esp32-idf.yaml index e075629679..966f11bb64 100644 --- a/tests/components/cc1101/test.esp32-idf.yaml +++ b/tests/components/cc1101/test.esp32-idf.yaml @@ -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 diff --git a/tests/components/cc1101/test.esp8266.yaml b/tests/components/cc1101/test.esp8266.yaml index 7900658bc1..6f0f078507 100644 --- a/tests/components/cc1101/test.esp8266.yaml +++ b/tests/components/cc1101/test.esp8266.yaml @@ -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 From 7eff3217aadd59ab90c5679ae177cc156617bff0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 12:34:54 -0600 Subject: [PATCH 05/15] [ota] Match client timeout to device timeout to prevent premature failures --- esphome/espota2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index c29506224c..6349ad0fa8 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -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 = [ From 8ce2cc564f1b0960653aaaf2d1c915b5888208d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 13:26:54 -0600 Subject: [PATCH 06/15] make sure we are disconnected on timeout --- esphome/components/wifi/wifi_component.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 7b8148c033..b20d61fd1a 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1200,7 +1200,8 @@ void WiFiComponent::check_connecting_finished() { uint32_t now = millis(); if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) { - ESP_LOGW(TAG, "Connection timeout"); + ESP_LOGW(TAG, "Connection timeout, aborting connection attempt"); + this->wifi_disconnect_(); this->retry_connect(); return; } From 616dae5bf925c04a53b73672aa6355cf4e5ce3f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 13:37:48 -0600 Subject: [PATCH 07/15] fix missing s_sta_connecting = false; --- esphome/components/wifi/wifi_component_libretiny.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index a48b2dec69..9f19e9da5f 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -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: { From 6939b67e4728a9f4ff37cc70b833fc75d612f8d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 13:42:10 -0600 Subject: [PATCH 08/15] esp32 has same bug --- esphome/components/wifi/wifi_component_esp_idf.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 1f4eb1e42c..4a3c40a119 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -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; From 4928862622a8bb02f6b0faf6935f2762d46182ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 13:42:10 -0600 Subject: [PATCH 09/15] esp32 has same bug --- esphome/components/wifi/wifi_component_esp_idf.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 1f4eb1e42c..4a3c40a119 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -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; From 7801420ecafdb9e0c08a8b1ad831c15800e8e71b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 14:18:59 -0600 Subject: [PATCH 10/15] one more failure more --- esphome/components/wifi/wifi_component.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index b20d61fd1a..2003d0e006 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1421,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; From f22396a09726df3c8fe0719feaf086d6792caa0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 14:25:23 -0600 Subject: [PATCH 11/15] fixes --- esphome/components/wifi/wifi_component_libretiny.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 9f19e9da5f..36003a6eb4 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -528,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{}; From c8b48df8f276fe4ccc24f0257e8f30c6a5afff67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 14:31:41 -0600 Subject: [PATCH 12/15] tweak --- esphome/components/wifi/wifi_component.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 2003d0e006..cadc477325 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -218,7 +218,7 @@ static constexpr uint32_t WIFI_SCAN_TIMEOUT_MS = 31000; /// 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 = 61000; +static constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 42000; static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) { switch (phase) { From 11c9e974ac64bae211ed78dbbb087478a7e47779 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 14:38:02 -0600 Subject: [PATCH 13/15] tweak --- esphome/components/wifi/wifi_component.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index cadc477325..a5e8c4a59d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -218,7 +218,7 @@ static constexpr uint32_t WIFI_SCAN_TIMEOUT_MS = 31000; /// 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 = 42000; +static constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 46000; static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) { switch (phase) { From 712da5c2aed8e77303a9c44c5cfad364fadc3436 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 15:07:22 -0600 Subject: [PATCH 14/15] recovery --- esphome/components/wifi/wifi_component.cpp | 15 ++++++++++++++- esphome/components/wifi/wifi_component.h | 4 ++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index a5e8c4a59d..a493e5e6e1 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -196,6 +196,11 @@ static constexpr uint8_t WIFI_RETRY_COUNT_PER_SSID = 1; // Rationale: Fast connect prioritizes speed - try each AP once to find a working one quickly static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1; +// 2 forced scan cycles after credentials change via captive portal/improv +// Rationale: After new credentials are submitted, we need fresh scan results to find the +// best AP (strongest signal). Two cycles in case the first scan catches the network mid-transition. +static constexpr uint8_t WIFI_FORCE_SCAN_AFTER_CREDENTIAL_CHANGE = 2; + /// Cooldown duration in milliseconds after adapter restart or repeated failures /// Allows WiFi hardware to stabilize before next connection attempt static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; @@ -685,6 +690,8 @@ void WiFiComponent::set_sta(const WiFiAP &ap) { this->init_sta(1); this->add_sta(ap); this->selected_sta_index_ = 0; + // Force scan cycles to find best AP even when captive portal/improv is active + this->force_scan_count_ = WIFI_FORCE_SCAN_AFTER_CREDENTIAL_CHANGE; // When new credentials are set (e.g., from improv), skip cooldown to retry immediately this->skip_cooldown_next_cycle_ = true; } @@ -1331,8 +1338,14 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { } // Skip scanning when captive portal/improv is active to avoid disrupting AP // Even passive scans can cause brief AP disconnections on ESP32 + // Exception: force scans after credential change to find best AP if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) { - return WiFiRetryPhase::RETRY_HIDDEN; + if (this->force_scan_count_ > 0) { + this->force_scan_count_--; + ESP_LOGD(TAG, "Forcing scan despite active portal (remaining: %u)", this->force_scan_count_); + } else { + return WiFiRetryPhase::RETRY_HIDDEN; + } } return WiFiRetryPhase::SCAN_CONNECTING; } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index be94e9462b..6d9ab5b00c 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -643,6 +643,10 @@ class WiFiComponent : public Component { bool keep_scan_results_{false}; bool did_scan_this_cycle_{false}; bool skip_cooldown_next_cycle_{false}; + /// Force scan cycles after credentials change (even when AP is active) + /// Set to 2 after captive portal/improv submits new credentials to ensure + /// we find the best AP rather than connecting to a potentially weak one + uint8_t force_scan_count_{0}; #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE}; bool is_high_performance_mode_{false}; From d9296a907dd2b5563e67bd0b1da0e364719e36c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 15:13:22 -0600 Subject: [PATCH 15/15] Revert "recovery" This reverts commit 712da5c2aed8e77303a9c44c5cfad364fadc3436. --- esphome/components/wifi/wifi_component.cpp | 15 +-------------- esphome/components/wifi/wifi_component.h | 4 ---- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index a493e5e6e1..a5e8c4a59d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -196,11 +196,6 @@ static constexpr uint8_t WIFI_RETRY_COUNT_PER_SSID = 1; // Rationale: Fast connect prioritizes speed - try each AP once to find a working one quickly static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1; -// 2 forced scan cycles after credentials change via captive portal/improv -// Rationale: After new credentials are submitted, we need fresh scan results to find the -// best AP (strongest signal). Two cycles in case the first scan catches the network mid-transition. -static constexpr uint8_t WIFI_FORCE_SCAN_AFTER_CREDENTIAL_CHANGE = 2; - /// Cooldown duration in milliseconds after adapter restart or repeated failures /// Allows WiFi hardware to stabilize before next connection attempt static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; @@ -690,8 +685,6 @@ void WiFiComponent::set_sta(const WiFiAP &ap) { this->init_sta(1); this->add_sta(ap); this->selected_sta_index_ = 0; - // Force scan cycles to find best AP even when captive portal/improv is active - this->force_scan_count_ = WIFI_FORCE_SCAN_AFTER_CREDENTIAL_CHANGE; // When new credentials are set (e.g., from improv), skip cooldown to retry immediately this->skip_cooldown_next_cycle_ = true; } @@ -1338,14 +1331,8 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { } // Skip scanning when captive portal/improv is active to avoid disrupting AP // Even passive scans can cause brief AP disconnections on ESP32 - // Exception: force scans after credential change to find best AP if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) { - if (this->force_scan_count_ > 0) { - this->force_scan_count_--; - ESP_LOGD(TAG, "Forcing scan despite active portal (remaining: %u)", this->force_scan_count_); - } else { - return WiFiRetryPhase::RETRY_HIDDEN; - } + return WiFiRetryPhase::RETRY_HIDDEN; } return WiFiRetryPhase::SCAN_CONNECTING; } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 6d9ab5b00c..be94e9462b 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -643,10 +643,6 @@ class WiFiComponent : public Component { bool keep_scan_results_{false}; bool did_scan_this_cycle_{false}; bool skip_cooldown_next_cycle_{false}; - /// Force scan cycles after credentials change (even when AP is active) - /// Set to 2 after captive portal/improv submits new credentials to ensure - /// we find the best AP rather than connecting to a potentially weak one - uint8_t force_scan_count_{0}; #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE}; bool is_high_performance_mode_{false};