1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-11 20:35:51 +00:00

Compare commits

..

31 Commits

Author SHA1 Message Date
J. Nick Koston
99bb6ead7e Merge branch 'dev' into no_batch_events 2025-11-09 22:04:58 -06:00
J. Nick Koston
d171406dff Merge branch 'dev' into no_batch_events 2025-11-09 17:35:33 -06:00
J. Nick Koston
65fd784fa7 tidy 2025-11-08 10:39:09 -06:00
J. Nick Koston
b9f208b63a [api][event] Send events immediately to prevent loss during rapid triggers 2025-11-08 10:16:18 -06:00
J. Nick Koston
fac05dab35 Merge branch 'api_event_test' into controller_registry_event_has_prog_lifetime 2025-11-08 09:46:44 -06:00
J. Nick Koston
f6100a55bc Merge branch 'controller_registry' into api_event_test 2025-11-08 09:46:43 -06:00
J. Nick Koston
c2abf363b6 Ensure event paths are enabled in api compile tests 2025-11-08 09:45:44 -06:00
J. Nick Koston
62f43d3353 dry 2025-11-08 08:41:46 -06:00
J. Nick Koston
7e96f10a79 dry 2025-11-08 08:39:23 -06:00
J. Nick Koston
6b9cb4289a Merge branch 'controller_registry' into controller_registry_event_has_prog_lifetime 2025-11-07 18:16:57 -06:00
J. Nick Koston
b264c6caac cleanup defines 2025-11-07 18:16:22 -06:00
J. Nick Koston
a6c669ff51 cleanup 2025-11-07 18:03:38 -06:00
J. Nick Koston
c15290e386 wip 2025-11-07 17:53:26 -06:00
J. Nick Koston
e3fb074a60 preen 2025-11-07 17:14:50 -06:00
J. Nick Koston
6e7f66d393 missing registry 2025-11-07 16:40:36 -06:00
J. Nick Koston
ac85949f17 cleanups 2025-11-07 16:38:32 -06:00
J. Nick Koston
0962024d99 cleanups 2025-11-07 16:35:24 -06:00
J. Nick Koston
327543303c cleanups 2025-11-07 16:34:37 -06:00
J. Nick Koston
8229e3a471 cleanups 2025-11-07 16:33:01 -06:00
J. Nick Koston
1b6471f4b0 cleanups 2025-11-07 16:30:38 -06:00
J. Nick Koston
c87d07ba70 fixes 2025-11-07 16:15:07 -06:00
J. Nick Koston
fc8dc33023 fixes 2025-11-07 16:13:59 -06:00
J. Nick Koston
c0e4f415f1 Revert "no ifdefs needed on forward decs"
This reverts commit 871c5ddb4e.
2025-11-07 16:10:56 -06:00
J. Nick Koston
871c5ddb4e no ifdefs needed on forward decs 2025-11-07 16:07:54 -06:00
J. Nick Koston
6ef2763cab controller registry 2025-11-07 16:01:45 -06:00
J. Nick Koston
929279dc23 controller registry 2025-11-07 15:55:22 -06:00
J. Nick Koston
6fa0f1e290 controller registry 2025-11-07 15:51:13 -06:00
J. Nick Koston
51eb8ea1d0 controller registry 2025-11-07 15:48:02 -06:00
J. Nick Koston
cbdd663fbf Merge remote-tracking branch 'upstream/dev' into controller_registry 2025-11-07 15:46:57 -06:00
J. Nick Koston
f1009a7468 tweak 2025-11-07 15:44:17 -06:00
J. Nick Koston
295fe8da04 controller registry phase1/2 2025-11-07 15:32:46 -06:00
65 changed files with 347 additions and 2414 deletions

View File

@@ -172,7 +172,8 @@ This document provides essential context for AI models interacting with this pro
* **C++ Class Pattern:**
```cpp
namespace esphome::my_component {
namespace esphome {
namespace my_component {
class MyComponent : public Component {
public:
@@ -188,7 +189,8 @@ This document provides essential context for AI models interacting with this pro
int param_{0};
};
} // namespace esphome::my_component
} // namespace my_component
} // namespace esphome
```
* **Common Component Examples:**

View File

@@ -21,7 +21,7 @@ permissions:
jobs:
request-codeowner-reviews:
name: Run
if: ${{ github.repository == 'esphome/esphome' && !github.event.pull_request.draft }}
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-latest
steps:
- name: Request reviews from component codeowners

View File

