Compare commits

...
Author SHA1 Message Date
J. Nick Koston 1031f74457 Merge branch 'dev' into wifi-manual-ip-progmem 2026-10-26 21:33:50 -10:00
J. Nick Koston b5b4a5b642 [wifi] Test the manual IP table and drop the unreachable row limit
Networks are capped at 127 in the schema, so with the AP there are at most 128 rows and the index never reaches 0xFF.
2026-10-26 18:37:07 -10:00
J. Nick Koston 8e5b65cf8a [wifi] Keep manual IP settings in flash
Each WiFiAP now stores a 1 byte row index into one PROGMEM table of all configured manual IPs instead of an optional<ManualIP> copy, saving 26 bytes per network and per fallback AP.
2026-10-26 17:54:14 -10:00
10 changed files with 136 additions and 47 deletions

No files matched your search

+33 -29
View File
@@ -1,3 +1,4 @@
from ipaddress import IPv4Address
import logging
import math
from typing import Any
@@ -19,7 +20,6 @@ from esphome.components.network import (
add_use_address,
get_network_priority,
has_high_performance_wifi,
ip_address_literal,
require_high_performance_wifi,
require_ipv4,
)
@@ -190,7 +190,6 @@ DEFAULT_DPP_TIMEOUT = "1min"
wifi_ns = cg.esphome_ns.namespace("wifi")
EAPAuth = wifi_ns.struct("EAPAuth")
ManualIP = wifi_ns.struct("ManualIP")
WiFiComponent = wifi_ns.class_("WiFiComponent", cg.Component)
WiFiAP = wifi_ns.struct("WiFiAP")
@@ -804,24 +803,20 @@ def eap_auth(config):
)
def safe_ip(ip):
return ip_address_literal(ip)
def manual_ip(config):
if config is None:
return None
return cg.StructInitializer(
ManualIP,
("static_ip", safe_ip(config[CONF_STATIC_IP])),
("gateway", safe_ip(config[CONF_GATEWAY])),
("subnet", safe_ip(config[CONF_SUBNET])),
("dns1", safe_ip(config.get(CONF_DNS1))),
("dns2", safe_ip(config.get(CONF_DNS2))),
def manual_ip_entry(config: ConfigType) -> bytes:
"""Pack manual IP settings as one MANUAL_IP_TABLE row (MANUAL_IP_ENTRY_SIZE bytes)."""
no_dns = IPv4Address(0)
addresses = (
config[CONF_STATIC_IP],
config[CONF_GATEWAY],
config[CONF_SUBNET],
config.get(CONF_DNS1, no_dns),
config.get(CONF_DNS2, no_dns),
)
return b"".join(address.packed for address in addresses)
def wifi_network(config, ap, static_ip):
def wifi_network(config, ap, manual_ip_index: int | None):
cg.add_optional(ap.set_ssid, config.get(CONF_SSID))
cg.add_optional(ap.set_password, config.get(CONF_PASSWORD))
if CONF_EAP in config:
@@ -831,8 +826,7 @@ def wifi_network(config, ap, static_ip):
cg.add(ap.set_bssid([HexInt(i) for i in config[CONF_BSSID].parts]))
cg.add_optional(ap.set_hidden, config.get(CONF_HIDDEN))
cg.add_optional(ap.set_channel, config.get(CONF_CHANNEL))
if static_ip is not None:
cg.add(ap.set_manual_ip(manual_ip(static_ip)))
cg.add_optional(ap.set_manual_ip, manual_ip_index)
# priority_ is 0 in C++; skip the setter when the config matches it.
if config.get(CONF_PRIORITY, 0) != 0:
cg.add(ap.set_priority(config[CONF_PRIORITY]))
@@ -851,8 +845,17 @@ async def to_code(config):
# Track if any network uses Enterprise authentication
has_eap = False
# Track if any network uses manual IP
has_manual_ip = False
# Rows of MANUAL_IP_TABLE; networks with the same settings share one. At most
# MAX_WIFI_NETWORKS + 1 (the AP) rows, so an index never reaches MANUAL_IP_NONE (0xFF).
manual_ip_entries: list[bytes] = []
def manual_ip_index(ip_config: ConfigType | None) -> int | None:
if ip_config is None:
return None
entry = manual_ip_entry(ip_config)
if entry not in manual_ip_entries:
manual_ip_entries.append(entry)
return manual_ip_entries.index(entry)
# Initialize FixedVector with the count of networks
networks = config.get(CONF_NETWORKS, [])
@@ -864,24 +867,20 @@ async def to_code(config):
def add_sta(ap: cg.MockObj, network: dict) -> None:
ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP))
cg.add(var.add_sta(wifi_network(network, ap, ip_config)))
cg.add(var.add_sta(wifi_network(network, ap, manual_ip_index(ip_config))))
for network in networks:
if CONF_EAP in network:
has_eap = True
if network.get(CONF_MANUAL_IP) or config.get(CONF_MANUAL_IP):
has_manual_ip = True
cg.with_local_variable(network[CONF_ID], WiFiAP(), add_sta, network)
if CONF_AP in config:
conf = config[CONF_AP]
ip_config = conf.get(CONF_MANUAL_IP)
if ip_config:
has_manual_ip = True
ap_ip_index = manual_ip_index(conf.get(CONF_MANUAL_IP))
cg.with_local_variable(
conf[CONF_ID],
WiFiAP(),
lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))),
lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ap_ip_index))),
)
# Skip the setter when the config matches the C++ initializer.
if (ap_timeout := conf[CONF_AP_TIMEOUT]) != DEFAULT_AP_TIMEOUT_PERIOD:
@@ -939,8 +938,13 @@ async def to_code(config):
require_mbedtls_tls_extras()
# Only define USE_WIFI_MANUAL_IP if any AP uses manual IP
if has_manual_ip:
if manual_ip_entries:
cg.add_define("USE_WIFI_MANUAL_IP")
cg.extern_progmem_array(
"esphome::wifi::MANUAL_IP_TABLE",
cg.uint8,
list(b"".join(manual_ip_entries)),
)
# The C++ initializers are DEFAULT_REBOOT_TIMEOUT, power save NONE and minimum
# auth WPA2; skip the setters when the config matches them.
+16 -7
View File
@@ -1360,17 +1360,15 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) {
ESP_LOGV(TAG, " Channel not set");
}
#ifdef USE_WIFI_MANUAL_IP
auto manual_ip = ap.get_manual_ip();
if (manual_ip.has_value()) {
ManualIP m = *manual_ip;
if (const auto m = ap.get_manual_ip(); m.has_value()) {
char static_ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE];
char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE];
ESP_LOGV(TAG, " Manual IP: Static IP=%s Gateway=%s Subnet=%s DNS1=%s DNS2=%s", m.static_ip.str_to(static_ip_buf),
m.gateway.str_to(gateway_buf), m.subnet.str_to(subnet_buf), m.dns1.str_to(dns1_buf),
m.dns2.str_to(dns2_buf));
ESP_LOGV(TAG, " Manual IP: Static IP=%s Gateway=%s Subnet=%s DNS1=%s DNS2=%s", m->static_ip.str_to(static_ip_buf),
m->gateway.str_to(gateway_buf), m->subnet.str_to(subnet_buf), m->dns1.str_to(dns1_buf),
m->dns2.str_to(dns2_buf));
} else
#endif
{
@@ -1908,7 +1906,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
#if defined(USE_ESP8266) && defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
// On ESP8266, GOT_IP event may not fire for static IP configurations,
// so notify IP state listeners here as a fallback.
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_manual_ip()) {
this->notify_ip_state_listeners_();
}
#endif
@@ -2881,6 +2879,17 @@ void WiFiComponent::process_roaming_scan_() {
this->start_connecting(roam_params);
}
#ifdef USE_WIFI_MANUAL_IP
optional<ManualIP> WiFiAP::get_manual_ip() const {
if (!this->has_manual_ip())
return {};
uint8_t b[MANUAL_IP_ENTRY_SIZE];
progmem_memcpy(b, MANUAL_IP_TABLE + this->manual_ip_index_ * MANUAL_IP_ENTRY_SIZE, sizeof(b));
auto ip = [&b](size_t i) { return network::IPAddress(b[i], b[i + 1], b[i + 2], b[i + 3]); };
return ManualIP{ip(0), ip(4), ip(8), ip(12), ip(16)};
}
#endif
WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::wifi
+17 -7
View File
@@ -188,6 +188,14 @@ struct ManualIP {
network::IPAddress dns2; ///< The second DNS server. 0.0.0.0 for default.
};
#ifdef USE_WIFI_MANUAL_IP
/// One MANUAL_IP_TABLE row: static IP, gateway, subnet, dns1 and dns2 as 4 octets each.
static constexpr size_t MANUAL_IP_ENTRY_SIZE = 20;
static constexpr uint8_t MANUAL_IP_NONE = 0xFF;
/// Every configured manual IP, generated by codegen (components/wifi/__init__.py) and kept in flash.
extern const uint8_t MANUAL_IP_TABLE[] PROGMEM;
#endif
#ifdef USE_WIFI_WPA2_EAP
struct EAPAuth {
std::string identity; // required for all auth types
@@ -300,7 +308,8 @@ class WiFiAP {
void clear_channel() { this->channel_ = 0; }
void set_priority(int8_t priority) { priority_ = priority; }
#ifdef USE_WIFI_MANUAL_IP
void set_manual_ip(optional<ManualIP> manual_ip) { this->manual_ip_ = manual_ip; }
/// index is a row of MANUAL_IP_TABLE
void set_manual_ip(uint8_t index) { this->manual_ip_index_ = index; }
#endif
void set_hidden(bool hidden) { this->hidden_ = hidden; }
StringRef get_ssid() const { return this->ssid_.ref(); }
@@ -314,7 +323,8 @@ class WiFiAP {
bool has_channel() const { return this->channel_ != 0; }
int8_t get_priority() const { return priority_; }
#ifdef USE_WIFI_MANUAL_IP
const optional<ManualIP> &get_manual_ip() const { return this->manual_ip_; }
bool has_manual_ip() const { return this->manual_ip_index_ != MANUAL_IP_NONE; }
optional<ManualIP> get_manual_ip() const;
#endif
bool get_hidden() const { return this->hidden_; }
@@ -324,14 +334,14 @@ class WiFiAP {
#ifdef USE_WIFI_WPA2_EAP
optional<EAPAuth> eap_;
#endif // USE_WIFI_WPA2_EAP
#ifdef USE_WIFI_MANUAL_IP
optional<ManualIP> manual_ip_;
#endif
// Group small types together to minimize padding
bssid_t bssid_{}; // 6 bytes, all zeros = any/not set
uint8_t channel_{0}; // 1 byte, 0 = auto/not set
int8_t priority_{0}; // 1 byte
bool hidden_{false}; // 1 byte; WiFiAP is byte aligned unless manual IP or EAP adds a 4 byte aligned member
bool hidden_{false}; // 1 byte; WiFiAP is byte aligned unless EAP adds a 4 byte aligned member
#ifdef USE_WIFI_MANUAL_IP
uint8_t manual_ip_index_{MANUAL_IP_NONE};
#endif
};
class WiFiScanResult {
@@ -1050,7 +1060,7 @@ class WiFiComponent final : public Component {
uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed
#ifdef USE_WIFI_AP
uint32_t ap_timeout_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py
// WiFiAP is byte aligned unless manual IP or EAP is enabled; placed before the
// WiFiAP is byte aligned unless EAP is enabled; placed before the
// 1-byte members so they pack into its trailing bytes instead of padding after it
WiFiAP ap_;
#endif // USE_WIFI_AP
@@ -795,7 +795,7 @@ void WiFiComponent::save_lease_settings_() {
return;
#ifdef USE_WIFI_MANUAL_IP
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value())
if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_manual_ip())
return;
#endif
@@ -1093,7 +1093,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
bool static_ip = false;
#ifdef USE_WIFI_MANUAL_IP
const WiFiAP *config = this->get_selected_sta_();
static_ip = config && config->get_manual_ip().has_value();
static_ip = config && config->has_manual_ip();
#endif
#ifdef USE_WIFI_LEASE_CACHE
static_ip = static_ip || this->applied_cached_lease_;
@@ -510,7 +510,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
#endif
// For static IP configurations, GOT_IP event may not fire, so set connected state here
#ifdef USE_WIFI_MANUAL_IP
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_manual_ip()) {
this->sta_state_ = static_cast<uint8_t>(LTWiFiSTAState::CONNECTED);
#ifdef USE_WIFI_IP_STATE_LISTENERS
this->notify_ip_state_listeners_();
@@ -342,7 +342,7 @@ bool WiFiComponent::wifi_loop_() {
#endif
// For static IP configurations, notify IP listeners immediately as the IP is already configured
#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_manual_ip()) {
s_sta_had_ip = true;
this->notify_ip_state_listeners_();
}
@@ -0,0 +1,32 @@
esphome:
name: test
esp8266:
board: d1_mini
wifi:
# Inherited by every network without its own manual_ip
manual_ip:
static_ip: 192.168.2.20
gateway: 192.168.2.1
subnet: 255.255.255.0
dns1: 192.168.2.1
networks:
- ssid: own
password: password1
manual_ip:
static_ip: 192.168.1.10
gateway: 192.168.1.1
subnet: 255.255.255.0
dns1: 1.1.1.1
dns2: 8.8.8.8
- ssid: inherits1
password: password2
- ssid: inherits2
password: password3
ap:
password: fallback_password
manual_ip:
static_ip: 192.168.4.1
gateway: 192.168.4.1
subnet: 255.255.255.0
@@ -0,0 +1,25 @@
"""Tests for the manual IP table wifi codegen emits (MANUAL_IP_TABLE in wifi_component.h)."""
from collections.abc import Callable
from pathlib import Path
import re
def test_manual_ip_rows_are_shared(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Networks with the same settings share a row; the AP row packs its missing DNS as zeros."""
main_cpp = generate_main(component_config_path("manual_ip.yaml"))
own = "192, 168, 1, 10, 192, 168, 1, 1, 255, 255, 255, 0, 1, 1, 1, 1, 8, 8, 8, 8"
inherited = (
"192, 168, 2, 20, 192, 168, 2, 1, 255, 255, 255, 0, 192, 168, 2, 1, 0, 0, 0, 0"
)
ap = "192, 168, 4, 1, 192, 168, 4, 1, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0"
assert (
f"esphome::wifi::MANUAL_IP_TABLE[] PROGMEM = {{{own}, {inherited}, {ap}}};"
in main_cpp
)
# Three networks in config order, then the AP
assert re.findall(r"set_manual_ip\((\d+)\)", main_cpp) == ["0", "1", "1", "2"]
+5
View File
@@ -42,6 +42,11 @@ wifi:
- ssid: MySSID2
password: password2
priority: 5
manual_ip:
static_ip: 192.168.2.100
gateway: 192.168.2.1
subnet: 255.255.255.0
dns1: 192.168.2.1
- ssid: MySSID3
password: password3
priority: 0
@@ -5,6 +5,10 @@ wifi:
ap:
ssid: Fallback AP
password: fallback_password
manual_ip:
static_ip: 192.168.4.1
gateway: 192.168.4.1
subnet: 255.255.255.0
packages:
- !include common.yaml