diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index f140f395e4..1f2fe61fe1 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -61,6 +61,21 @@ DEPENDENCIES = ["esp32"] AUTO_LOAD = ["network"] LOGGER = logging.getLogger(__name__) +# Key for tracking IP state listener count in CORE.data +ETHERNET_IP_STATE_LISTENERS_KEY = "ethernet_ip_state_listeners" + + +def request_ethernet_ip_state_listener() -> None: + """Request an IP state listener slot. + + Components that implement EthernetIPStateListener should call this + in their to_code() to register for IP state notifications. + """ + CORE.data[ETHERNET_IP_STATE_LISTENERS_KEY] = ( + CORE.data.get(ETHERNET_IP_STATE_LISTENERS_KEY, 0) + 1 + ) + + # RMII pins that are hardcoded on ESP32 classic and cannot be changed # These pins are used by the internal Ethernet MAC when using RMII PHYs ESP32_RMII_FIXED_PINS = { @@ -411,6 +426,8 @@ async def to_code(config): if CORE.using_arduino: cg.add_library("WiFi", None) + CORE.add_job(final_step) + def _final_validate_rmii_pins(config: ConfigType) -> None: """Validate that RMII pins are not used by other components.""" @@ -467,3 +484,11 @@ def _final_validate(config: ConfigType) -> ConfigType: FINAL_VALIDATE_SCHEMA = _final_validate + + +@coroutine_with_priority(CoroPriority.FINAL) +async def final_step(): + """Final code generation step to configure optional Ethernet features.""" + if ip_state_count := CORE.data.get(ETHERNET_IP_STATE_LISTENERS_KEY, 0): + cg.add_define("USE_ETHERNET_IP_STATE_LISTENERS") + cg.add_define("ESPHOME_ETHERNET_IP_STATE_LISTENERS", ip_state_count) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 896c5cc874..70f8ce1204 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -472,6 +472,12 @@ void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base break; case ETHERNET_EVENT_CONNECTED: event_name = "ETH connected"; + // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here +#if defined(USE_ETHERNET_IP_STATE_LISTENERS) && defined(USE_ETHERNET_MANUAL_IP) + if (global_eth_component->manual_ip_.has_value()) { + global_eth_component->notify_ip_state_listeners_(); + } +#endif break; case ETHERNET_EVENT_DISCONNECTED: event_name = "ETH disconnected"; @@ -498,6 +504,9 @@ void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_b global_eth_component->connected_ = true; global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes #endif /* USE_NETWORK_IPV6 */ +#ifdef USE_ETHERNET_IP_STATE_LISTENERS + global_eth_component->notify_ip_state_listeners_(); +#endif } #if USE_NETWORK_IPV6 @@ -514,9 +523,23 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_ global_eth_component->connected_ = global_eth_component->got_ipv4_address_; global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes #endif +#ifdef USE_ETHERNET_IP_STATE_LISTENERS + global_eth_component->notify_ip_state_listeners_(); +#endif } #endif /* USE_NETWORK_IPV6 */ +#ifdef USE_ETHERNET_IP_STATE_LISTENERS +void EthernetComponent::notify_ip_state_listeners_() { + auto ips = this->get_ip_addresses(); + auto dns1 = this->get_dns_address(0); + auto dns2 = this->get_dns_address(1); + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(ips, dns1, dns2); + } +} +#endif // USE_ETHERNET_IP_STATE_LISTENERS + void EthernetComponent::finish_connect_() { #if USE_NETWORK_IPV6 // Retry IPv6 link-local setup if it failed during initial connect diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 490a9d026e..34380047d1 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -17,6 +17,22 @@ namespace esphome { namespace ethernet { +#ifdef USE_ETHERNET_IP_STATE_LISTENERS +/** Listener interface for Ethernet IP state changes. + * + * Components can implement this interface to receive IP address updates + * without the overhead of std::function callbacks or polling. + * + * @note Components must call ethernet.request_ethernet_ip_state_listener() in their + * Python to_code() to register for this listener type. + */ +class EthernetIPStateListener { + public: + virtual void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) = 0; +}; +#endif // USE_ETHERNET_IP_STATE_LISTENERS + enum EthernetType : uint8_t { ETHERNET_TYPE_UNKNOWN = 0, ETHERNET_TYPE_LAN8720, @@ -99,12 +115,19 @@ class EthernetComponent : public Component { eth_speed_t get_link_speed(); bool powerdown(); +#ifdef USE_ETHERNET_IP_STATE_LISTENERS + void add_ip_state_listener(EthernetIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); } +#endif + protected: static void eth_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); static void got_ip_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #if LWIP_IPV6 static void got_ip6_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #endif /* LWIP_IPV6 */ +#ifdef USE_ETHERNET_IP_STATE_LISTENERS + void notify_ip_state_listeners_(); +#endif void start_connect_(); void finish_connect_(); @@ -163,6 +186,10 @@ class EthernetComponent : public Component { esp_eth_phy_t *phy_{nullptr}; optional> fixed_mac_; +#ifdef USE_ETHERNET_IP_STATE_LISTENERS + StaticVector ip_state_listeners_; +#endif + private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. diff --git a/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp b/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp index 35e18c7de5..72ce9c86e2 100644 --- a/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp +++ b/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp @@ -7,8 +7,44 @@ namespace esphome::ethernet_info { static const char *const TAG = "ethernet_info"; +#ifdef USE_ETHERNET_IP_STATE_LISTENERS +void IPAddressEthernetInfo::setup() { ethernet::global_eth_component->add_ip_state_listener(this); } + void IPAddressEthernetInfo::dump_config() { LOG_TEXT_SENSOR("", "EthernetInfo IPAddress", this); } + +void IPAddressEthernetInfo::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) { + char buf[network::IP_ADDRESS_BUFFER_SIZE]; + ips[0].str_to(buf); + this->publish_state(buf); + uint8_t sensor = 0; + for (const auto &ip : ips) { + if (ip.is_set()) { + if (this->ip_sensors_[sensor] != nullptr) { + ip.str_to(buf); + this->ip_sensors_[sensor]->publish_state(buf); + } + sensor++; + } + } +} + +void DNSAddressEthernetInfo::setup() { ethernet::global_eth_component->add_ip_state_listener(this); } + void DNSAddressEthernetInfo::dump_config() { LOG_TEXT_SENSOR("", "EthernetInfo DNS Address", this); } + +void DNSAddressEthernetInfo::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) { + // IP_ADDRESS_BUFFER_SIZE (40) = max IP (39) + null; space reuses first null's slot + char buf[network::IP_ADDRESS_BUFFER_SIZE * 2]; + dns1.str_to(buf); + size_t len1 = strlen(buf); + buf[len1] = ' '; + dns2.str_to(buf + len1 + 1); + this->publish_state(buf); +} +#endif // USE_ETHERNET_IP_STATE_LISTENERS + void MACAddressEthernetInfo::dump_config() { LOG_TEXT_SENSOR("", "EthernetInfo MAC Address", this); } } // namespace esphome::ethernet_info diff --git a/esphome/components/ethernet_info/ethernet_info_text_sensor.h b/esphome/components/ethernet_info/ethernet_info_text_sensor.h index 5b858b772f..912a39a83f 100644 --- a/esphome/components/ethernet_info/ethernet_info_text_sensor.h +++ b/esphome/components/ethernet_info/ethernet_info_text_sensor.h @@ -8,64 +8,37 @@ namespace esphome::ethernet_info { -class IPAddressEthernetInfo : public PollingComponent, public text_sensor::TextSensor { +#ifdef USE_ETHERNET_IP_STATE_LISTENERS +class IPAddressEthernetInfo final : public Component, + public text_sensor::TextSensor, + public ethernet::EthernetIPStateListener { public: - void update() override { - auto ips = ethernet::global_eth_component->get_ip_addresses(); - if (ips != this->last_ips_) { - this->last_ips_ = ips; - char buf[network::IP_ADDRESS_BUFFER_SIZE]; - ips[0].str_to(buf); - this->publish_state(buf); - uint8_t sensor = 0; - for (auto &ip : ips) { - if (ip.is_set()) { - if (this->ip_sensors_[sensor] != nullptr) { - ip.str_to(buf); - this->ip_sensors_[sensor]->publish_state(buf); - } - sensor++; - } - } - } - } - - float get_setup_priority() const override { return setup_priority::ETHERNET; } + void setup() override; void dump_config() override; void add_ip_sensors(uint8_t index, text_sensor::TextSensor *s) { this->ip_sensors_[index] = s; } + // EthernetIPStateListener interface + void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) override; + protected: - network::IPAddresses last_ips_; - std::array ip_sensors_; + std::array ip_sensors_{}; }; -class DNSAddressEthernetInfo : public PollingComponent, public text_sensor::TextSensor { +class DNSAddressEthernetInfo final : public Component, + public text_sensor::TextSensor, + public ethernet::EthernetIPStateListener { public: - void update() override { - auto dns1 = ethernet::global_eth_component->get_dns_address(0); - auto dns2 = ethernet::global_eth_component->get_dns_address(1); - - if (dns1 != this->last_dns1_ || dns2 != this->last_dns2_) { - this->last_dns1_ = dns1; - this->last_dns2_ = dns2; - // IP_ADDRESS_BUFFER_SIZE (40) = max IP (39) + null; space reuses first null's slot - char buf[network::IP_ADDRESS_BUFFER_SIZE * 2]; - dns1.str_to(buf); - size_t len1 = strlen(buf); - buf[len1] = ' '; - dns2.str_to(buf + len1 + 1); - this->publish_state(buf); - } - } - float get_setup_priority() const override { return setup_priority::ETHERNET; } + void setup() override; void dump_config() override; - protected: - network::IPAddress last_dns1_; - network::IPAddress last_dns2_; + // EthernetIPStateListener interface + void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) override; }; +#endif // USE_ETHERNET_IP_STATE_LISTENERS -class MACAddressEthernetInfo : public Component, public text_sensor::TextSensor { +class MACAddressEthernetInfo final : public Component, public text_sensor::TextSensor { public: void setup() override { char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/ethernet_info/text_sensor.py b/esphome/components/ethernet_info/text_sensor.py index 31da516e44..8c20cf332c 100644 --- a/esphome/components/ethernet_info/text_sensor.py +++ b/esphome/components/ethernet_info/text_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import text_sensor +from esphome.components import ethernet, text_sensor import esphome.config_validation as cv from esphome.const import ( CONF_DNS_ADDRESS, @@ -13,24 +13,22 @@ DEPENDENCIES = ["ethernet"] ethernet_info_ns = cg.esphome_ns.namespace("ethernet_info") IPAddressEthernetInfo = ethernet_info_ns.class_( - "IPAddressEthernetInfo", text_sensor.TextSensor, cg.PollingComponent + "IPAddressEthernetInfo", text_sensor.TextSensor, cg.Component ) DNSAddressEthernetInfo = ethernet_info_ns.class_( - "DNSAddressEthernetInfo", text_sensor.TextSensor, cg.PollingComponent + "DNSAddressEthernetInfo", text_sensor.TextSensor, cg.Component ) MACAddressEthernetInfo = ethernet_info_ns.class_( - "MACAddressEthernetInfo", text_sensor.TextSensor, cg.PollingComponent + "MACAddressEthernetInfo", text_sensor.TextSensor, cg.Component ) CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_IP_ADDRESS): text_sensor.text_sensor_schema( IPAddressEthernetInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ) - .extend(cv.polling_component_schema("1s")) - .extend( + ).extend( { cv.Optional(f"address_{x}"): text_sensor.text_sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, @@ -40,7 +38,7 @@ CONFIG_SCHEMA = cv.Schema( ), cv.Optional(CONF_DNS_ADDRESS): text_sensor.text_sensor_schema( DNSAddressEthernetInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ).extend(cv.polling_component_schema("1s")), + ), cv.Optional(CONF_MAC_ADDRESS): text_sensor.text_sensor_schema( MACAddressEthernetInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ), @@ -49,6 +47,12 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): + # Request Ethernet IP state listener slots - one per sensor type + if CONF_IP_ADDRESS in config: + ethernet.request_ethernet_ip_state_listener() + if CONF_DNS_ADDRESS in config: + ethernet.request_ethernet_ip_state_listener() + if conf := config.get(CONF_IP_ADDRESS): ip_info = await text_sensor.new_text_sensor(config[CONF_IP_ADDRESS]) await cg.register_component(ip_info, config[CONF_IP_ADDRESS]) @@ -57,8 +61,8 @@ async def to_code(config): sens = await text_sensor.new_text_sensor(sensor_conf) cg.add(ip_info.add_ip_sensors(x, sens)) if conf := config.get(CONF_DNS_ADDRESS): - dns_info = await text_sensor.new_text_sensor(config[CONF_DNS_ADDRESS]) - await cg.register_component(dns_info, config[CONF_DNS_ADDRESS]) + dns_info = await text_sensor.new_text_sensor(conf) + await cg.register_component(dns_info, conf) if conf := config.get(CONF_MAC_ADDRESS): - mac_info = await text_sensor.new_text_sensor(config[CONF_MAC_ADDRESS]) - await cg.register_component(mac_info, config[CONF_MAC_ADDRESS]) + mac_info = await text_sensor.new_text_sensor(conf) + await cg.register_component(mac_info, conf) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bb40cd4ad1..c229d1df7d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -238,6 +238,8 @@ #define USE_ETHERNET #define USE_ETHERNET_KSZ8081 #define USE_ETHERNET_MANUAL_IP +#define USE_ETHERNET_IP_STATE_LISTENERS +#define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2 #endif #ifdef USE_ESP32