@@ -206,7 +206,6 @@ esphome/components/hdc2010/* @optimusprimespace @ssieb
esphome/components/he60r/* @clydebarrow
esphome/components/heatpumpir/* @rob-deutsch
esphome/components/hitachi_ac424/* @sourabhjaiswal
esphome/components/hlk_fm22x/* @OnFreund
esphome/components/hm3301/* @freekode
esphome/components/hmac_md5/* @dwmw2
esphome/components/homeassistant/* @esphome/core @OttoWinter
@@ -396,7 +395,6 @@ esphome/components/rpi_dpi_rgb/* @clydebarrow
esphome/components/rtl87xx/* @kuba2k2
esphome/components/rtttl/* @glmnet
esphome/components/runtime_stats/* @bdraco
esphome/components/rx8130/* @beormund
esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti
esphome/components/scd4x/* @martgras @sjtrny
esphome/components/script/* @esphome/core

View File

@@ -1294,11 +1294,11 @@ void APIConnection::alarm_control_panel_command(const AlarmControlPanelCommandRe
#endif
#ifdef USE_EVENT
void APIConnection::send_event(event::Event *event, const std::string &event_type) {
this->schedule_message_(event, MessageCreator(event_type), EventResponse::MESSAGE_TYPE,
EventResponse::ESTIMATED_SIZE);
void APIConnection::send_event(event::Event *event, const char *event_type) {
this->send_message_smart_(event, MessageCreator(event_type), EventResponse::MESSAGE_TYPE,
EventResponse::ESTIMATED_SIZE);
}
uint16_t APIConnection::try_send_event_response(event::Event *event, const std::string &event_type, APIConnection *conn,
uint16_t APIConnection::try_send_event_response(event::Event *event, const char *event_type, APIConnection *conn,
uint32_t remaining_size, bool is_single) {
EventResponse resp;
resp.set_event_type(StringRef(event_type));
@@ -1650,9 +1650,7 @@ void APIConnection::DeferredBatch::add_item(EntityBase *entity, MessageCreator c
// O(n) but optimized for RAM and not performance.
for (auto &item : items) {
if (item.entity == entity && item.message_type == message_type) {
// Clean up old creator before replacing
item.creator.cleanup(message_type);
// Move assign the new creator
// Replace with new creator
item.creator = std::move(creator);
return;
}
@@ -1822,7 +1820,7 @@ void APIConnection::process_batch_() {
// Handle remaining items more efficiently
if (items_processed < this->deferred_batch_.size()) {
// Remove processed items from the beginning with proper cleanup
// Remove processed items from the beginning
this->deferred_batch_.remove_front(items_processed);
// Reschedule for remaining items
this->schedule_batch_();
@@ -1835,10 +1833,10 @@ void APIConnection::process_batch_() {
uint16_t APIConnection::MessageCreator::operator()(EntityBase *entity, APIConnection *conn, uint32_t remaining_size,
bool is_single, uint8_t message_type) const {
#ifdef USE_EVENT
// Special case: EventResponse uses string pointer
// Special case: EventResponse uses const char * pointer
if (message_type == EventResponse::MESSAGE_TYPE) {
auto *e = static_cast<event::Event *>(entity);
return APIConnection::try_send_event_response(e, *data_.string_ptr, conn, remaining_size, is_single);
return APIConnection::try_send_event_response(e, data_.const_char_ptr, conn, remaining_size, is_single);
}
#endif

View File

@@ -177,7 +177,7 @@ class APIConnection final : public APIServerConnection {
#endif
#ifdef USE_EVENT
void send_event(event::Event *event, const std::string &event_type);
void send_event(event::Event *event, const char *event_type);
#endif
#ifdef USE_UPDATE
@@ -450,7 +450,7 @@ class APIConnection final : public APIServerConnection {
bool is_single);
#endif
#ifdef USE_EVENT
static uint16_t try_send_event_response(event::Event *event, const std::string &event_type, APIConnection *conn,
static uint16_t try_send_event_response(event::Event *event, const char *event_type, APIConnection *conn,
uint32_t remaining_size, bool is_single);
static uint16_t try_send_event_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single);
#endif
@@ -508,10 +508,8 @@ class APIConnection final : public APIServerConnection {
// Constructor for function pointer
MessageCreator(MessageCreatorPtr ptr) { data_.function_ptr = ptr; }
// Constructor for string state capture
explicit MessageCreator(const std::string &str_value) { data_.string_ptr = new std::string(str_value); }
// No destructor - cleanup must be called explicitly with message_type
// Constructor for const char * (Event types - no allocation needed)
explicit MessageCreator(const char *str_value) { data_.const_char_ptr = str_value; }
// Delete copy operations - MessageCreator should only be moved
MessageCreator(const MessageCreator &other) = delete;
@@ -523,8 +521,6 @@ class APIConnection final : public APIServerConnection {
// Move assignment
MessageCreator &operator=(MessageCreator &&other) noexcept {
if (this != &other) {
// IMPORTANT: Caller must ensure cleanup() was called if this contains a string!
// In our usage, this happens in add_item() deduplication and vector::erase()
data_ = other.data_;
other.data_.function_ptr = nullptr;
}
@@ -535,20 +531,10 @@ class APIConnection final : public APIServerConnection {
uint16_t operator()(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single,
uint8_t message_type) const;
// Manual cleanup method - must be called before destruction for string types
void cleanup(uint8_t message_type) {
#ifdef USE_EVENT
if (message_type == EventResponse::MESSAGE_TYPE && data_.string_ptr != nullptr) {
delete data_.string_ptr;
data_.string_ptr = nullptr;
}
#endif
}
private:
union Data {
MessageCreatorPtr function_ptr;
std::string *string_ptr;
const char *const_char_ptr;
} data_; // 4 bytes on 32-bit, 8 bytes on 64-bit - same as before
};
@@ -568,42 +554,24 @@ class APIConnection final : public APIServerConnection {
std::vector<BatchItem> items;
uint32_t batch_start_time{0};
private:
// Helper to cleanup items from the beginning
void cleanup_items_(size_t count) {
for (size_t i = 0; i < count; i++) {
items[i].creator.cleanup(items[i].message_type);
}
}
public:
DeferredBatch() {
// Pre-allocate capacity for typical batch sizes to avoid reallocation
items.reserve(8);
}
~DeferredBatch() {
// Ensure cleanup of any remaining items
clear();
}
// Add item to the batch
void add_item(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size);
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size);
// Clear all items with proper cleanup
// Clear all items
void clear() {
cleanup_items_(items.size());
items.clear();
batch_start_time = 0;
}
// Remove processed items from the front with proper cleanup
void remove_front(size_t count) {
cleanup_items_(count);
items.erase(items.begin(), items.begin() + count);
}
// Remove processed items from the front
void remove_front(size_t count) { items.erase(items.begin(), items.begin() + count); }
bool empty() const { return items.empty(); }
size_t size() const { return items.size(); }
@@ -682,21 +650,30 @@ class APIConnection final : public APIServerConnection {
}
#endif
// Helper to check if a message type should bypass batching
// Returns true if:
// 1. It's an UpdateStateResponse (always send immediately to handle cases where
// the main loop is blocked, e.g., during OTA updates)
// 2. It's an EventResponse (events are edge-triggered - every occurrence matters)
// 3. OR: User has opted into immediate sending (should_try_send_immediately = true
// AND batch_delay = 0)
inline bool should_send_immediately_(uint8_t message_type) const {
return (
#ifdef USE_UPDATE
message_type == UpdateStateResponse::MESSAGE_TYPE ||
#endif
#ifdef USE_EVENT
message_type == EventResponse::MESSAGE_TYPE ||
#endif
(this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0));
}
// Helper method to send a message either immediately or via batching
// Tries immediate send if should_send_immediately_() returns true and buffer has space
// Falls back to batching if immediate send fails or isn't applicable
bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint8_t message_type,
uint8_t estimated_size) {
// Try to send immediately if:
// 1. It's an UpdateStateResponse (always send immediately to handle cases where
// the main loop is blocked, e.g., during OTA updates)
// 2. OR: We should try to send immediately (should_try_send_immediately = true)
// AND Batch delay is 0 (user has opted in to immediate sending)
// 3. AND: Buffer has space available
if ((
#ifdef USE_UPDATE
message_type == UpdateStateResponse::MESSAGE_TYPE ||
#endif
(this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0)) &&
this->helper_->can_write_without_blocking()) {
if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) {
// Now actually encode and send
if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true) &&
this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) {
@@ -714,6 +691,27 @@ class APIConnection final : public APIServerConnection {
return this->schedule_message_(entity, creator, message_type, estimated_size);
}
// Overload for MessageCreator (used by events which need to capture event_type)
bool send_message_smart_(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size) {
// Try to send immediately if message type should bypass batching and buffer has space
if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) {
// Now actually encode and send
if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true, message_type) &&
this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) {
#ifdef HAS_PROTO_MESSAGE_DUMP
// Log the message in verbose mode
this->log_proto_message_(entity, creator, message_type);
#endif
return true;
}
// If immediate send failed, fall through to batching
}
// Fall back to scheduled batching
return this->schedule_message_(entity, std::move(creator), message_type, estimated_size);
}
// Helper function to schedule a deferred message with known message type
bool schedule_message_(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size) {
this->deferred_batch_.add_item(entity, std::move(creator), message_type, estimated_size);

View File

@@ -23,7 +23,7 @@ void DS1307Component::dump_config() {
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
RealTimeClock::dump_config();
ESP_LOGCONFIG(TAG, " Timezone: '%s'", this->timezone_.c_str());
}
float DS1307Component::get_setup_priority() const { return setup_priority::DATA; }

View File

@@ -418,6 +418,8 @@ void EthernetComponent::dump_config() {
float EthernetComponent::get_setup_priority() const { return setup_priority::WIFI; }
bool EthernetComponent::can_proceed() { return this->is_connected(); }
network::IPAddresses EthernetComponent::get_ip_addresses() {
network::IPAddresses addresses;
esp_netif_ip_info_t ip;

View File

@@ -58,6 +58,7 @@ class EthernetComponent : public Component {
void loop() override;
void dump_config() override;
float get_setup_priority() const override;
bool can_proceed() override;
void on_powerdown() override { powerdown(); }
bool is_connected();

View File

@@ -1,247 +0,0 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import uart
import esphome.config_validation as cv
from esphome.const import (
CONF_DIRECTION,
CONF_ID,
CONF_NAME,
CONF_ON_ENROLLMENT_DONE,
CONF_ON_ENROLLMENT_FAILED,
CONF_TRIGGER_ID,
)
CODEOWNERS = ["@OnFreund"]
DEPENDENCIES = ["uart"]
AUTO_LOAD = ["binary_sensor", "sensor", "text_sensor"]
MULTI_CONF = True
CONF_HLK_FM22X_ID = "hlk_fm22x_id"
CONF_FACE_ID = "face_id"
CONF_ON_FACE_SCAN_MATCHED = "on_face_scan_matched"
CONF_ON_FACE_SCAN_UNMATCHED = "on_face_scan_unmatched"
CONF_ON_FACE_SCAN_INVALID = "on_face_scan_invalid"
CONF_ON_FACE_INFO = "on_face_info"
hlk_fm22x_ns = cg.esphome_ns.namespace("hlk_fm22x")
HlkFm22xComponent = hlk_fm22x_ns.class_(
"HlkFm22xComponent", cg.PollingComponent, uart.UARTDevice
)
FaceScanMatchedTrigger = hlk_fm22x_ns.class_(
"FaceScanMatchedTrigger", automation.Trigger.template(cg.int16, cg.std_string)
)
FaceScanUnmatchedTrigger = hlk_fm22x_ns.class_(
"FaceScanUnmatchedTrigger", automation.Trigger.template()
)
FaceScanInvalidTrigger = hlk_fm22x_ns.class_(
"FaceScanInvalidTrigger", automation.Trigger.template(cg.uint8)
)
FaceInfoTrigger = hlk_fm22x_ns.class_(
"FaceInfoTrigger",
automation.Trigger.template(
cg.int16, cg.int16, cg.int16, cg.int16, cg.int16, cg.int16, cg.int16, cg.int16
),
)
EnrollmentDoneTrigger = hlk_fm22x_ns.class_(
"EnrollmentDoneTrigger", automation.Trigger.template(cg.int16, cg.uint8)
)
EnrollmentFailedTrigger = hlk_fm22x_ns.class_(
"EnrollmentFailedTrigger", automation.Trigger.template(cg.uint8)
)
EnrollmentAction = hlk_fm22x_ns.class_("EnrollmentAction", automation.Action)
DeleteAction = hlk_fm22x_ns.class_("DeleteAction", automation.Action)
DeleteAllAction = hlk_fm22x_ns.class_("DeleteAllAction", automation.Action)
ScanAction = hlk_fm22x_ns.class_("ScanAction", automation.Action)
ResetAction = hlk_fm22x_ns.class_("ResetAction", automation.Action)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(HlkFm22xComponent),
cv.Optional(CONF_ON_FACE_SCAN_MATCHED): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
FaceScanMatchedTrigger
),
}
),
cv.Optional(CONF_ON_FACE_SCAN_UNMATCHED): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
FaceScanUnmatchedTrigger
),
}
),
cv.Optional(CONF_ON_FACE_SCAN_INVALID): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
FaceScanInvalidTrigger
),
}
),
cv.Optional(CONF_ON_FACE_INFO): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FaceInfoTrigger),
}
),
cv.Optional(CONF_ON_ENROLLMENT_DONE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
EnrollmentDoneTrigger
),
}
),
cv.Optional(CONF_ON_ENROLLMENT_FAILED): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
EnrollmentFailedTrigger
),
}
),
}
)
.extend(cv.polling_component_schema("50ms"))
.extend(uart.UART_DEVICE_SCHEMA),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
for conf in config.get(CONF_ON_FACE_SCAN_MATCHED, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(
trigger, [(cg.int16, "face_id"), (cg.std_string, "name")], conf
)
for conf in config.get(CONF_ON_FACE_SCAN_UNMATCHED, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_FACE_SCAN_INVALID, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [(cg.uint8, "error")], conf)
for conf in config.get(CONF_ON_FACE_INFO, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(
trigger,
[
(cg.int16, "status"),
(cg.int16, "left"),
(cg.int16, "top"),
(cg.int16, "right"),
(cg.int16, "bottom"),
(cg.int16, "yaw"),
(cg.int16, "pitch"),
(cg.int16, "roll"),
],
conf,
)
for conf in config.get(CONF_ON_ENROLLMENT_DONE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(
trigger, [(cg.int16, "face_id"), (cg.uint8, "direction")], conf
)
for conf in config.get(CONF_ON_ENROLLMENT_FAILED, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [(cg.uint8, "error")], conf)
@automation.register_action(
"hlk_fm22x.enroll",
EnrollmentAction,
cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(HlkFm22xComponent),
cv.Required(CONF_NAME): cv.templatable(cv.string),
cv.Required(CONF_DIRECTION): cv.templatable(cv.uint8_t),
},
key=CONF_NAME,
),
)
async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
template_ = await cg.templatable(config[CONF_NAME], args, cg.std_string)
cg.add(var.set_name(template_))
template_ = await cg.templatable(config[CONF_DIRECTION], args, cg.uint8)
cg.add(var.set_direction(template_))
return var
@automation.register_action(
"hlk_fm22x.delete",
DeleteAction,
cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(HlkFm22xComponent),
cv.Required(CONF_FACE_ID): cv.templatable(cv.uint16_t),
},
key=CONF_FACE_ID,
),
)
async def hlk_fm22x_delete_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
template_ = await cg.templatable(config[CONF_FACE_ID], args, cg.int16)
cg.add(var.set_face_id(template_))
return var
@automation.register_action(
"hlk_fm22x.delete_all",
DeleteAllAction,
cv.Schema(
{
cv.GenerateID(): cv.use_id(HlkFm22xComponent),
}
),
)
async def hlk_fm22x_delete_all_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
@automation.register_action(
"hlk_fm22x.scan",
ScanAction,
cv.Schema(
{
cv.GenerateID(): cv.use_id(HlkFm22xComponent),
}
),
)
async def hlk_fm22x_scan_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
@automation.register_action(
"hlk_fm22x.reset",
ResetAction,
cv.Schema(
{
cv.GenerateID(): cv.use_id(HlkFm22xComponent),
}
),
)
async def hlk_fm22x_reset_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

View File

@@ -1,21 +0,0 @@
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_ICON, ICON_KEY_PLUS
from . import CONF_HLK_FM22X_ID, HlkFm22xComponent
DEPENDENCIES = ["hlk_fm22x"]
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend(
{
cv.GenerateID(CONF_HLK_FM22X_ID): cv.use_id(HlkFm22xComponent),
cv.Optional(CONF_ICON, default=ICON_KEY_PLUS): cv.icon,
}
)
async def to_code(config):
hub = await cg.get_variable(config[CONF_HLK_FM22X_ID])
var = await binary_sensor.new_binary_sensor(config)
cg.add(hub.set_enrolling_binary_sensor(var))

View File

@@ -1,325 +0,0 @@
#include "hlk_fm22x.h"
#include "esphome/core/log.h"
#include "esphome/core/helpers.h"
#include <array>
#include <cinttypes>
namespace esphome::hlk_fm22x {
static const char *const TAG = "hlk_fm22x";
void HlkFm22xComponent::setup() {
ESP_LOGCONFIG(TAG, "Setting up HLK-FM22X...");
this->set_enrolling_(false);
while (this->available()) {
this->read();
}
this->defer([this]() { this->send_command_(HlkFm22xCommand::GET_STATUS); });
}
void HlkFm22xComponent::update() {
if (this->active_command_ != HlkFm22xCommand::NONE) {
if (this->wait_cycles_ > 600) {
ESP_LOGE(TAG, "Command 0x%.2X timed out", this->active_command_);
if (HlkFm22xCommand::RESET == this->active_command_) {
this->mark_failed();
} else {
this->reset();
}
}
}
this->recv_command_();
}
void HlkFm22xComponent::enroll_face(const std::string &name, HlkFm22xFaceDirection direction) {
if (name.length() > 31) {
ESP_LOGE(TAG, "enroll_face(): name too long '%s'", name.c_str());
return;
}
ESP_LOGI(TAG, "Starting enrollment for %s", name.c_str());
std::array<uint8_t, 35> data{};
data[0] = 0; // admin
std::copy(name.begin(), name.end(), data.begin() + 1);
// Remaining bytes are already zero-initialized
data[33] = (uint8_t) direction;
data[34] = 10; // timeout
this->send_command_(HlkFm22xCommand::ENROLL, data.data(), data.size());
this->set_enrolling_(true);
}
void HlkFm22xComponent::scan_face() {
ESP_LOGI(TAG, "Verify face");
static const uint8_t DATA[] = {0, 0};
this->send_command_(HlkFm22xCommand::VERIFY, DATA, sizeof(DATA));
}
void HlkFm22xComponent::delete_face(int16_t face_id) {
ESP_LOGI(TAG, "Deleting face in slot %d", face_id);
const uint8_t data[] = {(uint8_t) (face_id >> 8), (uint8_t) (face_id & 0xFF)};
this->send_command_(HlkFm22xCommand::DELETE_FACE, data, sizeof(data));
}
void HlkFm22xComponent::delete_all_faces() {
ESP_LOGI(TAG, "Deleting all stored faces");
this->send_command_(HlkFm22xCommand::DELETE_ALL_FACES);
}
void HlkFm22xComponent::get_face_count_() {
ESP_LOGD(TAG, "Getting face count");
this->send_command_(HlkFm22xCommand::GET_ALL_FACE_IDS);
}
void HlkFm22xComponent::reset() {
ESP_LOGI(TAG, "Resetting module");
this->active_command_ = HlkFm22xCommand::NONE;
this->wait_cycles_ = 0;
this->set_enrolling_(false);
this->send_command_(HlkFm22xCommand::RESET);
}
void HlkFm22xComponent::send_command_(HlkFm22xCommand command, const uint8_t *data, size_t size) {
ESP_LOGV(TAG, "Send command: 0x%.2X", command);
if (this->active_command_ != HlkFm22xCommand::NONE) {
ESP_LOGW(TAG, "Command 0x%.2X already active", this->active_command_);
return;
}
this->wait_cycles_ = 0;
this->active_command_ = command;
while (this->available())
this->read();
this->write((uint8_t) (START_CODE >> 8));
this->write((uint8_t) (START_CODE & 0xFF));
this->write((uint8_t) command);
uint16_t data_size = size;
this->write((uint8_t) (data_size >> 8));
this->write((uint8_t) (data_size & 0xFF));
uint8_t checksum = 0;
checksum ^= (uint8_t) command;
checksum ^= (data_size >> 8);
checksum ^= (data_size & 0xFF);
for (size_t i = 0; i < size; i++) {
this->write(data[i]);
checksum ^= data[i];
}
this->write(checksum);
this->active_command_ = command;
this->wait_cycles_ = 0;
}
void HlkFm22xComponent::recv_command_() {
uint8_t byte, checksum = 0;
uint16_t length = 0;
if (this->available() < 7) {
++this->wait_cycles_;
return;
}
this->wait_cycles_ = 0;
if ((this->read() != (uint8_t) (START_CODE >> 8)) || (this->read() != (uint8_t) (START_CODE & 0xFF))) {
ESP_LOGE(TAG, "Invalid start code");
return;
}
byte = this->read();
checksum ^= byte;
HlkFm22xResponseType response_type = (HlkFm22xResponseType) byte;
byte = this->read();
checksum ^= byte;
length = byte << 8;
byte = this->read();
checksum ^= byte;
length |= byte;
std::vector<uint8_t> data;
data.reserve(length);
for (uint16_t idx = 0; idx < length; ++idx) {
byte = this->read();
checksum ^= byte;
data.push_back(byte);
}
ESP_LOGV(TAG, "Recv type: 0x%.2X, data: %s", response_type, format_hex_pretty(data).c_str());
byte = this->read();
if (byte != checksum) {
ESP_LOGE(TAG, "Invalid checksum for data. Calculated: 0x%.2X, Received: 0x%.2X", checksum, byte);
return;
}
switch (response_type) {
case HlkFm22xResponseType::NOTE:
this->handle_note_(data);
break;
case HlkFm22xResponseType::REPLY:
this->handle_reply_(data);
break;
default:
ESP_LOGW(TAG, "Unexpected response type: 0x%.2X", response_type);
break;
}
}
void HlkFm22xComponent::handle_note_(const std::vector<uint8_t> &data) {
switch (data[0]) {
case HlkFm22xNoteType::FACE_STATE:
if (data.size() < 17) {
ESP_LOGE(TAG, "Invalid face note data size: %u", data.size());
break;
}
{
int16_t info[8];
uint8_t offset = 1;
for (int16_t &i : info) {
i = ((int16_t) data[offset + 1] << 8) | data[offset];
offset += 2;
}
ESP_LOGV(TAG, "Face state: status: %d, left: %d, top: %d, right: %d, bottom: %d, yaw: %d, pitch: %d, roll: %d",
info[0], info[1], info[2], info[3], info[4], info[5], info[6], info[7]);
this->face_info_callback_.call(info[0], info[1], info[2], info[3], info[4], info[5], info[6], info[7]);
}
break;
case HlkFm22xNoteType::READY:
ESP_LOGE(TAG, "Command 0x%.2X timed out", this->active_command_);
switch (this->active_command_) {
case HlkFm22xCommand::ENROLL:
this->set_enrolling_(false);
this->enrollment_failed_callback_.call(HlkFm22xResult::FAILED4_TIMEOUT);
break;
case HlkFm22xCommand::VERIFY:
this->face_scan_invalid_callback_.call(HlkFm22xResult::FAILED4_TIMEOUT);
break;
default:
break;
}
this->active_command_ = HlkFm22xCommand::NONE;
this->wait_cycles_ = 0;
break;
default:
ESP_LOGW(TAG, "Unhandled note: 0x%.2X", data[0]);
break;
}
}
void HlkFm22xComponent::handle_reply_(const std::vector<uint8_t> &data) {
auto expected = this->active_command_;
this->active_command_ = HlkFm22xCommand::NONE;
if (data[0] != (uint8_t) expected) {
ESP_LOGE(TAG, "Unexpected response command. Expected: 0x%.2X, Received: 0x%.2X", expected, data[0]);
return;
}
if (data[1] != HlkFm22xResult::SUCCESS) {
ESP_LOGE(TAG, "Command <0x%.2X> failed. Error: 0x%.2X", data[0], data[1]);
switch (expected) {
case HlkFm22xCommand::ENROLL:
this->set_enrolling_(false);
this->enrollment_failed_callback_.call(data[1]);
break;
case HlkFm22xCommand::VERIFY:
if (data[1] == HlkFm22xResult::REJECTED) {
this->face_scan_unmatched_callback_.call();
} else {
this->face_scan_invalid_callback_.call(data[1]);
}
break;
default:
break;
}
return;
}
switch (expected) {
case HlkFm22xCommand::VERIFY: {
int16_t face_id = ((int16_t) data[2] << 8) | data[3];
std::string name(data.begin() + 4, data.begin() + 36);
ESP_LOGD(TAG, "Face verified. ID: %d, name: %s", face_id, name.c_str());
if (this->last_face_id_sensor_ != nullptr) {
this->last_face_id_sensor_->publish_state(face_id);
}
if (this->last_face_name_text_sensor_ != nullptr) {
this->last_face_name_text_sensor_->publish_state(name);
}
this->face_scan_matched_callback_.call(face_id, name);
break;
}
case HlkFm22xCommand::ENROLL: {
int16_t face_id = ((int16_t) data[2] << 8) | data[3];
HlkFm22xFaceDirection direction = (HlkFm22xFaceDirection) data[4];
ESP_LOGI(TAG, "Face enrolled. ID: %d, Direction: 0x%.2X", face_id, direction);
this->enrollment_done_callback_.call(face_id, (uint8_t) direction);
this->set_enrolling_(false);
this->defer([this]() { this->get_face_count_(); });
break;
}
case HlkFm22xCommand::GET_STATUS:
if (this->status_sensor_ != nullptr) {
this->status_sensor_->publish_state(data[2]);
}
this->defer([this]() { this->send_command_(HlkFm22xCommand::GET_VERSION); });
break;
case HlkFm22xCommand::GET_VERSION:
if (this->version_text_sensor_ != nullptr) {
std::string version(data.begin() + 2, data.end());
this->version_text_sensor_->publish_state(version);
}
this->defer([this]() { this->get_face_count_(); });
break;
case HlkFm22xCommand::GET_ALL_FACE_IDS:
if (this->face_count_sensor_ != nullptr) {
this->face_count_sensor_->publish_state(data[2]);
}
break;
case HlkFm22xCommand::DELETE_FACE:
ESP_LOGI(TAG, "Deleted face");
break;
case HlkFm22xCommand::DELETE_ALL_FACES:
ESP_LOGI(TAG, "Deleted all faces");
break;
case HlkFm22xCommand::RESET:
ESP_LOGI(TAG, "Module reset");
this->defer([this]() { this->send_command_(HlkFm22xCommand::GET_STATUS); });
break;
default:
ESP_LOGW(TAG, "Unhandled command: 0x%.2X", this->active_command_);
break;
}
}
void HlkFm22xComponent::set_enrolling_(bool enrolling) {
if (this->enrolling_binary_sensor_ != nullptr) {
this->enrolling_binary_sensor_->publish_state(enrolling);
}
}
void HlkFm22xComponent::dump_config() {
ESP_LOGCONFIG(TAG, "HLK_FM22X:");
LOG_UPDATE_INTERVAL(this);
if (this->version_text_sensor_) {
LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_);
ESP_LOGCONFIG(TAG, " Current Value: %s", this->version_text_sensor_->get_state().c_str());
}
if (this->enrolling_binary_sensor_) {
LOG_BINARY_SENSOR(" ", "Enrolling", this->enrolling_binary_sensor_);
ESP_LOGCONFIG(TAG, " Current Value: %s", this->enrolling_binary_sensor_->state ? "ON" : "OFF");
}
if (this->face_count_sensor_) {
LOG_SENSOR(" ", "Face Count", this->face_count_sensor_);
ESP_LOGCONFIG(TAG, " Current Value: %u", (uint16_t) this->face_count_sensor_->get_state());
}
if (this->status_sensor_) {
LOG_SENSOR(" ", "Status", this->status_sensor_);
ESP_LOGCONFIG(TAG, " Current Value: %u", (uint8_t) this->status_sensor_->get_state());
}
if (this->last_face_id_sensor_) {
LOG_SENSOR(" ", "Last Face ID", this->last_face_id_sensor_);
ESP_LOGCONFIG(TAG, " Current Value: %u", (int16_t) this->last_face_id_sensor_->get_state());
}
if (this->last_face_name_text_sensor_) {
LOG_TEXT_SENSOR(" ", "Last Face Name", this->last_face_name_text_sensor_);
ESP_LOGCONFIG(TAG, " Current Value: %s", this->last_face_name_text_sensor_->get_state().c_str());
}
}
} // namespace esphome::hlk_fm22x

View File

@@ -1,224 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/automation.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/text_sensor/text_sensor.h"
#include "esphome/components/uart/uart.h"
#include <utility>
#include <vector>
namespace esphome::hlk_fm22x {
static const uint16_t START_CODE = 0xEFAA;
enum HlkFm22xCommand {
NONE = 0x00,
RESET = 0x10,
GET_STATUS = 0x11,
VERIFY = 0x12,
ENROLL = 0x13,
DELETE_FACE = 0x20,
DELETE_ALL_FACES = 0x21,
GET_ALL_FACE_IDS = 0x24,
GET_VERSION = 0x30,
GET_SERIAL_NUMBER = 0x93,
};
enum HlkFm22xResponseType {
REPLY = 0x00,
NOTE = 0x01,
IMAGE = 0x02,
};
enum HlkFm22xNoteType {
READY = 0x00,
FACE_STATE = 0x01,
};
enum HlkFm22xResult {
SUCCESS = 0x00,
REJECTED = 0x01,
ABORTED = 0x02,
FAILED4_CAMERA = 0x04,
FAILED4_UNKNOWNREASON = 0x05,
FAILED4_INVALIDPARAM = 0x06,
FAILED4_NOMEMORY = 0x07,
FAILED4_UNKNOWNUSER = 0x08,
FAILED4_MAXUSER = 0x09,
FAILED4_FACEENROLLED = 0x0A,
FAILED4_LIVENESSCHECK = 0x0C,
FAILED4_TIMEOUT = 0x0D,
FAILED4_AUTHORIZATION = 0x0E,
FAILED4_READ_FILE = 0x13,
FAILED4_WRITE_FILE = 0x14,
FAILED4_NO_ENCRYPT = 0x15,
FAILED4_NO_RGBIMAGE = 0x17,
FAILED4_JPGPHOTO_LARGE = 0x18,
FAILED4_JPGPHOTO_SMALL = 0x19,
};
enum HlkFm22xFaceDirection {
FACE_DIRECTION_UNDEFINED = 0x00,
FACE_DIRECTION_MIDDLE = 0x01,
FACE_DIRECTION_RIGHT = 0x02,
FACE_DIRECTION_LEFT = 0x04,
FACE_DIRECTION_DOWN = 0x08,
FACE_DIRECTION_UP = 0x10,
};
class HlkFm22xComponent : public PollingComponent, public uart::UARTDevice {
public:
void setup() override;
void update() override;
void dump_config() override;
void set_face_count_sensor(sensor::Sensor *face_count_sensor) { this->face_count_sensor_ = face_count_sensor; }
void set_status_sensor(sensor::Sensor *status_sensor) { this->status_sensor_ = status_sensor; }
void set_last_face_id_sensor(sensor::Sensor *last_face_id_sensor) {
this->last_face_id_sensor_ = last_face_id_sensor;
}
void set_last_face_name_text_sensor(text_sensor::TextSensor *last_face_name_text_sensor) {
this->last_face_name_text_sensor_ = last_face_name_text_sensor;
}
void set_enrolling_binary_sensor(binary_sensor::BinarySensor *enrolling_binary_sensor) {
this->enrolling_binary_sensor_ = enrolling_binary_sensor;
}
void set_version_text_sensor(text_sensor::TextSensor *version_text_sensor) {
this->version_text_sensor_ = version_text_sensor;
}
void add_on_face_scan_matched_callback(std::function<void(int16_t, std::string)> callback) {
this->face_scan_matched_callback_.add(std::move(callback));
}
void add_on_face_scan_unmatched_callback(std::function<void()> callback) {
this->face_scan_unmatched_callback_.add(std::move(callback));
}
void add_on_face_scan_invalid_callback(std::function<void(uint8_t)> callback) {
this->face_scan_invalid_callback_.add(std::move(callback));
}
void add_on_face_info_callback(
std::function<void(int16_t, int16_t, int16_t, int16_t, int16_t, int16_t, int16_t, int16_t)> callback) {
this->face_info_callback_.add(std::move(callback));
}
void add_on_enrollment_done_callback(std::function<void(int16_t, uint8_t)> callback) {
this->enrollment_done_callback_.add(std::move(callback));
}
void add_on_enrollment_failed_callback(std::function<void(uint8_t)> callback) {
this->enrollment_failed_callback_.add(std::move(callback));
}
void enroll_face(const std::string &name, HlkFm22xFaceDirection direction);
void scan_face();
void delete_face(int16_t face_id);
void delete_all_faces();
void reset();
protected:
void get_face_count_();
void send_command_(HlkFm22xCommand command, const uint8_t *data = nullptr, size_t size = 0);
void recv_command_();
void handle_note_(const std::vector<uint8_t> &data);
void handle_reply_(const std::vector<uint8_t> &data);
void set_enrolling_(bool enrolling);
HlkFm22xCommand active_command_ = HlkFm22xCommand::NONE;
uint16_t wait_cycles_ = 0;
sensor::Sensor *face_count_sensor_{nullptr};
sensor::Sensor *status_sensor_{nullptr};
sensor::Sensor *last_face_id_sensor_{nullptr};
binary_sensor::BinarySensor *enrolling_binary_sensor_{nullptr};
text_sensor::TextSensor *last_face_name_text_sensor_{nullptr};
text_sensor::TextSensor *version_text_sensor_{nullptr};
CallbackManager<void(uint8_t)> face_scan_invalid_callback_;
CallbackManager<void(int16_t, std::string)> face_scan_matched_callback_;
CallbackManager<void()> face_scan_unmatched_callback_;
CallbackManager<void(int16_t, int16_t, int16_t, int16_t, int16_t, int16_t, int16_t, int16_t)> face_info_callback_;
CallbackManager<void(int16_t, uint8_t)> enrollment_done_callback_;
CallbackManager<void(uint8_t)> enrollment_failed_callback_;
};
class FaceScanMatchedTrigger : public Trigger<int16_t, std::string> {
public:
explicit FaceScanMatchedTrigger(HlkFm22xComponent *parent) {
parent->add_on_face_scan_matched_callback(
[this](int16_t face_id, const std::string &name) { this->trigger(face_id, name); });
}
};
class FaceScanUnmatchedTrigger : public Trigger<> {
public:
explicit FaceScanUnmatchedTrigger(HlkFm22xComponent *parent) {
parent->add_on_face_scan_unmatched_callback([this]() { this->trigger(); });
}
};
class FaceScanInvalidTrigger : public Trigger<uint8_t> {
public:
explicit FaceScanInvalidTrigger(HlkFm22xComponent *parent) {
parent->add_on_face_scan_invalid_callback([this](uint8_t error) { this->trigger(error); });
}
};
class FaceInfoTrigger : public Trigger<int16_t, int16_t, int16_t, int16_t, int16_t, int16_t, int16_t, int16_t> {
public:
explicit FaceInfoTrigger(HlkFm22xComponent *parent) {
parent->add_on_face_info_callback(
[this](int16_t status, int16_t left, int16_t top, int16_t right, int16_t bottom, int16_t yaw, int16_t pitch,
int16_t roll) { this->trigger(status, left, top, right, bottom, yaw, pitch, roll); });
}
};
class EnrollmentDoneTrigger : public Trigger<int16_t, uint8_t> {
public:
explicit EnrollmentDoneTrigger(HlkFm22xComponent *parent) {
parent->add_on_enrollment_done_callback(
[this](int16_t face_id, uint8_t direction) { this->trigger(face_id, direction); });
}
};
class EnrollmentFailedTrigger : public Trigger<uint8_t> {
public:
explicit EnrollmentFailedTrigger(HlkFm22xComponent *parent) {
parent->add_on_enrollment_failed_callback([this](uint8_t error) { this->trigger(error); });
}
};
template<typename... Ts> class EnrollmentAction : public Action<Ts...>, public Parented<HlkFm22xComponent> {
public:
TEMPLATABLE_VALUE(std::string, name)
TEMPLATABLE_VALUE(uint8_t, direction)
void play(Ts... x) override {
auto name = this->name_.value(x...);
auto direction = (HlkFm22xFaceDirection) this->direction_.value(x...);
this->parent_->enroll_face(name, direction);
}
};
template<typename... Ts> class DeleteAction : public Action<Ts...>, public Parented<HlkFm22xComponent> {
public:
TEMPLATABLE_VALUE(int16_t, face_id)
void play(Ts... x) override {
auto face_id = this->face_id_.value(x...);
this->parent_->delete_face(face_id);
}
};
template<typename... Ts> class DeleteAllAction : public Action<Ts...>, public Parented<HlkFm22xComponent> {
public:
void play(Ts... x) override { this->parent_->delete_all_faces(); }
};
template<typename... Ts> class ScanAction : public Action<Ts...>, public Parented<HlkFm22xComponent> {
public:
void play(Ts... x) override { this->parent_->scan_face(); }
};
template<typename... Ts> class ResetAction : public Action<Ts...>, public Parented<HlkFm22xComponent> {
public:
void play(Ts... x) override { this->parent_->reset(); }
};
} // namespace esphome::hlk_fm22x

View File

@@ -1,47 +0,0 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import CONF_STATUS, ENTITY_CATEGORY_DIAGNOSTIC, ICON_ACCOUNT
from . import CONF_HLK_FM22X_ID, HlkFm22xComponent
DEPENDENCIES = ["hlk_fm22x"]
CONF_FACE_COUNT = "face_count"
CONF_LAST_FACE_ID = "last_face_id"
ICON_FACE = "mdi:face-recognition"
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_HLK_FM22X_ID): cv.use_id(HlkFm22xComponent),
cv.Optional(CONF_FACE_COUNT): sensor.sensor_schema(
icon=ICON_FACE,
accuracy_decimals=0,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_STATUS): sensor.sensor_schema(
accuracy_decimals=0,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_LAST_FACE_ID): sensor.sensor_schema(
icon=ICON_ACCOUNT,
accuracy_decimals=0,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
async def to_code(config):
hub = await cg.get_variable(config[CONF_HLK_FM22X_ID])
for key in [
CONF_FACE_COUNT,
CONF_STATUS,
CONF_LAST_FACE_ID,
]:
if key not in config:
continue
conf = config[key]
sens = await sensor.new_sensor(conf)
cg.add(getattr(hub, f"set_{key}_sensor")(sens))

View File

@@ -1,42 +0,0 @@
import esphome.codegen as cg
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_VERSION,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_ACCOUNT,
ICON_RESTART,
)
from . import CONF_HLK_FM22X_ID, HlkFm22xComponent
DEPENDENCIES = ["hlk_fm22x"]
CONF_LAST_FACE_NAME = "last_face_name"
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_HLK_FM22X_ID): cv.use_id(HlkFm22xComponent),
cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema(
icon=ICON_RESTART,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_LAST_FACE_NAME): text_sensor.text_sensor_schema(
icon=ICON_ACCOUNT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
async def to_code(config):
hub = await cg.get_variable(config[CONF_HLK_FM22X_ID])
for key in [
CONF_VERSION,
CONF_LAST_FACE_NAME,
]:
if key not in config:
continue
conf = config[key]
sens = await text_sensor.new_text_sensor(conf)
cg.add(getattr(hub, f"set_{key}_text_sensor")(sens))

View File

@@ -7,8 +7,10 @@ namespace homeassistant {
static const char *const TAG = "homeassistant.time";
void HomeassistantTime::dump_config() {
ESP_LOGCONFIG(TAG, "Home Assistant Time");
RealTimeClock::dump_config();
ESP_LOGCONFIG(TAG,
"Home Assistant Time:\n"
" Timezone: '%s'",
this->timezone_.c_str());
}
float HomeassistantTime::get_setup_priority() const { return setup_priority::DATA; }

View File

@@ -1,8 +1,6 @@
import importlib
import logging
import pkgutil
from esphome.automation import build_automation, validate_automation
from esphome.automation import build_automation, register_action, validate_automation
import esphome.codegen as cg
from esphome.components.const import CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING
from esphome.components.display import Display
@@ -27,8 +25,8 @@ from esphome.cpp_generator import MockObj
from esphome.final_validate import full_config
from esphome.helpers import write_file_if_changed
from . import defines as df, helpers, lv_validation as lvalid, widgets
from .automation import disp_update, focused_widgets, refreshed_widgets
from . import defines as df, helpers, lv_validation as lvalid
from .automation import disp_update, focused_widgets, refreshed_widgets, update_to_code
from .defines import add_define
from .encoders import (
ENCODERS_CONFIG,
@@ -47,6 +45,7 @@ from .schemas import (
WIDGET_TYPES,
any_widget_schema,
container_schema,
create_modify_schema,
obj_schema,
)
from .styles import add_top_layer, styles_to_code, theme_to_code
@@ -55,6 +54,7 @@ from .trigger import add_on_boot_triggers, generate_triggers
from .types import (
FontEngine,
IdleTrigger,
ObjUpdateAction,
PlainTrigger,
lv_font_t,
lv_group_t,
@@ -69,23 +69,33 @@ from .widgets import (
set_obj_properties,
styles_used,
)
# Import only what we actually use directly in this file
from .widgets.animimg import animimg_spec
from .widgets.arc import arc_spec
from .widgets.button import button_spec
from .widgets.buttonmatrix import buttonmatrix_spec
from .widgets.canvas import canvas_spec
from .widgets.checkbox import checkbox_spec
from .widgets.container import container_spec
from .widgets.dropdown import dropdown_spec
from .widgets.img import img_spec
from .widgets.keyboard import keyboard_spec
from .widgets.label import label_spec
from .widgets.led import led_spec
from .widgets.line import line_spec
from .widgets.lv_bar import bar_spec
from .widgets.meter import meter_spec
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
from .widgets.obj import obj_spec # Used in LVGL_SCHEMA
from .widgets.page import ( # page_spec used in LVGL_SCHEMA
add_pages,
generate_page_triggers,
page_spec,
)
# Widget registration happens via WidgetType.__init__ in individual widget files
# The imports below trigger creation of the widget types
# Action registration (lvgl.{widget}.update) happens automatically
# in the WidgetType.__init__ method
for module_info in pkgutil.iter_modules(widgets.__path__):
importlib.import_module(f".widgets.{module_info.name}", package=__package__)
from .widgets.obj import obj_spec
from .widgets.page import add_pages, generate_page_triggers, page_spec
from .widgets.qrcode import qr_code_spec
from .widgets.roller import roller_spec
from .widgets.slider import slider_spec
from .widgets.spinbox import spinbox_spec
from .widgets.spinner import spinner_spec
from .widgets.switch import switch_spec
from .widgets.tabview import tabview_spec
from .widgets.textarea import textarea_spec
from .widgets.tileview import tileview_spec
DOMAIN = "lvgl"
DEPENDENCIES = ["display"]
@@ -93,6 +103,41 @@ AUTO_LOAD = ["key_provider"]
CODEOWNERS = ["@clydebarrow"]
LOGGER = logging.getLogger(__name__)
for w_type in (
label_spec,
obj_spec,
button_spec,
bar_spec,
slider_spec,
arc_spec,
line_spec,
spinner_spec,
led_spec,
animimg_spec,
checkbox_spec,
img_spec,
switch_spec,
tabview_spec,
buttonmatrix_spec,
meter_spec,
dropdown_spec,
roller_spec,
textarea_spec,
spinbox_spec,
keyboard_spec,
tileview_spec,
qr_code_spec,
canvas_spec,
container_spec,
):
WIDGET_TYPES[w_type.name] = w_type
for w_type in WIDGET_TYPES.values():
register_action(
f"lvgl.{w_type.name}.update",
ObjUpdateAction,
create_modify_schema(w_type),
)(update_to_code)
SIMPLE_TRIGGERS = (
df.CONF_ON_PAUSE,
@@ -331,7 +376,7 @@ async def to_code(configs):
# This must be done after all widgets are created
for comp in helpers.lvgl_components_required:
cg.add_define(f"USE_LVGL_{comp.upper()}")
if {"transform_angle", "transform_zoom"} & styles_used:
if "transform_angle" in styles_used:
add_define("LV_COLOR_SCREEN_TRANSP", "1")
for use in helpers.lv_uses:
add_define(f"LV_USE_{use.upper()}")
@@ -357,15 +402,6 @@ def add_hello_world(config):
return config
def _theme_schema(value):
return cv.Schema(
{
cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA)
for name, w in WIDGET_TYPES.items()
}
)(value)
FINAL_VALIDATE_SCHEMA = final_validation
LVGL_SCHEMA = cv.All(
@@ -418,7 +454,12 @@ LVGL_SCHEMA = cv.All(
cv.Optional(
df.CONF_TRANSPARENCY_KEY, default=0x000400
): lvalid.lv_color,
cv.Optional(df.CONF_THEME): _theme_schema,
cv.Optional(df.CONF_THEME): cv.Schema(
{
cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA)
for name, w in WIDGET_TYPES.items()
}
),
cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA,
cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema,
cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG,

View File

@@ -411,10 +411,6 @@ def any_widget_schema(extras=None):
Dynamically generate schemas for all possible LVGL widgets. This is what implements the ability to have a list of any kind of
widget under the widgets: key.
This uses lazy evaluation - the schema is built when called during validation,
not at import time. This allows external components to register widgets
before schema validation begins.
:param extras: Additional schema to be applied to each generated one
:return: A validator for the Widgets key
"""

View File

@@ -1,10 +1,8 @@
import sys
from esphome import automation, codegen as cg
from esphome.automation import register_action
from esphome.config_validation import Schema
from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_TEXT, CONF_VALUE
from esphome.core import EsphomeError
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.cpp_types import esphome_ns
@@ -126,16 +124,13 @@ class WidgetType:
schema=None,
modify_schema=None,
lv_name=None,
is_mock: bool = False,
):
"""
:param name: The widget name, e.g. "bar"
:param w_type: The C type of the widget
:param parts: What parts this widget supports
:param schema: The config schema for defining a widget
:param modify_schema: A schema to update the widget, defaults to the same as the schema
:param lv_name: The name of the LVGL widget in the LVGL library, if different from the name
:param is_mock: Whether this widget is a mock widget, i.e. not a real LVGL widget
:param modify_schema: A schema to update the widget
"""
self.name = name
self.lv_name = lv_name or name
@@ -151,22 +146,6 @@ class WidgetType:
self.modify_schema = modify_schema
self.mock_obj = MockObj(f"lv_{self.lv_name}", "_")
# Local import to avoid circular import
from .automation import update_to_code
from .schemas import WIDGET_TYPES, create_modify_schema
if not is_mock:
if self.name in WIDGET_TYPES:
raise EsphomeError(f"Duplicate definition of widget type '{self.name}'")
WIDGET_TYPES[self.name] = self
# Register the update action automatically
register_action(
f"lvgl.{self.name}.update",
ObjUpdateAction,
create_modify_schema(self),
)(update_to_code)
@property
def animated(self):
return False

View File

@@ -213,14 +213,17 @@ class LvScrActType(WidgetType):
"""
def __init__(self):
super().__init__("lv_scr_act()", lv_obj_t, (), is_mock=True)
super().__init__("lv_scr_act()", lv_obj_t, ())
async def to_code(self, w, config: dict):
return []
lv_scr_act_spec = LvScrActType()
def get_scr_act(lv_comp: MockObj) -> Widget:
return Widget.create(None, lv_comp.get_scr_act(), LvScrActType(), {})
return Widget.create(None, lv_comp.get_scr_act(), lv_scr_act_spec, {})
def get_widget_generator(wid):

View File

@@ -2,7 +2,7 @@ from esphome import automation
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_RANGE_FROM, CONF_RANGE_TO, CONF_STEP, CONF_VALUE
from ..automation import action_to_code
from ..automation import action_to_code, update_to_code
from ..defines import (
CONF_CURSOR,
CONF_DECIMAL_PLACES,
@@ -171,3 +171,17 @@ async def spinbox_decrement(config, action_id, template_arg, args):
lv.spinbox_decrement(w.obj)
return await action_to_code(widgets, do_increment, action_id, template_arg, args)
@automation.register_action(
"lvgl.spinbox.update",
ObjUpdateAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(lv_spinbox_t),
cv.Required(CONF_VALUE): lv_float,
}
),
)
async def spinbox_update_to_code(config, action_id, template_arg, args):
return await update_to_code(config, action_id, template_arg, args)

View File

@@ -25,7 +25,6 @@ from esphome.const import (
CONF_FRAMEWORK,
CONF_ID,
CONF_RESET_PIN,
CONF_VOLTAGE,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
KEY_TARGET_FRAMEWORK,
@@ -103,11 +102,6 @@ nrf52_ns = cg.esphome_ns.namespace("nrf52")
DeviceFirmwareUpdate = nrf52_ns.class_("DeviceFirmwareUpdate", cg.Component)
CONF_DFU = "dfu"
CONF_REG0 = "reg0"
CONF_UICR_ERASE = "uicr_erase"
VOLTAGE_LEVELS = [1.8, 2.1, 2.4, 2.7, 3.0, 3.3]
DEFAULT_VOLTAGE_LEVEL = "default"
CONFIG_SCHEMA = cv.All(
_detect_bootloader,
@@ -122,18 +116,6 @@ CONFIG_SCHEMA = cv.All(
cv.Required(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
),
cv.Optional(CONF_REG0): cv.Schema(
{
cv.Required(CONF_VOLTAGE): cv.Any(
cv.All(
cv.voltage,
cv.one_of(*VOLTAGE_LEVELS, float=True),
),
cv.one_of(*[DEFAULT_VOLTAGE_LEVEL], lower=True),
),
cv.Optional(CONF_UICR_ERASE, default=False): cv.boolean,
}
),
}
),
)
@@ -201,14 +183,6 @@ async def to_code(config: ConfigType) -> None:
if dfu_config := config.get(CONF_DFU):
CORE.add_job(_dfu_to_code, dfu_config)
if reg0_config := config.get(CONF_REG0):
value = 7 # DEFAULT_VOLTAGE_LEVEL
if reg0_config[CONF_VOLTAGE] in VOLTAGE_LEVELS:
value = VOLTAGE_LEVELS.index(reg0_config[CONF_VOLTAGE])
cg.add_define("USE_NRF52_REG0_VOUT", value)
if reg0_config[CONF_UICR_ERASE]:
cg.add_define("USE_NRF52_UICR_ERASE")
@coroutine_with_priority(CoroPriority.DIAGNOSTICS)
async def _dfu_to_code(dfu_config):

View File

@@ -1,110 +0,0 @@
#include "esphome/core/defines.h"
#ifdef USE_NRF52_REG0_VOUT
#include <zephyr/init.h>
#include <hal/nrf_power.h>
#include <zephyr/sys/printk.h>
extern "C" {
void nvmc_config(uint32_t mode);
void nvmc_wait();
nrfx_err_t nrfx_nvmc_uicr_erase();
}
namespace esphome::nrf52 {
enum class StatusFlags : uint8_t {
OK = 0x00,
NEED_RESET = 0x01,
NEED_ERASE = 0x02,
};
constexpr StatusFlags &operator|=(StatusFlags &a, StatusFlags b) {
a = static_cast<StatusFlags>(static_cast<uint8_t>(a) | static_cast<uint8_t>(b));
return a;
}
constexpr bool operator&(StatusFlags a, StatusFlags b) {
return (static_cast<uint8_t>(a) & static_cast<uint8_t>(b)) != 0;
}
static bool regout0_ok() {
return (NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) == (USE_NRF52_REG0_VOUT << UICR_REGOUT0_VOUT_Pos);
}
static StatusFlags set_regout0() {
/* If the board is powered from USB (high voltage mode),
* GPIO output voltage is set to 1.8 volts by default.
*/
if (!regout0_ok()) {
nvmc_config(NVMC_CONFIG_WEN_Wen);
NRF_UICR->REGOUT0 =
(NRF_UICR->REGOUT0 & ~((uint32_t) UICR_REGOUT0_VOUT_Msk)) | (USE_NRF52_REG0_VOUT << UICR_REGOUT0_VOUT_Pos);
nvmc_wait();
nvmc_config(NVMC_CONFIG_WEN_Ren);
return regout0_ok() ? StatusFlags::NEED_RESET : StatusFlags::NEED_ERASE;
}
return StatusFlags::OK;
}
#ifndef USE_BOOTLOADER_MCUBOOT
// https://github.com/adafruit/Adafruit_nRF52_Bootloader/blob/6a9a6a3e6d0f86918e9286188426a279976645bd/lib/sdk11/components/libraries/bootloader_dfu/dfu_types.h#L61
constexpr uint32_t BOOTLOADER_REGION_START = 0x000F4000;
constexpr uint32_t BOOTLOADER_MBR_PARAMS_PAGE_ADDRESS = 0x000FE000;
static bool bootloader_ok() {
return NRF_UICR->NRFFW[0] == BOOTLOADER_REGION_START && NRF_UICR->NRFFW[1] == BOOTLOADER_MBR_PARAMS_PAGE_ADDRESS;
}
static StatusFlags fix_bootloader() {
if (!bootloader_ok()) {
nvmc_config(NVMC_CONFIG_WEN_Wen);
NRF_UICR->NRFFW[0] = BOOTLOADER_REGION_START;
NRF_UICR->NRFFW[1] = BOOTLOADER_MBR_PARAMS_PAGE_ADDRESS;
nvmc_wait();
nvmc_config(NVMC_CONFIG_WEN_Ren);
return bootloader_ok() ? StatusFlags::NEED_RESET : StatusFlags::NEED_ERASE;
}
return StatusFlags::OK;
}
#endif
static StatusFlags set_uicr() {
StatusFlags status = StatusFlags::OK;
status |= set_regout0();
#ifndef USE_BOOTLOADER_MCUBOOT
status |= fix_bootloader();
#endif
return status;
}
static int board_esphome_init() {
StatusFlags status = set_uicr();
#ifdef USE_NRF52_UICR_ERASE
if (status & StatusFlags::NEED_ERASE) {
nrfx_err_t ret = nrfx_nvmc_uicr_erase();
if (ret != NRFX_SUCCESS) {
#ifdef CONFIG_PRINTK
printk("nrfx_nvmc_uicr_erase failed %d\n", ret);
#endif
} else {
status |= set_uicr();
}
}
#endif
if (status & StatusFlags::NEED_RESET) {
/* a reset is required for changes to take effect */
NVIC_SystemReset();
}
return 0;
}
} // namespace esphome::nrf52
static int board_esphome_init() { return esphome::nrf52::board_esphome_init(); }
SYS_INIT(board_esphome_init, PRE_KERNEL_1, CONFIG_KERNEL_INIT_PRIORITY_DEFAULT);
#endif

View File

@@ -23,7 +23,7 @@ void PCF85063Component::dump_config() {
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
RealTimeClock::dump_config();
ESP_LOGCONFIG(TAG, " Timezone: '%s'", this->timezone_.c_str());
}
float PCF85063Component::get_setup_priority() const { return setup_priority::DATA; }

View File

@@ -23,7 +23,7 @@ void PCF8563Component::dump_config() {
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
RealTimeClock::dump_config();
ESP_LOGCONFIG(TAG, " Timezone: '%s'", this->timezone_.c_str());
}
float PCF8563Component::get_setup_priority() const { return setup_priority::DATA; }

View File

@@ -1,128 +0,0 @@
#include "rx8130.h"
#include "esphome/core/log.h"
// https://download.epsondevice.com/td/pdf/app/RX8130CE_en.pdf
namespace esphome {
namespace rx8130 {
static const uint8_t RX8130_REG_SEC = 0x10;
static const uint8_t RX8130_REG_MIN = 0x11;
static const uint8_t RX8130_REG_HOUR = 0x12;
static const uint8_t RX8130_REG_WDAY = 0x13;
static const uint8_t RX8130_REG_MDAY = 0x14;
static const uint8_t RX8130_REG_MONTH = 0x15;
static const uint8_t RX8130_REG_YEAR = 0x16;
static const uint8_t RX8130_REG_EXTEN = 0x1C;
static const uint8_t RX8130_REG_FLAG = 0x1D;
static const uint8_t RX8130_REG_CTRL0 = 0x1E;
static const uint8_t RX8130_REG_CTRL1 = 0x1F;
static const uint8_t RX8130_REG_DIG_OFFSET = 0x30;
static const uint8_t RX8130_BIT_CTRL_STOP = 0x40;
static const uint8_t RX8130_BAT_FLAGS = 0x30;
static const uint8_t RX8130_CLEAR_FLAGS = 0x00;
static const char *const TAG = "rx8130";
constexpr uint8_t bcd2dec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0f); }
constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); }
void RX8130Component::setup() {
// Set digital offset to disabled with no offset
if (this->write_register(RX8130_REG_DIG_OFFSET, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) {
this->mark_failed();
return;
}
// Disable wakeup timers
if (this->write_register(RX8130_REG_EXTEN, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) {
this->mark_failed();
return;
}
// Clear VLF flag in case there has been data loss
if (this->write_register(RX8130_REG_FLAG, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) {
this->mark_failed();
return;
}
// Clear test flag and disable interrupts
if (this->write_register(RX8130_REG_CTRL0, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) {
this->mark_failed();
return;
}
// Enable battery charging and switching
if (this->write_register(RX8130_REG_CTRL1, &RX8130_BAT_FLAGS, 1) != i2c::ERROR_OK) {
this->mark_failed();
return;
}
// Clear STOP bit
this->stop_(false);
}
void RX8130Component::update() { this->read_time(); }
void RX8130Component::dump_config() {
ESP_LOGCONFIG(TAG, "RX8130:");
LOG_I2C_DEVICE(this);
RealTimeClock::dump_config();
}
void RX8130Component::read_time() {
uint8_t date[7];
if (this->read_register(RX8130_REG_SEC, date, 7) != i2c::ERROR_OK) {
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
return;
}
ESPTime rtc_time{
.second = bcd2dec(date[0] & 0x7f),
.minute = bcd2dec(date[1] & 0x7f),
.hour = bcd2dec(date[2] & 0x3f),
.day_of_week = bcd2dec(date[3] & 0x7f),
.day_of_month = bcd2dec(date[4] & 0x3f),
.day_of_year = 1, // ignored by recalc_timestamp_utc(false)
.month = bcd2dec(date[5] & 0x1f),
.year = static_cast<uint16_t>(bcd2dec(date[6]) + 2000),
.is_dst = false, // not used
.timestamp = 0 // overwritten by recalc_timestamp_utc(false)
};
rtc_time.recalc_timestamp_utc(false);
if (!rtc_time.is_valid()) {
ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock.");
return;
}
ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month,
rtc_time.hour, rtc_time.minute, rtc_time.second);
time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp);
}
void RX8130Component::write_time() {
auto now = time::RealTimeClock::utcnow();
if (!now.is_valid()) {
ESP_LOGE(TAG, "Invalid system time, not syncing to RTC.");
return;
}
uint8_t buff[7];
buff[0] = dec2bcd(now.second);
buff[1] = dec2bcd(now.minute);
buff[2] = dec2bcd(now.hour);
buff[3] = dec2bcd(now.day_of_week);
buff[4] = dec2bcd(now.day_of_month);
buff[5] = dec2bcd(now.month);
buff[6] = dec2bcd(now.year % 100);
this->stop_(true);
if (this->write_register(RX8130_REG_SEC, buff, 7) != i2c::ERROR_OK) {
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
} else {
ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour,
now.minute, now.second);
}
this->stop_(false);
}
void RX8130Component::stop_(bool stop) {
const uint8_t data = stop ? RX8130_BIT_CTRL_STOP : RX8130_CLEAR_FLAGS;
if (this->write_register(RX8130_REG_CTRL0, &data, 1) != i2c::ERROR_OK) {
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
}
}
} // namespace rx8130
} // namespace esphome

View File

@@ -1,35 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/time/real_time_clock.h"
namespace esphome {
namespace rx8130 {
class RX8130Component : public time::RealTimeClock, public i2c::I2CDevice {
public:
void setup() override;
void update() override;
void dump_config() override;
void read_time();
void write_time();
/// Ensure RTC is initialized at the correct time in the setup sequence
float get_setup_priority() const override { return setup_priority::DATA; }
protected:
void stop_(bool stop);
};
template<typename... Ts> class WriteAction : public Action<Ts...>, public Parented<RX8130Component> {
public:
void play(const Ts... x) override { this->parent_->write_time(); }
};
template<typename... Ts> class ReadAction : public Action<Ts...>, public Parented<RX8130Component> {
public:
void play(const Ts... x) override { this->parent_->read_time(); }
};
} // namespace rx8130
} // namespace esphome

View File

@@ -1,56 +0,0 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c, time
import esphome.config_validation as cv
from esphome.const import CONF_ID
CODEOWNERS = ["@beormund"]
DEPENDENCIES = ["i2c"]
rx8130_ns = cg.esphome_ns.namespace("rx8130")
RX8130Component = rx8130_ns.class_("RX8130Component", time.RealTimeClock, i2c.I2CDevice)
WriteAction = rx8130_ns.class_("WriteAction", automation.Action)
ReadAction = rx8130_ns.class_("ReadAction", automation.Action)
CONFIG_SCHEMA = time.TIME_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(RX8130Component),
}
).extend(i2c.i2c_device_schema(0x32))
@automation.register_action(
"rx8130.write_time",
WriteAction,
cv.Schema(
{
cv.GenerateID(): cv.use_id(RX8130Component),
}
),
)
async def rx8130_write_time_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
@automation.register_action(
"rx8130.read_time",
ReadAction,
automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(RX8130Component),
}
),
)
async def rx8130_read_time_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
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
await time.register_time(var, config)

View File

@@ -61,7 +61,6 @@ void SNTPComponent::dump_config() {
for (auto &server : this->servers_) {
ESP_LOGCONFIG(TAG, " Server %zu: '%s'", i++, server);
}
RealTimeClock::dump_config();
}
void SNTPComponent::update() {
#if !defined(USE_ESP32)

View File

@@ -23,13 +23,6 @@ namespace time {
static const char *const TAG = "time";
RealTimeClock::RealTimeClock() = default;
void RealTimeClock::dump_config() {
#ifdef USE_TIME_TIMEZONE
ESP_LOGCONFIG(TAG, "Timezone: '%s'", this->timezone_.c_str());
#endif
}
void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
ESP_LOGVV(TAG, "Got epoch %" PRIu32, epoch);
// Update UTC epoch time.

View File

@@ -52,8 +52,6 @@ class RealTimeClock : public PollingComponent {
this->time_sync_callback_.add(std::move(callback));
};
void dump_config() override;
protected:
/// Report a unix epoch as current time.
void synchronize_epoch_(uint32_t epoch);

View File

@@ -42,258 +42,6 @@ namespace wifi {
static const char *const TAG = "wifi";
/// WiFi Retry Logic - Priority-Based BSSID Selection
///
/// The WiFi component uses a state machine with priority degradation to handle connection failures
/// and automatically cycle through different BSSIDs in mesh networks or multiple configured networks.
///
/// Connection Flow:
/// ┌──────────────────────────────────────────────────────────────────────┐
/// │ Fast Connect Path (Optional) │
/// ├──────────────────────────────────────────────────────────────────────┤
/// │ Entered if: configuration has 'fast_connect: true' │
/// │ Optimization to skip scanning when possible: │
/// │ │
/// │ 1. INITIAL_CONNECT → Try one of: │
/// │ a) Saved BSSID+channel (from previous boot) │
/// │ b) First configured non-hidden network (any BSSID) │
/// │ ↓ │
/// │ [FAILED] → Check if more configured networks available │
/// │ ↓ │
/// │ 2. FAST_CONNECT_CYCLING_APS → Try remaining configured networks │
/// │ (1 attempt each, any BSSID) │
/// │ ↓ │
/// │ [All Failed] → Fall through to explicit hidden or scanning │
/// │ │
/// │ Note: Fast connect data saved from previous successful connection │
/// └──────────────────────────────────────────────────────────────────────┘
/// ↓
/// ┌──────────────────────────────────────────────────────────────────────┐
/// │ Explicit Hidden Networks Path (Optional) │
/// ├──────────────────────────────────────────────────────────────────────┤
/// │ Entered if: first configured network has 'hidden: true' │
/// │ │
/// │ 1. EXPLICIT_HIDDEN → Try consecutive hidden networks (1 attempt) │
/// │ Stop when visible network reached │
/// │ ↓ │
/// │ Example: Hidden1, Hidden2, Visible1, Hidden3, Visible2 │
/// │ Try: Hidden1, Hidden2 (stop at Visible1) │
/// │ ↓ │
/// │ [All Failed] → Fall back to scan-based connection │
/// │ │
/// │ Note: Fast connect saves BSSID after first successful connection, │
/// │ so subsequent boots use fast path instead of hidden mode │
/// └──────────────────────────────────────────────────────────────────────┘
/// ↓
/// ┌──────────────────────────────────────────────────────────────────────┐
/// │ Scan-Based Connection Path │
/// ├──────────────────────────────────────────────────────────────────────┤
/// │ │
/// │ 1. SCAN → Sort by priority (highest first), then RSSI │
/// │ ┌─────────────────────────────────────────────────┐ │
/// │ │ scan_result_[0] = Best BSSID (highest priority) │ │
/// │ │ scan_result_[1] = Second best │ │
/// │ │ scan_result_[2] = Third best │ │
/// │ └─────────────────────────────────────────────────┘ │
/// │ ↓ │
/// │ 2. SCAN_CONNECTING → Try scan_result_[0] (2 attempts) │
/// │ (Visible1, Visible2 from example above) │
/// │ ↓ │
/// │ 3. FAILED → Decrease priority: 0.0 → -1.0 → -2.0 │
/// │ (stored in persistent sta_priorities_) │
/// │ ↓ │
/// │ 4. Check for hidden networks: │
/// │ - If found → RETRY_HIDDEN (try SSIDs not in scan, 1 attempt) │
/// │ Skip hidden networks before first visible one │
/// │ (Skip Hidden1/Hidden2, try Hidden3 from example) │
/// │ - If none → Skip RETRY_HIDDEN, go to step 5 │
/// │ ↓ │
/// │ 5. FAILED → RESTARTING_ADAPTER (skipped if AP/improv active) │
/// │ ↓ │
/// │ 6. Loop back to start: │
/// │ - If first network is hidden → EXPLICIT_HIDDEN (retry cycle) │
/// │ - Otherwise → SCAN_CONNECTING (rescan) │
/// │ ↓ │
/// │ 7. RESCAN → Apply stored priorities, sort again │
/// │ ┌─────────────────────────────────────────────────┐ │
/// │ │ scan_result_[0] = BSSID B (priority 0.0) ← NEW │ │
/// │ │ scan_result_[1] = BSSID C (priority 0.0) │ │
/// │ │ scan_result_[2] = BSSID A (priority -2.0) ← OLD │ │
/// │ └─────────────────────────────────────────────────┘ │
/// │ ↓ │
/// │ 8. SCAN_CONNECTING → Try scan_result_[0] (next best) │
/// │ │
/// │ Key: Priority system cycles through BSSIDs ACROSS scan cycles │
/// │ Full retry cycle: EXPLICIT_HIDDEN → SCAN → RETRY_HIDDEN │
/// │ Always try best available BSSID (scan_result_[0]) │
/// └──────────────────────────────────────────────────────────────────────┘
///
/// Retry Phases:
/// - INITIAL_CONNECT: Try saved BSSID+channel (fast_connect), or fall back to normal flow
/// - FAST_CONNECT_CYCLING_APS: Cycle through remaining configured networks (1 attempt each, fast_connect only)
/// - EXPLICIT_HIDDEN: Try consecutive networks marked hidden:true before scanning (1 attempt per SSID)
/// - SCAN_CONNECTING: Connect using scan results (2 attempts per BSSID)
/// - RETRY_HIDDEN: Try networks not found in scan (1 attempt per SSID, skipped if none found)
/// - RESTARTING_ADAPTER: Restart WiFi adapter to clear stuck state
///
/// Hidden Network Handling:
/// - Networks marked 'hidden: true' before first non-hidden → Tried in EXPLICIT_HIDDEN phase
/// - Networks marked 'hidden: true' after first non-hidden → Tried in RETRY_HIDDEN phase
/// - After successful connection, fast_connect saves BSSID → subsequent boots use fast path
/// - Networks not in scan results → Tried in RETRY_HIDDEN phase
/// - Networks visible in scan + not marked hidden → Skipped in RETRY_HIDDEN phase
/// - Networks marked 'hidden: true' always use hidden mode, even if broadcasting SSID
static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) {
switch (phase) {
case WiFiRetryPhase::INITIAL_CONNECT:
return LOG_STR("INITIAL_CONNECT");
#ifdef USE_WIFI_FAST_CONNECT
case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS:
return LOG_STR("FAST_CONNECT_CYCLING");
#endif
case WiFiRetryPhase::EXPLICIT_HIDDEN:
return LOG_STR("EXPLICIT_HIDDEN");
case WiFiRetryPhase::SCAN_CONNECTING:
return LOG_STR("SCAN_CONNECTING");
case WiFiRetryPhase::RETRY_HIDDEN:
return LOG_STR("RETRY_HIDDEN");
case WiFiRetryPhase::RESTARTING_ADAPTER:
return LOG_STR("RESTARTING");
default:
return LOG_STR("UNKNOWN");
}
}
bool WiFiComponent::went_through_explicit_hidden_phase_() const {
// If first configured network is marked hidden, we went through EXPLICIT_HIDDEN phase
// This means those networks were already tried and should be skipped in RETRY_HIDDEN
return !this->sta_.empty() && this->sta_[0].get_hidden();
}
int8_t WiFiComponent::find_first_non_hidden_index_() const {
// Find the first network that is NOT marked hidden:true
// This is where EXPLICIT_HIDDEN phase would have stopped
for (size_t i = 0; i < this->sta_.size(); i++) {
if (!this->sta_[i].get_hidden()) {
return static_cast<int8_t>(i);
}
}
return -1; // All networks are hidden
}
// 2 attempts per BSSID in SCAN_CONNECTING phase
// Rationale: This is the ONLY phase where we decrease BSSID priority, so we must be very sure.
// Auth failures are common immediately after scan due to WiFi stack state transitions.
// Trying twice filters out false positives and prevents unnecessarily marking a good BSSID as bad.
// After 2 genuine failures, priority degradation ensures we skip this BSSID on subsequent scans.
static constexpr uint8_t WIFI_RETRY_COUNT_PER_BSSID = 2;
// 1 attempt per SSID in RETRY_HIDDEN phase
// Rationale: Try hidden mode once, then rescan to get next best BSSID via priority system
static constexpr uint8_t WIFI_RETRY_COUNT_PER_SSID = 1;
// 1 attempt per AP in fast_connect mode (INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS)
// 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;
static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) {
switch (phase) {
case WiFiRetryPhase::INITIAL_CONNECT:
#ifdef USE_WIFI_FAST_CONNECT
case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS:
#endif
// INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS both use 1 attempt per AP (fast_connect mode)
return WIFI_RETRY_COUNT_PER_AP;
case WiFiRetryPhase::EXPLICIT_HIDDEN:
// Explicitly hidden network: 1 attempt (user marked as hidden, try once then scan)
return WIFI_RETRY_COUNT_PER_SSID;
case WiFiRetryPhase::SCAN_CONNECTING:
// Scan-based phase: 2 attempts per BSSID (handles transient auth failures after scan)
return WIFI_RETRY_COUNT_PER_BSSID;
case WiFiRetryPhase::RETRY_HIDDEN:
// Hidden network mode: 1 attempt per SSID
return WIFI_RETRY_COUNT_PER_SSID;
default:
return WIFI_RETRY_COUNT_PER_BSSID;
}
}
static void apply_scan_result_to_params(WiFiAP &params, const WiFiScanResult &scan) {
params.set_hidden(false);
params.set_ssid(scan.get_ssid());
params.set_bssid(scan.get_bssid());
params.set_channel(scan.get_channel());
}
bool WiFiComponent::needs_scan_results_() const {
// Only SCAN_CONNECTING phase needs scan results
if (this->retry_phase_ != WiFiRetryPhase::SCAN_CONNECTING) {
return false;
}
// Need scan if we have no results or no matching networks
return this->scan_result_.empty() || !this->scan_result_[0].get_matches();
}
bool WiFiComponent::ssid_was_seen_in_scan_(const std::string &ssid) const {
// Check if this SSID is configured as hidden
// If explicitly marked hidden, we should always try hidden mode regardless of scan results
for (const auto &conf : this->sta_) {
if (conf.get_ssid() == ssid && conf.get_hidden()) {
return false; // Treat as not seen - force hidden mode attempt
}
}
// Otherwise, check if we saw it in scan results
for (const auto &scan : this->scan_result_) {
if (scan.get_ssid() == ssid) {
return true;
}
}
return false;
}
int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index, bool include_explicit_hidden) {
// Find next SSID that wasn't in scan results (might be hidden)
// Start searching from start_index + 1
for (size_t i = start_index + 1; i < this->sta_.size(); i++) {
const auto &sta = this->sta_[i];
// Skip networks that were already tried in EXPLICIT_HIDDEN phase
// Those are: networks marked hidden:true that appear before the first non-hidden network
if (!include_explicit_hidden && sta.get_hidden()) {
int8_t first_non_hidden_idx = this->find_first_non_hidden_index_();
if (first_non_hidden_idx >= 0 && static_cast<int8_t>(i) < first_non_hidden_idx) {
ESP_LOGD(TAG, "Skipping " LOG_SECRET("'%s'") " (explicit hidden, already tried)", sta.get_ssid().c_str());
continue;
}
}
if (!this->ssid_was_seen_in_scan_(sta.get_ssid())) {
ESP_LOGD(TAG, "Hidden candidate " LOG_SECRET("'%s'") " at index %d", sta.get_ssid().c_str(), static_cast<int>(i));
return static_cast<int8_t>(i);
}
ESP_LOGD(TAG, "Skipping " LOG_SECRET("'%s'") " (visible in scan)", sta.get_ssid().c_str());
}
// No hidden SSIDs found
return -1;
}
void WiFiComponent::start_initial_connection_() {
// If first network (highest priority) is explicitly marked hidden, try it first before scanning
// This respects user's priority order when they explicitly configure hidden networks
if (!this->sta_.empty() && this->sta_[0].get_hidden()) {
ESP_LOGI(TAG, "Starting with explicit hidden network (highest priority)");
this->selected_sta_index_ = 0;
this->retry_phase_ = WiFiRetryPhase::EXPLICIT_HIDDEN;
WiFiAP params = this->build_params_for_current_phase_();
this->start_connecting(params, false);
} else {
ESP_LOGI(TAG, "Starting scan");
this->start_scanning();
}
}
#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) {
switch (type) {
@@ -361,28 +109,18 @@ void WiFiComponent::start() {
ESP_LOGV(TAG, "Setting Power Save Option failed");
}
this->transition_to_phase_(WiFiRetryPhase::INITIAL_CONNECT);
#ifdef USE_WIFI_FAST_CONNECT
WiFiAP params;
bool loaded_fast_connect = this->load_fast_connect_settings_(params);
// Fast connect optimization: only use when we have saved BSSID+channel data
// Without saved data, try first configured network or use normal flow
if (loaded_fast_connect) {
ESP_LOGI(TAG, "Starting fast_connect (saved) " LOG_SECRET("'%s'"), params.get_ssid().c_str());
this->start_connecting(params, false);
} else if (!this->sta_.empty() && !this->sta_[0].get_hidden()) {
// No saved data, but have configured networks - try first non-hidden network
ESP_LOGI(TAG, "Starting fast_connect (config) " LOG_SECRET("'%s'"), this->sta_[0].get_ssid().c_str());
this->trying_loaded_ap_ = this->load_fast_connect_settings_(params);
if (!this->trying_loaded_ap_) {
// FAST CONNECT FALLBACK: No saved settings available
// Use first config (will use SSID from config)
this->selected_sta_index_ = 0;
params = this->build_params_for_current_phase_();
this->start_connecting(params, false);
} else {
// No saved data and (no networks OR first is hidden) - use normal flow
this->start_initial_connection_();
params = this->build_wifi_ap_from_selected_();
}
this->start_connecting(params, false);
#else
// Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
this->start_initial_connection_();
this->start_scanning();
#endif
#ifdef USE_WIFI_AP
} else if (this->has_ap()) {
@@ -412,7 +150,8 @@ void WiFiComponent::restart_adapter() {
ESP_LOGW(TAG, "Restarting adapter");
this->wifi_mode_(false, {});
delay(100); // NOLINT
// Don't set retry_phase_ or num_retried_ here - state machine handles transitions
this->num_retried_ = 0;
this->retry_hidden_ = false;
}
void WiFiComponent::loop() {
@@ -433,19 +172,21 @@ void WiFiComponent::loop() {
case WIFI_COMPONENT_STATE_COOLDOWN: {
this->status_set_warning(LOG_STR("waiting to reconnect"));
if (millis() - this->action_started_ > 5000) {
// After cooldown, connect based on current retry phase
#ifdef USE_WIFI_FAST_CONNECT
// Safety check: Ensure selected_sta_index_ is valid before retrying
// (should already be set by retry_connect(), but check for robustness)
this->reset_selected_ap_to_first_if_invalid_();
// Check if we need to trigger a scan first
if (this->needs_scan_results_() && !this->all_networks_hidden_()) {
// Need scan results or no matching networks found - scan/rescan
ESP_LOGD(TAG, "Scanning required for phase %s", LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
this->start_scanning();
} else {
// Have everything we need to connect (or all networks are hidden, skip scanning)
WiFiAP params = this->build_params_for_current_phase_();
WiFiAP params = this->build_wifi_ap_from_selected_();
this->start_connecting(params, false);
#else
if (this->retry_hidden_) {
this->reset_selected_ap_to_first_if_invalid_();
WiFiAP params = this->build_wifi_ap_from_selected_();
this->start_connecting(params, false);
} else {
this->start_scanning();
}
#endif
}
break;
}
@@ -603,44 +344,30 @@ void WiFiComponent::set_sta(const WiFiAP &ap) {
this->selected_sta_index_ = 0;
}
WiFiAP WiFiComponent::build_params_for_current_phase_() {
WiFiAP WiFiComponent::build_wifi_ap_from_selected_() const {
// PRECONDITION: selected_sta_index_ must be valid (ensured by all callers)
const WiFiAP *config = this->get_selected_sta_();
if (config == nullptr) {
ESP_LOGE(TAG, "No valid network config (selected_sta_index_=%d, sta_.size()=%zu)",
static_cast<int>(this->selected_sta_index_), this->sta_.size());
// Return empty params - caller should handle this gracefully
return WiFiAP();
}
assert(config != nullptr);
WiFiAP params = *config;
switch (this->retry_phase_) {
case WiFiRetryPhase::INITIAL_CONNECT:
#ifdef USE_WIFI_FAST_CONNECT
case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS:
#endif
// Fast connect phases: use config-only (no scan results)
// BSSID/channel from config if user specified them, otherwise empty
break;
case WiFiRetryPhase::EXPLICIT_HIDDEN:
case WiFiRetryPhase::RETRY_HIDDEN:
// Hidden network mode: clear BSSID/channel to trigger probe request
// (both explicit hidden and retry hidden use same behavior)
params.set_bssid(optional<bssid_t>{});
params.set_channel(optional<uint8_t>{});
break;
case WiFiRetryPhase::SCAN_CONNECTING:
// Scan-based phase: always use best scan result (index 0 - highest priority after sorting)
if (!this->scan_result_.empty()) {
apply_scan_result_to_params(params, this->scan_result_[0]);
}
break;
case WiFiRetryPhase::RESTARTING_ADAPTER:
// Should not be building params during restart
break;
// SYNCHRONIZATION: selected_sta_index_ and scan_result_[0] are kept in sync after wifi_scan_done():
// - wifi_scan_done() sorts all scan results by priority/RSSI (best first)
// - It then finds which sta_[i] config matches scan_result_[0]
// - Sets selected_sta_index_ = i to record that matching config
// This sync holds until scan_result_ is cleared (e.g., after connection or in reset_for_next_ap_attempt_())
if (!this->scan_result_.empty()) {
// Override with scan data - network is visible
const WiFiScanResult &scan = this->scan_result_[0];
params.set_hidden(false);
params.set_ssid(scan.get_ssid());
params.set_bssid(scan.get_bssid());
params.set_channel(scan.get_channel());
} else if (params.get_hidden()) {
// Hidden network - clear BSSID and channel even if set in config
// There might be multiple hidden networks with same SSID but we can't know which is correct
// Rely on probe-req with just SSID. Empty channel triggers ALL_CHANNEL_SCAN.
params.set_bssid(optional<bssid_t>{});
params.set_channel(optional<uint8_t>{});
}
return params;
@@ -665,21 +392,7 @@ void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &pa
}
void WiFiComponent::start_connecting(const WiFiAP &ap, bool two) {
// Log connection attempt at INFO level with priority
std::string bssid_formatted;
float priority = 0.0f;
if (ap.get_bssid().has_value()) {
bssid_formatted = format_mac_address_pretty(ap.get_bssid().value().data());
priority = this->get_sta_priority(ap.get_bssid().value());
}
ESP_LOGI(TAG,
"Connecting to " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " (priority %.1f, attempt %u/%u in phase %s)...",
ap.get_ssid().c_str(), ap.get_bssid().has_value() ? bssid_formatted.c_str() : LOG_STR_LITERAL("any"),
priority, this->num_retried_ + 1, get_max_retries_for_phase(this->retry_phase_),
LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
ESP_LOGI(TAG, "Connecting to '%s'", ap.get_ssid().c_str());
#ifdef ESPHOME_LOG_HAS_VERBOSE
ESP_LOGV(TAG, "Connection Params:");
ESP_LOGV(TAG, " SSID: '%s'", ap.get_ssid().c_str());
@@ -852,39 +565,8 @@ void WiFiComponent::start_scanning() {
this->state_ = WIFI_COMPONENT_STATE_STA_SCANNING;
}
/// Comparator for WiFi scan result sorting - determines which network should be tried first
/// Returns true if 'a' should be placed before 'b' in the sorted order (a is "better" than b)
///
/// Sorting logic (in priority order):
/// 1. Matching networks always ranked before non-matching networks
/// 2. For matching networks: Priority first (CRITICAL - tracks failure history)
/// 3. RSSI as tiebreaker for equal priority or non-matching networks
///
/// WHY PRIORITY MUST BE CHECKED FIRST:
/// The priority field tracks connection failure history via priority degradation:
/// - Initial priority: 0.0 (from config or default)
/// - Each connection failure: priority -= 1.0 (becomes -1.0, -2.0, -3.0, etc.)
/// - Failed BSSIDs sorted lower → naturally try different BSSID on next scan
///
/// This enables automatic BSSID cycling for various real-world failure scenarios:
/// - Crashed/hung AP (visible but not responding)
/// - Misconfigured mesh node (accepts auth but no DHCP/routing)
/// - Capacity limits (AP refuses new clients)
/// - Rogue AP (same SSID, wrong password or malicious)
/// - Intermittent hardware issues (flaky radio, overheating)
///
/// Example mesh network: 3 APs with same SSID "home", all at priority 0.0 initially
/// - Try strongest BSSID A (sorted by RSSI) → fails → priority A becomes -1.0
/// - Next scan: BSSID B and C (priority 0.0) sorted BEFORE A (priority -1.0)
/// - Try next strongest BSSID B → succeeds or fails and gets deprioritized
/// - System naturally cycles through all BSSIDs via priority degradation
/// - Eventually finds working AP or tries all options before restarting adapter
///
/// If we checked RSSI first (Bug in PR #9963):
/// - Same failed BSSID would keep being selected if it has strongest signal
/// - Device stuck connecting to crashed AP with -30dBm while working AP at -50dBm ignored
/// - Priority degradation would be useless
/// - Mesh networks would never recover from single AP failure
// Helper function for WiFi scan result comparison
// Returns true if 'a' should be placed before 'b' in the sorted order
[[nodiscard]] inline static bool wifi_scan_result_is_better(const WiFiScanResult &a, const WiFiScanResult &b) {
// Matching networks always come before non-matching
if (a.get_matches() && !b.get_matches())
@@ -892,13 +574,21 @@ void WiFiComponent::start_scanning() {
if (!a.get_matches() && b.get_matches())
return false;
// Both matching: check priority first (tracks connection failures via priority degradation)
// Priority is decreased when a BSSID fails to connect, so lower priority = previously failed
if (a.get_matches() && b.get_matches() && a.get_priority() != b.get_priority()) {
return a.get_priority() > b.get_priority();
if (a.get_matches() && b.get_matches()) {
// For APs with the same SSID, always prefer stronger signal
// This helps with mesh networks and multiple APs
if (a.get_ssid() == b.get_ssid()) {
return a.get_rssi() > b.get_rssi();
}
// For different SSIDs, check priority first
if (a.get_priority() != b.get_priority())
return a.get_priority() > b.get_priority();
// If priorities are equal, prefer stronger signal
return a.get_rssi() > b.get_rssi();
}
// Use RSSI as tiebreaker (for equal-priority matching networks or all non-matching networks)
// Both don't match - sort by signal strength
return a.get_rssi() > b.get_rssi();
}
@@ -933,8 +623,10 @@ __attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res)
ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(),
res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s,
LOG_STR_ARG(get_signal_bars(res.get_rssi())));
ESP_LOGD(TAG, " Channel: %2u, RSSI: %3d dB, Priority: %4.1f", res.get_channel(), res.get_rssi(),
res.get_priority());
ESP_LOGD(TAG,
" Channel: %u\n"
" RSSI: %d dB",
res.get_channel(), res.get_rssi());
} else {
ESP_LOGD(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), bssid_s,
LOG_STR_ARG(get_signal_bars(res.get_rssi())));
@@ -983,36 +675,34 @@ void WiFiComponent::check_scanning_finished() {
// SYNCHRONIZATION POINT: Establish link between scan_result_[0] and selected_sta_index_
// After sorting, scan_result_[0] contains the best network. Now find which sta_[i] config
// matches that network and record it in selected_sta_index_. This keeps the two indices
// synchronized so build_params_for_current_phase_() can safely use both to build connection parameters.
// synchronized so build_wifi_ap_from_selected_() can safely use both to build connection parameters.
const WiFiScanResult &scan_res = this->scan_result_[0];
if (!scan_res.get_matches()) {
ESP_LOGW(TAG, "No matching network found");
this->retry_connect();
return;
}
bool found_match = false;
if (scan_res.get_matches()) {
for (size_t i = 0; i < this->sta_.size(); i++) {
if (scan_res.matches(this->sta_[i])) {
// Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation
// No overflow check needed - YAML validation prevents >127 networks
this->selected_sta_index_ = static_cast<int8_t>(i); // Links scan_result_[0] with sta_[i]
found_match = true;
break;
}
for (size_t i = 0; i < this->sta_.size(); i++) {
if (scan_res.matches(this->sta_[i])) {
// Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation
// No overflow check needed - YAML validation prevents >127 networks
this->selected_sta_index_ = static_cast<int8_t>(i); // Links scan_result_[0] with sta_[i]
found_match = true;
break;
}
}
if (!found_match) {
ESP_LOGW(TAG, "No matching network found");
// No scan results matched our configured networks - transition directly to hidden mode
// Don't call retry_connect() since we never attempted a connection (no BSSID to penalize)
this->transition_to_phase_(WiFiRetryPhase::RETRY_HIDDEN);
// Now start connection attempt in hidden mode
} else if (this->transition_to_phase_(WiFiRetryPhase::SCAN_CONNECTING)) {
return; // scan started, wait for next loop iteration
this->retry_connect();
return;
}
yield();
WiFiAP params = this->build_params_for_current_phase_();
// Ensure we're in SCAN_CONNECTING phase when connecting with scan results
// (needed when scan was started directly without transition_to_phase_, e.g., initial scan)
WiFiAP params = this->build_wifi_ap_from_selected_();
this->start_connecting(params, false);
}
@@ -1034,14 +724,11 @@ void WiFiComponent::check_connecting_finished() {
ESP_LOGI(TAG, "Connected");
// Warn if we had to retry with hidden network mode for a network that's not marked hidden
// Only warn if we actually connected without scan data (SSID only), not if scan succeeded on retry
if (const WiFiAP *config = this->get_selected_sta_(); this->retry_phase_ == WiFiRetryPhase::RETRY_HIDDEN &&
config && !config->get_hidden() &&
this->scan_result_.empty()) {
ESP_LOGW(TAG, LOG_SECRET("'%s'") " should be marked hidden", config->get_ssid().c_str());
if (const WiFiAP *config = this->get_selected_sta_();
this->retry_hidden_ && config && !config->get_hidden() && this->scan_result_.empty()) {
ESP_LOGW(TAG, "Network '%s' should be marked as hidden", config->get_ssid().c_str());
}
// Reset to initial phase on successful connection (don't log transition, just reset state)
this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT;
this->num_retried_ = 0;
this->retry_hidden_ = false;
this->print_connect_params_();
@@ -1109,340 +796,70 @@ void WiFiComponent::check_connecting_finished() {
this->retry_connect();
}
/// Determine the next retry phase based on current state and failure conditions
/// This function examines the current retry phase, number of retries, and failure reasons
/// to decide what phase to move to next. It does not modify any state - it only returns
/// the recommended next phase.
///
/// @return The next WiFiRetryPhase to transition to (may be same as current phase if should retry)
WiFiRetryPhase WiFiComponent::determine_next_phase_() {
switch (this->retry_phase_) {
case WiFiRetryPhase::INITIAL_CONNECT:
#ifdef USE_WIFI_FAST_CONNECT
case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS:
// INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS: no retries, try next AP or fall back to scan
if (this->selected_sta_index_ < static_cast<int8_t>(this->sta_.size()) - 1) {
return WiFiRetryPhase::FAST_CONNECT_CYCLING_APS; // Move to next AP
}
#endif
// No more APs to try, fall back to scan
return WiFiRetryPhase::SCAN_CONNECTING;
case WiFiRetryPhase::EXPLICIT_HIDDEN: {
// Try all explicitly hidden networks before scanning
if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) {
return WiFiRetryPhase::EXPLICIT_HIDDEN; // Keep retrying same SSID
}
// Exhausted retries on current SSID - check for more explicitly hidden networks
// Stop when we reach a visible network (proceed to scanning)
size_t next_index = this->selected_sta_index_ + 1;
if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) {
// Found another explicitly hidden network
return WiFiRetryPhase::EXPLICIT_HIDDEN;
}
// No more consecutive explicitly hidden networks - proceed to scanning
return WiFiRetryPhase::SCAN_CONNECTING;
}
case WiFiRetryPhase::SCAN_CONNECTING:
// If scan found no matching networks, skip to hidden network mode
if (!this->scan_result_.empty() && !this->scan_result_[0].get_matches()) {
return WiFiRetryPhase::RETRY_HIDDEN;
}
if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_BSSID) {
return WiFiRetryPhase::SCAN_CONNECTING; // Keep retrying same BSSID
}
// Exhausted retries on current BSSID (scan_result_[0])
// Its priority has been decreased, so on next scan it will be sorted lower
// and we'll try the next best BSSID.
// Check if there are any potentially hidden networks to try
if (this->find_next_hidden_sta_(-1, !this->went_through_explicit_hidden_phase_()) >= 0) {
return WiFiRetryPhase::RETRY_HIDDEN; // Found hidden networks to try
}
// No hidden networks - skip directly to restart/rescan
if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) {
return this->went_through_explicit_hidden_phase_() ? WiFiRetryPhase::EXPLICIT_HIDDEN
: WiFiRetryPhase::SCAN_CONNECTING;
}
return WiFiRetryPhase::RESTARTING_ADAPTER;
case WiFiRetryPhase::RETRY_HIDDEN:
// If no hidden SSIDs to try (selected_sta_index_ == -1), skip directly to rescan
if (this->selected_sta_index_ >= 0) {
if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) {
return WiFiRetryPhase::RETRY_HIDDEN; // Keep retrying same SSID
}
// Exhausted retries on current SSID - check if there are more potentially hidden SSIDs to try
if (this->selected_sta_index_ < static_cast<int8_t>(this->sta_.size()) - 1) {
// More SSIDs available - stay in RETRY_HIDDEN, advance will happen in retry_connect()
return WiFiRetryPhase::RETRY_HIDDEN;
}
}
// Exhausted all potentially hidden SSIDs - rescan to try next BSSID
// If captive portal/improv is active, skip adapter restart and go back to start
// Otherwise restart adapter to clear any stuck state
if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) {
// Go back to explicit hidden if we went through it initially, otherwise scan
return this->went_through_explicit_hidden_phase_() ? WiFiRetryPhase::EXPLICIT_HIDDEN
: WiFiRetryPhase::SCAN_CONNECTING;
}
// Restart adapter
return WiFiRetryPhase::RESTARTING_ADAPTER;
case WiFiRetryPhase::RESTARTING_ADAPTER:
// After restart, go back to explicit hidden if we went through it initially, otherwise scan
return this->went_through_explicit_hidden_phase_() ? WiFiRetryPhase::EXPLICIT_HIDDEN
: WiFiRetryPhase::SCAN_CONNECTING;
}
// Should never reach here
return WiFiRetryPhase::SCAN_CONNECTING;
}
/// Transition from current retry phase to a new phase with logging and phase-specific setup
/// This function handles the actual state change, including:
/// - Logging the phase transition
/// - Resetting the retry counter
/// - Performing phase-specific initialization (e.g., advancing AP index, starting scans)
///
/// @param new_phase The phase we're transitioning TO
/// @return true if an async scan was started (caller should wait for completion)
/// false if no scan started (caller can proceed with connection attempt)
bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) {
WiFiRetryPhase old_phase = this->retry_phase_;
// No-op if staying in same phase
if (old_phase == new_phase) {
return false;
}
ESP_LOGD(TAG, "Retry phase: %s → %s", LOG_STR_ARG(retry_phase_to_log_string(old_phase)),
LOG_STR_ARG(retry_phase_to_log_string(new_phase)));
this->retry_phase_ = new_phase;
this->num_retried_ = 0; // Reset retry counter on phase change
// Phase-specific setup
switch (new_phase) {
#ifdef USE_WIFI_FAST_CONNECT
case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS:
// Move to next configured AP - clear old scan data so new AP is tried with config only
this->selected_sta_index_++;
this->scan_result_.clear();
break;
#endif
case WiFiRetryPhase::EXPLICIT_HIDDEN:
// Starting explicit hidden phase - reset to first network
this->selected_sta_index_ = 0;
break;
case WiFiRetryPhase::SCAN_CONNECTING:
// Transitioning to scan-based connection
#ifdef USE_WIFI_FAST_CONNECT
if (old_phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) {
ESP_LOGI(TAG, "Fast connect exhausted, falling back to scan");
}
#endif
// Trigger scan if we don't have scan results OR if transitioning from phases that need fresh scan
if (this->scan_result_.empty() || old_phase == WiFiRetryPhase::EXPLICIT_HIDDEN ||
old_phase == WiFiRetryPhase::RETRY_HIDDEN || old_phase == WiFiRetryPhase::RESTARTING_ADAPTER) {
this->selected_sta_index_ = -1; // Will be set after scan completes
this->start_scanning();
return true; // Started scan, wait for completion
}
// Already have scan results - selected_sta_index_ should already be synchronized
// (set in check_scanning_finished() when scan completed)
// No need to reset it here
break;
case WiFiRetryPhase::RETRY_HIDDEN:
// Starting hidden mode - find first SSID that wasn't in scan results
if (old_phase == WiFiRetryPhase::SCAN_CONNECTING) {
// Keep scan results so we can skip SSIDs that were visible in the scan
// Don't clear scan_result_ - we need it to know which SSIDs are NOT hidden
// If first network is marked hidden, we went through EXPLICIT_HIDDEN phase
// In that case, skip networks marked hidden:true (already tried)
// Otherwise, include them (they haven't been tried yet)
this->selected_sta_index_ = this->find_next_hidden_sta_(-1, !this->went_through_explicit_hidden_phase_());
if (this->selected_sta_index_ == -1) {
ESP_LOGD(TAG, "All SSIDs visible or already tried, skipping hidden mode");
}
}
break;
case WiFiRetryPhase::RESTARTING_ADAPTER:
this->restart_adapter();
// Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting
return true;
default:
break;
}
return false; // Did not start scan, can proceed with connection
}
/// Log failed connection attempt and decrease BSSID priority to avoid repeated failures
/// This function identifies which BSSID was attempted (from scan results or config),
/// decreases its priority by 1.0 to discourage future attempts, and logs the change.
///
/// The priority degradation system ensures that failed BSSIDs are automatically sorted
/// lower in subsequent scans, naturally cycling through different APs without explicit
/// BSSID tracking within a scan cycle.
///
/// Priority sources:
/// - SCAN_CONNECTING phase: Uses BSSID from scan_result_[0] (best match after sorting)
/// - Other phases: Uses BSSID from config if explicitly specified by user or fast_connect
///
/// If no BSSID is available (SSID-only connection), priority adjustment is skipped.
void WiFiComponent::log_and_adjust_priority_for_failed_connect_() {
// Determine which BSSID we tried to connect to
optional<bssid_t> failed_bssid;
if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) {
// Scan-based phase: always use best result (index 0)
failed_bssid = this->scan_result_[0].get_bssid();
} else if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid()) {
// Config has specific BSSID (fast_connect or user-specified)
failed_bssid = *config->get_bssid();
}
if (!failed_bssid.has_value()) {
return; // No BSSID to penalize
}
// Decrease priority to avoid repeatedly trying the same failed BSSID
float old_priority = this->get_sta_priority(failed_bssid.value());
float new_priority = old_priority - 1.0f;
this->set_sta_priority(failed_bssid.value(), new_priority);
// Get SSID for logging
std::string ssid;
if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) {
ssid = this->scan_result_[0].get_ssid();
} else if (const WiFiAP *config = this->get_selected_sta_()) {
ssid = config->get_ssid();
}
ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %.1f → %.1f", ssid.c_str(),
format_mac_address_pretty(failed_bssid.value().data()).c_str(), old_priority, new_priority);
}
/// Handle target advancement or retry counter increment when staying in the same phase
/// This function is called when a connection attempt fails and determine_next_phase_() indicates
/// we should stay in the current phase. It decides whether to:
/// - Advance to the next target (AP in fast_connect, SSID in hidden mode)
/// - Or increment the retry counter to try the same target again
///
/// Phase-specific behavior:
/// - FAST_CONNECT_CYCLING_APS: Always advance to next AP (no retries per AP)
/// - RETRY_HIDDEN: Advance to next SSID after exhausting retries on current SSID
/// - Other phases: Increment retry counter (will retry same target)
void WiFiComponent::advance_to_next_target_or_increment_retry_() {
WiFiRetryPhase current_phase = this->retry_phase_;
// Check if we need to advance to next AP/SSID within the same phase
#ifdef USE_WIFI_FAST_CONNECT
if (current_phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) {
// Fast connect: always advance to next AP (no retries per AP)
this->selected_sta_index_++;
this->num_retried_ = 0;
ESP_LOGD(TAG, "Next AP in %s", LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
return;
}
#endif
if (current_phase == WiFiRetryPhase::EXPLICIT_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) {
// Explicit hidden: exhausted retries on current SSID, find next explicitly hidden network
// Stop when we reach a visible network (proceed to scanning)
size_t next_index = this->selected_sta_index_ + 1;
if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) {
this->selected_sta_index_ = static_cast<int8_t>(next_index);
this->num_retried_ = 0;
ESP_LOGD(TAG, "Next explicit hidden network at index %d", static_cast<int>(next_index));
return;
}
// No more consecutive explicit hidden networks found - fall through to trigger phase change
}
if (current_phase == WiFiRetryPhase::RETRY_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) {
// Hidden mode: exhausted retries on current SSID, find next potentially hidden SSID
// If first network is marked hidden, we went through EXPLICIT_HIDDEN phase
// In that case, skip networks marked hidden:true (already tried)
// Otherwise, include them (they haven't been tried yet)
int8_t next_index =
this->find_next_hidden_sta_(this->selected_sta_index_, !this->went_through_explicit_hidden_phase_());
if (next_index != -1) {
// Found another potentially hidden SSID
this->selected_sta_index_ = next_index;
this->num_retried_ = 0;
return;
}
// No more potentially hidden SSIDs - set selected_sta_index_ to -1 to trigger phase change
// This ensures determine_next_phase_() will skip the RETRY_HIDDEN logic and transition out
this->selected_sta_index_ = -1;
// Return early - phase change will happen on next wifi_loop() iteration
return;
}
// Don't increment retry counter if we're in a scan phase with no valid targets
if (this->needs_scan_results_()) {
return;
}
// Increment retry counter to try the same target again
this->num_retried_++;
ESP_LOGD(TAG, "Retry attempt %u/%u in phase %s", this->num_retried_ + 1,
get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
}
void WiFiComponent::retry_connect() {
this->log_and_adjust_priority_for_failed_connect_();
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid()) {
auto bssid = *config->get_bssid();
float priority = this->get_sta_priority(bssid);
this->set_sta_priority(bssid, priority - 1.0f);
}
delay(10);
// Determine next retry phase based on current state
WiFiRetryPhase current_phase = this->retry_phase_;
WiFiRetryPhase next_phase = this->determine_next_phase_();
// Handle phase transitions (transition_to_phase_ handles same-phase no-op internally)
if (this->transition_to_phase_(next_phase)) {
return; // Wait for scan to complete
if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_() &&
(this->num_retried_ > 3 || this->error_from_callback_)) {
#ifdef USE_WIFI_FAST_CONNECT
// No empty check needed - YAML validation requires at least one network for fast_connect
if (this->trying_loaded_ap_) {
this->trying_loaded_ap_ = false;
this->selected_sta_index_ = 0; // Retry from the first configured AP
this->reset_for_next_ap_attempt_();
} else if (this->selected_sta_index_ >= static_cast<int8_t>(this->sta_.size()) - 1) {
// Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation
// Exhausted all configured APs, restart adapter and cycle back to first
// Restart clears any stuck WiFi driver state
// Each AP is tried with config data only (SSID + optional BSSID/channel if user configured them)
// Typically SSID only, which triggers ESP-IDF internal scanning
ESP_LOGW(TAG, "No more APs to try");
this->selected_sta_index_ = 0;
this->reset_for_next_ap_attempt_();
this->restart_adapter();
} else {
// Try next AP
this->selected_sta_index_++;
this->reset_for_next_ap_attempt_();
}
#else
if (this->num_retried_ > 5) {
// If retry failed for more than 5 times, let's restart STA
this->restart_adapter();
} else {
// Try hidden networks after 3 failed retries
ESP_LOGD(TAG, "Retrying with hidden networks");
this->retry_hidden_ = true;
this->num_retried_++;
}
#endif
} else {
this->num_retried_++;
}
if (next_phase == current_phase) {
this->advance_to_next_target_or_increment_retry_();
}
this->error_from_callback_ = false;
if (this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTING) {
yield();
// Check if we have a valid target before building params
// After exhausting all networks in a phase, selected_sta_index_ may be -1
// In that case, skip connection and let next wifi_loop() handle phase transition
if (this->selected_sta_index_ >= 0) {
this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING_2;
WiFiAP params = this->build_params_for_current_phase_();
this->start_connecting(params, true);
return;
}
// No valid target - fall through to set state to allow phase transition
this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING_2;
WiFiAP params = this->build_wifi_ap_from_selected_();
this->start_connecting(params, true);
return;
}
this->state_ = WIFI_COMPONENT_STATE_COOLDOWN;
this->action_started_ = millis();
}
bool WiFiComponent::can_proceed() {
if (!this->has_sta() || this->state_ == WIFI_COMPONENT_STATE_DISABLED || this->ap_setup_) {
return true;
}
return this->is_connected();
}
void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
bool WiFiComponent::is_connected() {
return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED &&

View File

@@ -94,24 +94,6 @@ enum class WiFiSTAConnectStatus : int {
ERROR_CONNECT_FAILED,
};
/// Tracks the current retry strategy/phase for WiFi connection attempts
enum class WiFiRetryPhase : uint8_t {
/// Initial connection attempt (varies based on fast_connect setting)
INITIAL_CONNECT,
#ifdef USE_WIFI_FAST_CONNECT
/// Fast connect mode: cycling through configured APs (config-only, no scan)
FAST_CONNECT_CYCLING_APS,
#endif
/// Explicitly hidden networks (user marked as hidden, try before scanning)
EXPLICIT_HIDDEN,
/// Scan-based: connecting to best AP from scan results
SCAN_CONNECTING,
/// Retry networks not found in scan (might be hidden)
RETRY_HIDDEN,
/// Restarting WiFi adapter to clear stuck state
RESTARTING_ADAPTER,
};
/// Struct for setting static IPs in WiFiComponent.
struct ManualIP {
network::IPAddress static_ip;
@@ -269,6 +251,8 @@ class WiFiComponent : public Component {
void retry_connect();
bool can_proceed() override;
void set_reboot_timeout(uint32_t reboot_timeout);
bool is_connected();
@@ -357,37 +341,8 @@ class WiFiComponent : public Component {
#endif // USE_WIFI_AP
void print_connect_params_();
WiFiAP build_params_for_current_phase_();
WiFiAP build_wifi_ap_from_selected_() const;
/// Determine next retry phase based on current state and failure conditions
WiFiRetryPhase determine_next_phase_();
/// Transition to a new retry phase with logging
/// Returns true if a scan was started (caller should wait), false otherwise
bool transition_to_phase_(WiFiRetryPhase new_phase);
/// Check if we need valid scan results for the current phase but don't have any
/// Returns true if the phase requires scan results but they're missing or don't match
bool needs_scan_results_() const;
/// Check if we went through EXPLICIT_HIDDEN phase (first network is marked hidden)
/// Used in RETRY_HIDDEN to determine whether to skip explicitly hidden networks
bool went_through_explicit_hidden_phase_() const;
/// Find the index of the first non-hidden network
/// Returns where EXPLICIT_HIDDEN phase would have stopped, or -1 if all networks are hidden
int8_t find_first_non_hidden_index_() const;
/// Check if an SSID was seen in the most recent scan results
/// Used to skip hidden mode for SSIDs we know are visible
bool ssid_was_seen_in_scan_(const std::string &ssid) const;
/// Find next SSID that wasn't in scan results (might be hidden)
/// Returns index of next potentially hidden SSID, or -1 if none found
/// @param start_index Start searching from index after this (-1 to start from beginning)
/// @param include_explicit_hidden If true, include SSIDs marked hidden:true. If false, only find truly hidden SSIDs.
int8_t find_next_hidden_sta_(int8_t start_index, bool include_explicit_hidden = true);
/// Log failed connection and decrease BSSID priority to avoid repeated attempts
void log_and_adjust_priority_for_failed_connect_();
/// Advance to next target (AP/SSID) within current phase, or increment retry counter
/// Called when staying in the same phase after a failed connection attempt
void advance_to_next_target_or_increment_retry_();
/// Start initial connection - either scan or connect directly to hidden networks
void start_initial_connection_();
const WiFiAP *get_selected_sta_() const {
if (this->selected_sta_index_ >= 0 && static_cast<size_t>(this->selected_sta_index_) < this->sta_.size()) {
return &this->sta_[this->selected_sta_index_];
@@ -401,15 +356,14 @@ class WiFiComponent : public Component {
}
}
bool all_networks_hidden_() const {
if (this->sta_.empty())
return false;
for (const auto &ap : this->sta_) {
if (!ap.get_hidden())
return false;
}
return true;
#ifdef USE_WIFI_FAST_CONNECT
// Reset state for next fast connect AP attempt
// Clears old scan data so the new AP is tried with config only (SSID without specific BSSID/channel)
void reset_for_next_ap_attempt_() {
this->num_retried_ = 0;
this->scan_result_.clear();
}
#endif
void wifi_loop_();
bool wifi_mode_(optional<bool> sta, optional<bool> ap);
@@ -489,18 +443,20 @@ class WiFiComponent : public Component {
// Group all 8-bit values together
WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF};
WiFiPowerSaveMode power_save_{WIFI_POWER_SAVE_NONE};
WiFiRetryPhase retry_phase_{WiFiRetryPhase::INITIAL_CONNECT};
uint8_t num_retried_{0};
// Index into sta_ array for the currently selected AP configuration (-1 = none selected)
// Used to access password, manual_ip, priority, EAP settings, and hidden flag
// int8_t limits to 127 APs (enforced in __init__.py via MAX_WIFI_NETWORKS)
int8_t selected_sta_index_{-1};
#if USE_NETWORK_IPV6
uint8_t num_ipv6_addresses_{0};
#endif /* USE_NETWORK_IPV6 */
// Group all boolean values together
#ifdef USE_WIFI_FAST_CONNECT
bool trying_loaded_ap_{false};
#endif
bool retry_hidden_{false};
bool has_ap_{false};
bool handled_connected_state_{false};
bool error_from_callback_{false};

View File

@@ -287,8 +287,6 @@
#ifdef USE_NRF52
#define USE_NRF52_DFU
#define USE_NRF52_REG0_VOUT 5
#define USE_NRF52_UICR_ERASE
#define USE_SOFTDEVICE_ID 7
#define USE_SOFTDEVICE_VERSION 1
#endif

View File

@@ -1174,18 +1174,12 @@ template<class T> using ExternalRAMAllocator = RAMAllocator<T>;
* Functions to constrain the range of arithmetic values.
*/
template<typename T, typename U>
concept comparable_with = requires(T a, U b) {
{ a > b } -> std::convertible_to<bool>;
{ a < b } -> std::convertible_to<bool>;
};
template<std::totally_ordered T, comparable_with<T> U> T clamp_at_least(T value, U min) {
template<std::totally_ordered T> T clamp_at_least(T value, T min) {
if (value < min)
return min;
return value;
}
template<std::totally_ordered T, comparable_with<T> U> T clamp_at_most(T value, U max) {
template<std::totally_ordered T> T clamp_at_most(T value, T max) {
if (value > max)
return max;
return value;

View File

@@ -6,7 +6,3 @@
#ifdef USE_ARDUINO
#include <Arduino.h>
#endif
#ifdef USE_ZEPHYR
#define M_PI 3.14159265358979323846
#endif

View File

@@ -1,14 +1,14 @@
pylint==4.0.2
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.14.4 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.1 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.0 # also change in .pre-commit-config.yaml when updating
pre-commit
# Unit tests
pytest==9.0.0
pytest==8.4.2
pytest-cov==7.0.0
pytest-mock==3.15.1
pytest-asyncio==1.3.0
pytest-asyncio==1.2.0
pytest-xdist==3.8.0
asyncmock==0.4.2
hypothesis==6.92.1

View File

@@ -86,7 +86,6 @@ ISOLATED_COMPONENTS = {
"modbus_controller": "Defines multiple modbus buses for testing client/server functionality - conflicts with package modbus bus",
"neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)",
"packages": "cannot merge packages",
"tinyusb": "Conflicts with usb_host component - cannot be used together",
}

View File

@@ -16,6 +16,5 @@ display:
touchscreen:
- platform: chsc6x
i2c_id: i2c_bus
display: ili9xxx_display
interrupt_pin: 22

View File

@@ -1,4 +0,0 @@
packages:
i2c: !include ../../test_build_components/common/i2c/nrf52.yaml
<<: !include common.yaml

View File

@@ -1,41 +0,0 @@
esphome:
on_boot:
then:
- hlk_fm22x.enroll:
name: "Test"
direction: 1
- hlk_fm22x.delete_all:
hlk_fm22x:
on_face_scan_matched:
- logger.log: test_hlk_22x_face_scan_matched
on_face_scan_unmatched:
- logger.log: test_hlk_22x_face_scan_unmatched
on_face_scan_invalid:
- logger.log: test_hlk_22x_face_scan_invalid
on_face_info:
- logger.log: test_hlk_22x_face_info
on_enrollment_done:
- logger.log: test_hlk_22x_enrollment_done
on_enrollment_failed:
- logger.log: test_hlk_22x_enrollment_failed
sensor:
- platform: hlk_fm22x
face_count:
name: "Face Count"
last_face_id:
name: "Last Face ID"
status:
name: "Face Status"
binary_sensor:
- platform: hlk_fm22x
name: "Face Enrolling"
text_sensor:
- platform: hlk_fm22x
version:
name: "HLK Version"
last_face_name:
name: "Last Face Name"

View File

@@ -1,4 +0,0 @@
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -1,4 +0,0 @@
packages:
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
<<: !include common.yaml

View File

@@ -1,4 +0,0 @@
packages:
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
<<: !include common.yaml

View File

@@ -700,10 +700,6 @@ lvgl:
width: 100%
height: 10%
align: top_mid
on_value:
- lvgl.spinbox.update:
id: spinbox_id
value: !lambda return x;
- button:
styles: spin_button
id: spin_up

View File

@@ -15,6 +15,3 @@ nrf52:
inverted: true
mode:
output: true
reg0:
voltage: 2.1V
uicr_erase: true

View File

@@ -1,4 +0,0 @@
nrf52:
reg0:
voltage: 3.3V
uicr_erase: true

View File

@@ -5,5 +5,3 @@ nrf52:
inverted: true
mode:
output: true
reg0:
voltage: default

View File

@@ -1,4 +0,0 @@
packages:
i2c: !include ../../test_build_components/common/i2c/nrf52.yaml
<<: !include common.yaml

View File

@@ -1,4 +0,0 @@
packages:
i2c: !include ../../test_build_components/common/i2c/nrf52.yaml
<<: !include common.yaml

View File

@@ -1,8 +0,0 @@
esphome:
on_boot:
- rx8130.read_time
- rx8130.write_time
time:
- platform: rx8130
i2c_id: i2c_bus

View File

@@ -1,4 +0,0 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -1,4 +0,0 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml

View File

@@ -1,4 +0,0 @@
packages:
i2c: !include ../../test_build_components/common/i2c/nrf52.yaml
<<: !include common.yaml

View File

@@ -1,4 +0,0 @@
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml

View File

@@ -11,42 +11,26 @@ esphome:
on_boot:
then:
- speaker.mute_on:
id: speaker_id
- speaker.mute_off:
id: speaker_id
- if:
condition:
speaker.is_stopped:
id: speaker_id
condition: speaker.is_stopped
then:
- speaker.play:
id: speaker_id
data: [0, 1, 2, 3]
- speaker.volume_set:
id: speaker_id
volume: 0.9
- speaker.play: [0, 1, 2, 3]
- speaker.volume_set: 0.9
- if:
condition:
speaker.is_playing:
id: speaker_id
condition: speaker.is_playing
then:
- speaker.finish:
id: speaker_id
- speaker.stop:
id: speaker_id
button:
- platform: template
name: "Speaker Button"
on_press:
then:
- speaker.play:
id: speaker_id
data: [0x10, 0x20, 0x30, 0x40]
- speaker.play:
id: speaker_id
data: !lambda |-
return {0x01, 0x02, (uint8_t)id(my_number).state};
- speaker.play: [0x10, 0x20, 0x30, 0x40]
- speaker.play: !lambda |-
return {0x01, 0x02, (uint8_t)id(my_number).state};
i2s_audio:
i2s_lrclk_pin: ${i2s_bclk_pin}

View File

@@ -1,7 +0,0 @@
substitutions:
reset_pin: P0.10
packages:
i2c: !include ../../test_build_components/common/i2c/nrf52.yaml
<<: !include common.yaml

View File

@@ -1,3 +1,11 @@
remote_transmitter:
pin: ${tx_pin}
carrier_duty_percent: 50%
remote_receiver:
id: rcvr
pin: ${rx_pin}
climate:
- platform: toshiba
name: "RAS-2819T Climate"

View File

@@ -1,5 +1,5 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-ard.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-ard.yaml
substitutions:
tx_pin: GPIO5
rx_pin: GPIO4
<<: !include common_ras2819t.yaml

View File

@@ -1,5 +1,5 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-ard.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-c3-ard.yaml
substitutions:
tx_pin: GPIO5
rx_pin: GPIO4
<<: !include common_ras2819t.yaml

View File

@@ -1,5 +1,5 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml
substitutions:
tx_pin: GPIO5
rx_pin: GPIO4
<<: !include common_ras2819t.yaml

View File

@@ -1,5 +1,5 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml
substitutions:
tx_pin: GPIO5
rx_pin: GPIO4
<<: !include common_ras2819t.yaml

View File

@@ -1,12 +0,0 @@
# Common remote_receiver configuration for ESP32 Arduino tests
# Provides a shared remote receiver that all components can use
# Components will auto-use this receiver if they don't specify receiver_id
substitutions:
remote_receiver_pin: GPIO32
remote_receiver:
- id: rcvr
pin: ${remote_receiver_pin}
dump: all
tolerance: 25%

View File

@@ -1,12 +0,0 @@
# Common remote_receiver configuration for ESP32-C3 Arduino tests
# Provides a shared remote receiver that all components can use
# Components will auto-use this receiver if they don't specify receiver_id
substitutions:
remote_receiver_pin: GPIO10
remote_receiver:
- id: rcvr
pin: ${remote_receiver_pin}
dump: all
tolerance: 25%

View File

@@ -670,51 +670,3 @@ class TestEsphomeCore:
os.environ.pop("ESPHOME_IS_HA_ADDON", None)
os.environ.pop("ESPHOME_DATA_DIR", None)
assert target.data_dir == Path(expected_default)
def test_web_port__none(self, target):
"""Test web_port returns None when web_server is not configured."""
target.config = {}
assert target.web_port is None
def test_web_port__explicit_web_server_default_port(self, target):
"""Test web_port returns 80 when web_server is explicitly configured without port."""
target.config = {const.CONF_WEB_SERVER: {}}
assert target.web_port == 80
def test_web_port__explicit_web_server_custom_port(self, target):
"""Test web_port returns custom port when web_server is configured with port."""
target.config = {const.CONF_WEB_SERVER: {const.CONF_PORT: 8080}}
assert target.web_port == 8080
def test_web_port__ota_web_server_platform_only(self, target):
"""
Test web_port returns None when ota.web_server platform is explicitly configured.
This is a critical test for Dashboard Issue #766:
https://github.com/esphome/dashboard/issues/766
When ota: platform: web_server is explicitly configured (or auto-loaded by captive_portal):
- "web_server" appears in loaded_integrations (platform name added to integrations)
- "ota/web_server" appears in loaded_platforms
- But CONF_WEB_SERVER is NOT in config (only the platform is loaded, not the component)
- web_port MUST return None (no web UI available)
- Dashboard should NOT show VISIT button
This test ensures web_port only checks CONF_WEB_SERVER in config, not loaded_integrations.
"""
# Simulate config with ota.web_server platform but no web_server component
# This happens when:
# 1. User explicitly configures: ota: - platform: web_server
# 2. OR captive_portal auto-loads ota.web_server
target.config = {
const.CONF_OTA: [
{
"platform": "web_server",
# OTA web_server platform config would be here
}
],
# Note: CONF_WEB_SERVER is NOT in config - only the OTA platform
}
# Even though "web_server" is in loaded_integrations due to the platform,
# web_port must return None because the full web_server component is not configured
assert target.web_port is None