Complete Phase 12 dual-stack networking

Add IPv6-aware Wi-Fi state, HTTPS/SSH listeners, mDNS service
reconciliation, and browser Wi-Fi administration.

Include a guarded build-local fix for mDNS 1.12.0 membership handling,
focused regression suites, and Phase 12 acceptance documentation.
This commit is contained in:
2026-09-20 22:35:34 +02:00
parent ece4ba77e3
commit 8902b25d78
52 changed files with 3042 additions and 163 deletions
+47
View File
@@ -0,0 +1,47 @@
# Wi-Fi Phase 12 focused host regression
Run `python3 tests/wifi_phase12/run.py`. The renderer tests use the installed public `esp_netif_ip_addr.h` at `~/.platformio/packages/framework-espidf/components/esp_netif/include/` with host endian/config shims (no SDK modification). Optional `--sanitize` requires host ASan/UBSan runtime libraries. No PlatformIO build or device access is performed. Compilation/executables use a temporary directory.
The harness extracts production hostname, address reconciliation, IP callback, and deadline functions verbatim. Real manager/config headers define the data contracts. Host fakes model driver association, netif-up state, IPv6 slot states, TCP/IP execution, and clock; surrounding failover/driver operations are call-count fakes, not a full scheduler/network simulation. Wiring assertions cover pre-connect hostname installation and message dispatch.
Coverage includes:
- Configured hostname and rename without disconnect; full 59-byte hostname; config-read failure.
- STA-only IPv6 event filtering, null events, and IPv4 netif filtering.
- Explicit STA SLAAC enablement, link-local creation, no repeated tentative/duplicate DAD.
- Link-local-only, ULA/GUA, IPv4-only, and dual-stack readiness.
- IPv4 loss while IPv6 remains, IPv6 deprecation/removal and restoration.
- No IPv4 deadline failover on usable IPv6; no perpetual fallback stability reset.
- Dropped address/association events, dropped online disconnect, recreated LL after missed disconnect/reconnect, and stale events during stop/profile advance.
- Permanent owner wakeup, including boot-disabled and stopped Wi-Fi; offline service withdrawal, failed record updates and pending reannouncement retries at one-second cadence.
- Busy-loop calls do not repeat periodic work; persistent mDNS failure does not generate per-pass warnings.
- Permanent hostname pointer lifetime, failed offline rename retry, and mutex/TCP-IP-context assertions for address and hostname access.
- Address-read failure/uncertain callback completion withdraws stale readiness without retiring the recovery AP; later successful reads restore readiness.
- Verbatim production console rendering of simultaneous preferred link-local, ULA and GUA addresses, with non-symmetric network-order words and IDF public formatting macros; client-interface zone guidance never renders the ESP32 zone.
- Full numeric list and count clearing on failed callback admission, skipped callback, IP-info error, netif-down and the production disconnect-clear helper; wiring assertions require intentional disconnect, stop and disconnect-event paths to call that helper. Tentative, duplicate and deprecated slots are excluded by the preferred-getter fake matching SDK semantics.
- Increasing configured SDK slots to four fails the production compile-time capacity assertion.
## Contract and SDK evidence
`ONLINE` means a current association/netif with IPv4 or a **preferred** IPv6 link-local/ULA/GUA address. It is not an Internet/default-route check. Deprecated-only addresses do not qualify for new service readiness. In particular, link-local-only operation suppresses DHCPv4-driven profile failover and can retire the fallback AP after the existing stability interval. IPv6 link-local clients need an interface scope. Snapshot IPv4 fields remain zero on IPv6-only networks; `ipv6_linklocal` / `ipv6_routable` booleans report preferred-address availability, not routing success. The console also lists up to three numeric preferred IPv6 addresses from the exact same TCP/IP observation as those flags. Count and all address storage are published/cleared together under the manager mutex. The configured lwIP slot count must not exceed three (compile-time contract, no silent truncation). Web/OLED continue consuming the existing flags without adding list output.
Inspected installed ESP-IDF 5.5.0 sources:
- `esp_netif/lwip/esp_netif_lwip.c`: netif-up and address getters access lwIP directly; all manager netif-up/address checks now execute inside the TCP/IP callback. Preferred getter excludes tentative/duplicate/deprecated/invalid addresses. Disconnect invalidates/clears IPv6 slots. `esp_netif_tcpip_exec` supplies synchronous TCP/IP context, with no retained stack request on return. Its IDF 5.5 wrapper ignores the underlying `tcpip_send_msg_wait_sem` result: explicit completion markers therefore detect a callback that was never executed despite an apparent `ESP_OK`. No hard wall-clock guarantee is made if upstream stalls.
- `lwip/port/include/lwipopts.h`, `lwip/src/include/lwip/opt.h`, and `esp_netif_start_api`: saved `CONFIG_LWIP_IPV6_AUTOCONFIG` is disabled, but lwIP SLAAC remains compiled via `LWIP_IPV6_AUTOCONFIG`. Explicit per-STA `netif_set_ip6_autoconfig_enabled` is needed; AP policy is untouched.
- `esp_netif_set_hostname_api` has a 32-byte limit, below the existing configured hostname maximum of 59. The manager therefore installs permanent bounded storage through `netif_set_hostname` in TCP/IP context, without transferring ownership to esp-netif. `netif_add` does not clear the hostname; `esp_netif/lwip/netif/wlanif.c` preserves it via `esp_netif_get_hostname` during initialization.
- `lwip/src/core/ipv4/dhcp.c`: option 12 is read from the current netif hostname in subsequent outgoing DHCP exchanges (request/renew/rebind included). Rename does not force release/reacquisition or promise immediate router/DNS cache replacement.
Reverify these boundaries for an SDK upgrade. Tests do not emulate DHCP wire packets, router advertisements, actual DAD timers, mDNS component behavior, or socket listeners. A target syntax check is not a linked firmware build. Device and integration validation remain separate.
## Coordination
The owner calls the new `mdns_service_reconcile` every second even offline/stopped. A pending online reannouncement uses `mdns_service_reannounce`, which also reconciles records. Listener owners' lock-free availability stores need no queue wakeup; changes and failures converge on subsequent passes. Reconciliation never initializes the responder; startup still requires either-family STA readiness. Failures remain nonfatal, visible through service error status, and do not log each retry. Upstream mDNS calls may block: the cadence bounds attempt frequency, not upstream execution time.
`wifi_manager_mdns_reannounce` also refreshes DHCP option 12 while offline. Periodic hostname refresh retries failed/missed DHCP-name changes without reconnecting. The permanent hostname buffer is only read/written in TCP/IP context; no application status caller borrows that pointer (use the copied mDNS snapshot). Pending mDNS rename errors retry online. Other owners should consume the IPv6 snapshot flags rather than infer readiness from `snapshot.ip != 0`.
## Diagnostic addition resource budget and handoff
The numeric payload is three arrays of four network-order `uint32_t` words (48 bytes), plus a one-byte count that fits existing snapshot padding. No strings, device zones, task, socket, heap allocation or queue-message growth are introduced. Host layout measurements with production headers: snapshot 232 → 280 bytes; settings projection 424 → 472 bytes; manager shared storage 760 → 808 bytes; temporary address observation 16 → 64 bytes. Thus the manager's static storage grows by 48 bytes, and each existing snapshot/settings copy (console, web, local UI) grows by 48 bytes. The owner observation adds 48 bytes of transient stack payload; the existing three-entry SDK enumeration scratch is unchanged. Actual compiler stack-frame/high-water effects and linked target RAM remain for the parent's build/device checks; host sizes are not runtime headroom evidence.
Scoped validation: normal host suite and slot-cap negative compile PASS. `--sanitize` could not link on this host because `libasan.so.8.0.0` / `libubsan.so.1.0.0` are missing. No dependency changes, PlatformIO build or hardware operation performed. Parent owns durable documentation updates (the flags-only statements in agent memory/command docs are now stale for console status) and firmware build. Production edits are limited to `src/wifi_manager.c`, `src/wifi_manager.h`, and `src/wifi_console.c`; tests/docs for this addition stay here.
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Compile verbatim manager address/deadline paths with bounded host fakes."""
from pathlib import Path
import subprocess
import tempfile
import sys
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
source = (ROOT / 'src/wifi_manager.c').read_text()
def function(name):
# Definitions end at a column-zero brace; skip forward declarations.
import re
match = re.search(r'^static [^\n]+\b' + name + r'\([^;]*?\)\n\{', source, re.M)
assert match, name
start = match.start()
return source[start:source.index('\n}', match.end()) + 2]
types = source[source.index('typedef enum {'):source.index('static SemaphoreHandle_t')]
addresses = source[source.index('typedef struct {\n esp_netif_ip_info_t ip4;'):source.index('/* IDF\'s IPv6 getters')]
hostname_request = source[source.index('typedef struct {\n const char *hostname;'):source.index('static esp_err_t set_station_hostname')]
names = ['set_station_hostname', 'refresh_station_hostname', 'start_mdns_announcement',
'read_station_addresses', 'handle_got_ip', 'clear_station_network_snapshot',
'handle_expired_deadlines', 'next_runtime_deadline', 'runtime_wait_ticks',
'ip_event_callback']
# Check wiring not present in the extracted paths.
connect = function('start_next_profile')
assert connect.index('refresh_station_hostname()') < connect.index('esp_wifi_connect()')
message = function('handle_message')
assert 'case MESSAGE_STA_GOT_IP6:' in message
assert 'refresh_station_hostname()' in message
assert 'handle_got_ip(runtime, message);' in message
assert '#define WIFI_MANAGER_QUEUE_LENGTH 16U' in source
assert '#define WIFI_MANAGER_TASK_STACK_SIZE 6144U' in source
assert 'esp_netif_tcpip_exec(read_station_addresses, &addresses)' in source
assert 'esp_netif_is_netif_up' not in function('handle_got_ip')
assert 'mdns_service_reconcile()' in function('handle_expired_deadlines')
assert 'runtime->radio_started ? runtime->reconcile_deadline' not in source
for name in ['handle_sta_disconnected', 'mark_intentional_disconnect', 'stop_radio']:
assert 'clear_station_network_snapshot();' in function(name)
with tempfile.TemporaryDirectory(prefix='wifi-phase12-') as directory:
tmp = Path(directory)
(tmp / 'esp_err.h').write_text('#pragma once\ntypedef int esp_err_t;\n#define ESP_OK 0\n#define ESP_FAIL -1\n#define ESP_ERR_INVALID_STATE 1\n#define ESP_ERR_INVALID_ARG 2\n')
(tmp / 'esp_wifi_types.h').write_text('#pragma once\ntypedef int wifi_auth_mode_t;\n')
# Use the installed IDF public address types/macros, not a reimplementation.
sdk_header = Path.home() / '.platformio/packages/framework-espidf/components/esp_netif/include/esp_netif_ip_addr.h'
(tmp / 'esp_netif_ip_addr.h').write_text(sdk_header.read_text())
(tmp / 'machine').mkdir()
(tmp / 'machine/endian.h').write_text('#include <endian.h>\n')
(tmp / 'sdkconfig.h').write_text('#define CONFIG_LWIP_IPV6 1\n')
(tmp / 'types.inc').write_text(types + addresses + hostname_request)
console = (ROOT / 'src/wifi_console.c').read_text()
start = console.index('static void print_ipv6_addresses(')
(tmp / 'console.inc').write_text(console[start:console.index('\n}', start) + 2])
assert 'print_ipv6_addresses(&snapshot);' in console
(tmp / 'production.inc').write_text('\n\n'.join(function(name) for name in names))
sanitizer = ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] if '--sanitize' in sys.argv else []
subprocess.run(['cc', '-std=gnu17', '-Wall', '-Wextra', '-Werror', *sanitizer,
'-I' + str(tmp), '-I' + str(ROOT / 'src'), str(HERE / 'test.c'),
'-o', str(tmp / 'test')], check=True, timeout=30)
subprocess.run([str(tmp / 'test')], check=True, timeout=20)
# Increasing SDK slots must fail compilation rather than truncate/overrun.
oversized = (HERE / 'test.c').read_text().replace('#define LWIP_IPV6_NUM_ADDRESSES 3', '#define LWIP_IPV6_NUM_ADDRESSES 4')
(tmp / 'oversized.c').write_text(oversized)
result = subprocess.run(['cc', '-std=gnu17', '-I' + str(tmp), '-I' + str(ROOT / 'src'),
'-fsyntax-only', str(tmp / 'oversized.c')], capture_output=True, text=True)
assert result.returncode != 0 and 'IPv6 snapshot capacity' in result.stderr
print('IPv6 slot-capacity compile contract PASS')
header = (ROOT / 'src/wifi_manager.h').read_text()
baseline = header.replace(' uint8_t ipv6_count;\n', '').replace(
' wifi_manager_ipv6_address_t ipv6_addresses[WIFI_MANAGER_IPV6_MAX_ADDRESSES];\n', '')
(tmp / 'baseline.h').write_text(baseline)
(tmp / 'sizes.c').write_text('#include <stdio.h>\n#include "baseline.h"\n'
'int main(void) { printf("baseline snapshot=%zu settings=%zu bytes\\n", '
'sizeof(wifi_manager_snapshot_t), sizeof(wifi_manager_settings_t)); }\n')
subprocess.run(['cc', '-I' + str(tmp), '-I' + str(ROOT / 'src'), str(tmp / 'sizes.c'),
'-o', str(tmp / 'sizes')], check=True, timeout=30)
subprocess.run([str(tmp / 'sizes')], check=True, timeout=20)
+332
View File
@@ -0,0 +1,332 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <stdarg.h>
#include "esp_netif_ip_addr.h"
#define WIFI_AUTH_OPEN 0
#include "wifi_manager.h"
#include "mdns_service.h"
#define LWIP_NETIF_HOSTNAME 1
#define CONFIG_LWIP_IPV6 1
static char s_station_hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U];
#define LWIP_IPV6_AUTOCONFIG 1
#define LWIP_IPV6_NUM_ADDRESSES 3
#define IP6_ADDR_INVALID 0
#define TENTATIVE 1
#define PREFERRED 2
#define DEPRECATED 3
#define DUPLICATE 4
#define WIFI_MANAGER_ATTEMPT_US 12000000LL
#define WIFI_MANAGER_STABLE_US 30000000LL
#define WIFI_MANAGER_RECONCILE_US 1000000LL
#define WIFI_MANAGER_DISCONNECT_SETTLE_US 1000000LL
#define WIFI_MANAGER_INITIAL_BACKOFF_SECONDS 2
#define ESP_LOGW(...) (++warnings)
#define portMAX_DELAY UINT32_MAX
#define pdMS_TO_TICKS(x) (x)
typedef uint32_t TickType_t;
typedef struct { uint32_t addr; } ip4_t;
typedef struct { ip4_t ip, netmask, gw; } esp_netif_ip_info_t;
typedef struct { uint8_t ssid[33], primary; int8_t rssi; int authmode; } wifi_ap_record_t;
typedef struct { int unused; } esp_netif_t;
static esp_netif_t sta, other;
static esp_netif_t *s_sta_netif = &sta;
typedef const char *esp_event_base_t;
static const char *IP_EVENT = "ip";
enum { IP_EVENT_STA_GOT_IP, IP_EVENT_STA_LOST_IP, IP_EVENT_GOT_IP6 };
typedef struct { esp_netif_t *esp_netif; esp_netif_ip_info_t ip_info; } ip_event_got_ip_t;
typedef struct { esp_netif_t *esp_netif; } ip_event_got_ip6_t;
#include "types.inc"
static manager_shared_t s_shared;
struct netif { int state[3]; bool autoconfig; esp_ip6_addr_t addresses[3]; const char *hostname; };
static struct netif netif;
static bool up, associated, matching, in_tcpip, byte_ssid;
static int64_t now;
static esp_netif_ip_info_t ip4;
static int mdns_starts, mdns_stops, disconnects, next_profiles, cycles, ap_disables;
static int creations, messages, mdns_reconciles, mdns_reannounces, tcpip_calls;
static int shared_locks, warnings;
static esp_err_t tcpip_error, mdns_error, ip_info_error;
static bool https_available, https_registered, tcpip_skip;
static manager_message_t last_message;
static char hostname[64], configured[64];
static esp_err_t hostname_error;
static void lock_shared(void) { assert(shared_locks == 0); ++shared_locks; }
static void unlock_shared(void) { assert(shared_locks == 1); --shared_locks; }
static bool manager_is_started(void) { return s_shared.snapshot.started; }
static void set_state(wifi_manager_state_t state) { s_shared.snapshot.state = state; }
static void set_last_error(esp_err_t error) { s_shared.snapshot.last_error = error; }
static int64_t esp_timer_get_time(void) { return now; }
static bool esp_netif_is_netif_up(esp_netif_t *n) { assert(n == &sta && in_tcpip); return up; }
static esp_err_t esp_netif_get_ip_info(esp_netif_t *n, esp_netif_ip_info_t *out)
{ assert(n == &sta && in_tcpip); *out = ip4; return ip_info_error; }
static void *esp_netif_get_netif_impl(esp_netif_t *n)
{ assert(n == &sta && in_tcpip); return &netif; }
static void netif_set_ip6_autoconfig_enabled(struct netif *n, int enabled)
{ assert(in_tcpip); n->autoconfig = enabled; }
static int netif_ip6_addr_state(struct netif *n, int slot) { return n->state[slot]; }
static void netif_create_ip6_linklocal_address(struct netif *n, int mac)
{ assert(in_tcpip && mac == 1); ++creations; n->state[0] = TENTATIVE; }
static int esp_netif_get_all_preferred_ip6(esp_netif_t *n, esp_ip6_addr_t *out)
{
assert(n == &sta && in_tcpip);
int count = 0;
for (int i = 0; i < 3; ++i) if (netif.state[i] == PREFERRED) out[count++] = netif.addresses[i];
return count;
}
static esp_err_t esp_netif_tcpip_exec(esp_err_t (*fn)(void *), void *context)
{ assert(!in_tcpip && shared_locks == 0); ++tcpip_calls;
if (tcpip_error != ESP_OK) return tcpip_error;
if (tcpip_skip) return ESP_OK; /* IDF wrapper ignores failed enqueue. */
in_tcpip = true; esp_err_t e = fn(context); in_tcpip = false; return e; }
static esp_err_t esp_wifi_sta_get_ap_info(wifi_ap_record_t *out)
{ memset(out, 0, sizeof(*out)); memcpy(out->ssid, "test", 4);
if (byte_ssid) out->ssid[1] = 0;
return associated ? ESP_OK : ESP_FAIL; }
static bool connected_event_matches_active_profile(const manager_message_t *m)
{ assert(m->data.connected.ssid_len == 4); return matching; }
static void copy_working_config(wifi_app_config_t *out) { *out = s_shared.config; }
void wifi_config_secure_wipe(void *p, size_t n) { memset(p, 0, n); }
esp_err_t mdns_service_start(void) { ++mdns_starts; return mdns_error; }
esp_err_t mdns_service_reconcile(void)
{
assert(!in_tcpip && shared_locks == 0);
++mdns_reconciles;
if (mdns_error == ESP_OK) https_registered = https_available;
return mdns_error;
}
esp_err_t mdns_service_reannounce(void)
{ ++mdns_reannounces; return mdns_service_reconcile(); }
void mdns_service_stop(void) { ++mdns_stops; }
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *out)
{ memset(out, 0, sizeof(*out)); strcpy(out->hostname, configured); return hostname_error; }
static void netif_set_hostname(struct netif *n, const char *name)
{ assert(n == &netif && in_tcpip); n->hostname = name; strcpy(hostname, name); }
static void handle_sta_disconnected(manager_runtime_t *r, const manager_message_t *m)
{ (void)m; r->online = r->associated = false; ++disconnects; }
static void mark_intentional_disconnect(manager_runtime_t *r)
{ ++disconnects; ++r->intentional_disconnects; r->online = r->associated = false; }
static void start_next_profile(manager_runtime_t *r) { (void)r; ++next_profiles; }
static void start_profile_cycle(manager_runtime_t *r) { (void)r; ++cycles; }
static void start_radio_and_policy(manager_runtime_t *r) { (void)r; }
static esp_err_t esp_wifi_disconnect(void) { ++disconnects; return ESP_OK; }
static esp_err_t set_runtime_ap_enabled(manager_runtime_t *r, bool enable)
{ r->ap_enabled = enable; if (!enable) ++ap_disables; return ESP_OK; }
static bool enqueue_message(const manager_message_t *m) { last_message = *m; ++messages; return true; }
#include "production.inc"
static char rendered[2048];
static int capture_printf(const char *format, ...)
{
size_t used = strlen(rendered);
va_list args;
va_start(args, format);
int n = vsnprintf(rendered + used, sizeof(rendered) - used, format, args);
va_end(args);
assert(n >= 0 && (size_t)n < sizeof(rendered) - used);
return n;
}
#define printf capture_printf
#include "console.inc"
#undef printf
static void assert_empty_addresses(void)
{
const wifi_manager_ipv6_address_t zero[WIFI_MANAGER_IPV6_MAX_ADDRESSES] = {0};
assert(s_shared.snapshot.ipv6_count == 0);
assert(!s_shared.snapshot.ipv6_linklocal && !s_shared.snapshot.ipv6_routable);
assert(memcmp(zero, s_shared.snapshot.ipv6_addresses, sizeof(zero)) == 0);
}
static manager_runtime_t reset(void)
{
memset(&s_shared, 0, sizeof(s_shared)); memset(&netif, 0, sizeof(netif));
memset(&ip4, 0, sizeof(ip4));
up = associated = matching = true; now = 1000000; byte_ssid = false;
mdns_starts = mdns_stops = disconnects = next_profiles = cycles = ap_disables = 0;
creations = messages = mdns_reconciles = mdns_reannounces = tcpip_calls = 0;
hostname_error = tcpip_error = mdns_error = ip_info_error = ESP_OK;
warnings = 0;
https_available = https_registered = tcpip_skip = false;
strcpy(configured, "sak-test");
s_shared.snapshot.started = true;
s_shared.snapshot.sta_ssid_len = 4;
s_shared.config.ap_policy = WIFI_CONFIG_AP_POLICY_FALLBACK;
manager_runtime_t r = { .radio_started = true, .attempt_deadline = now + 12000000 };
return r;
}
static void address(int slot, uint8_t first, uint8_t second, int state)
{
memset(&netif.addresses[slot], 0, sizeof(netif.addresses[slot]));
uint8_t *bytes = (uint8_t *)netif.addresses[slot].addr;
bytes[0] = first; bytes[1] = second; bytes[15] = 1;
netif.state[slot] = state;
}
int main(void)
{
manager_runtime_t r = reset();
strcpy(configured, "sak-first"); assert(refresh_station_hostname() == ESP_OK);
assert(!strcmp(hostname, "sak-first"));
strcpy(configured, "sak-renamed"); assert(refresh_station_hostname() == ESP_OK);
assert(!strcmp(hostname, "sak-renamed") && disconnects == 0);
memset(configured, 'a', 59); memcpy(configured, "sak-", 4); configured[59] = 0;
assert(refresh_station_hostname() == ESP_OK && strlen(hostname) == 59);
assert(netif.hostname == s_station_hostname); /* No captured stack pointer. */
assert(!strcmp(netif.hostname, configured));
hostname_error = ESP_FAIL; assert(refresh_station_hostname() == ESP_FAIL);
r = reset(); byte_ssid = true; ip4.ip.addr = 7;
handle_got_ip(&r, NULL); assert(r.online); /* Embedded NUL keeps profile length. */
r = reset(); handle_got_ip(&r, NULL);
assert(netif.autoconfig && creations == 1 && !r.online);
handle_got_ip(&r, NULL); assert(creations == 1); /* Do not restart DAD. */
address(0, 0xfe, 0x80, PREFERRED); now = r.attempt_deadline;
handle_expired_deadlines(&r); /* dropped CONNECTED and GOT_IP6 */
assert(r.online && r.attempt_deadline == 0 && disconnects == 0);
assert(s_shared.snapshot.ipv6_linklocal && s_shared.snapshot.ip == 0);
assert(mdns_starts == 1);
int64_t stable = r.stable_deadline;
now += 1000000; handle_expired_deadlines(&r);
assert(r.stable_deadline == stable && mdns_starts == 1);
now = stable; handle_expired_deadlines(&r); assert(ap_disables == 1);
assert(runtime_wait_ticks(&r) <= 1000); /* Never sleep indefinitely online. */
address(1, 0xfd, 0x12, PREFERRED); handle_got_ip(&r, NULL);
assert(s_shared.snapshot.ipv6_routable);
ip4.ip.addr = 123; handle_got_ip(&r, NULL);
assert(s_shared.snapshot.ip == 123 && s_shared.snapshot.counters.got_ip == 1);
ip4.ip.addr = 0; handle_got_ip(&r, NULL);
assert(r.online && !s_shared.snapshot.ip && mdns_stops == 0);
address(0, 0xfe, 0x80, DEPRECATED); address(1, 0xfd, 0x12, DEPRECATED);
now += 1000000; handle_expired_deadlines(&r);
assert(!r.online && !s_shared.snapshot.ipv6_linklocal && !s_shared.snapshot.ipv6_routable);
assert(mdns_stops == 1 && r.attempt_deadline > now);
address(1, 0x20, 0x01, PREFERRED); handle_got_ip(&r, NULL);
assert(r.online && s_shared.snapshot.ipv6_routable && mdns_starts == 2);
r = reset(); address(0, 0xfe, 0x80, DUPLICATE);
now = r.attempt_deadline; handle_expired_deadlines(&r);
assert(!r.online && creations == 0 && disconnects == 1);
assert(r.advance_after_disconnect && r.disconnect_deadline > now);
r = reset(); address(0, 0xfe, 0x80, TENTATIVE); ip4.ip.addr = 7;
handle_got_ip(&r, NULL); assert(r.online && !s_shared.snapshot.ipv6_linklocal);
ip4.ip.addr = 0; associated = false;
now += 1000000; handle_expired_deadlines(&r); assert(disconnects == 1 && !r.online);
r = reset(); address(0, 0xfe, 0x80, PREFERRED);
r.stop_pending = true; handle_got_ip(&r, NULL); assert(!r.online);
r.stop_pending = false; r.advance_after_disconnect = true;
handle_got_ip(&r, NULL); assert(!r.online);
r.advance_after_disconnect = false; matching = false;
handle_got_ip(&r, NULL); assert(!r.online);
matching = true; up = false; handle_got_ip(&r, NULL); assert(!r.online);
up = true; handle_got_ip(&r, NULL); assert(r.online);
netif.state[0] = IP6_ADDR_INVALID; /* missed disconnect+connect: re-create LL */
handle_got_ip(&r, NULL); assert(creations == 1 && !r.online);
ip_event_got_ip6_t event6 = { .esp_netif = &other };
ip_event_callback(NULL, IP_EVENT, IP_EVENT_GOT_IP6, &event6); assert(messages == 0);
event6.esp_netif = &sta;
ip_event_callback(NULL, IP_EVENT, IP_EVENT_GOT_IP6, &event6);
assert(messages == 1 && last_message.type == MESSAGE_STA_GOT_IP6);
ip_event_got_ip_t event4 = { .esp_netif = &other };
ip_event_callback(NULL, IP_EVENT, IP_EVENT_STA_GOT_IP, &event4); assert(messages == 1);
ip_event_callback(NULL, IP_EVENT, IP_EVENT_GOT_IP6, NULL); assert(messages == 1);
r = reset(); mdns_error = ESP_FAIL;
start_mdns_announcement(&r); start_mdns_announcement(&r);
assert(warnings == 1 && r.mdns_reannounce_pending);
for (int i = 0; i < 5; ++i) {
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
}
assert(warnings == 1); /* No per-pass warning on persistent failure. */
r = reset(); r.radio_started = false; r.attempt_deadline = 0;
s_shared.snapshot.started = false; associated = up = false;
assert(runtime_wait_ticks(&r) == 0); /* Boot with Wi-Fi disabled. */
https_registered = true;
handle_expired_deadlines(&r);
assert(mdns_reconciles == 1 && !https_registered && creations == 0);
assert(runtime_wait_ticks(&r) == 1000);
for (int i = 0; i < 100; ++i) handle_expired_deadlines(&r);
assert(mdns_reconciles == 1); /* Busy owner queue cannot cause retry spam. */
mdns_error = ESP_FAIL; https_available = true;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(mdns_reconciles == 2 && !https_registered);
mdns_error = ESP_OK;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(mdns_reconciles == 3 && https_registered && !r.radio_started);
/* Failed offline rename is retried without a lifecycle command. */
tcpip_error = ESP_FAIL; strcpy(configured, "sak-retry");
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(strcmp(hostname, configured) != 0);
tcpip_error = ESP_OK;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(!strcmp(hostname, configured) && netif.hostname == s_station_hostname);
assert(disconnects == 0 && !r.radio_started);
r = reset(); address(0, 0xfe, 0x80, PREFERRED);
handle_got_ip(&r, NULL); assert(r.online);
r.mdns_reannounce_pending = true; mdns_error = ESP_FAIL;
handle_expired_deadlines(&r);
assert(r.mdns_reannounce_pending && mdns_reannounces == 1);
mdns_error = ESP_OK;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(!r.mdns_reannounce_pending && mdns_reannounces == 2);
tcpip_error = ESP_FAIL; now = r.stable_deadline;
handle_expired_deadlines(&r);
assert(!r.online && !s_shared.snapshot.ipv6_linklocal && ap_disables == 0);
assert(mdns_stops == 1 && r.attempt_deadline > now);
tcpip_error = ESP_OK;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(r.online && s_shared.snapshot.ipv6_linklocal && disconnects == 0);
tcpip_skip = true;
assert(refresh_station_hostname() == ESP_FAIL);
handle_got_ip(&r, NULL);
assert(!r.online && s_shared.snapshot.last_error == ESP_FAIL);
tcpip_skip = false; handle_got_ip(&r, NULL); assert(r.online);
/* Netif-down with a stale driver association must withdraw readiness. */
up = false; handle_got_ip(&r, NULL);
assert(!r.online && !s_shared.snapshot.ipv6_linklocal);
assert_empty_addresses();
r = reset();
address(0, 0xfe, 0x80, PREFERRED);
address(1, 0xfd, 0x12, PREFERRED);
address(2, 0x20, 0x01, PREFERRED);
netif.addresses[2].addr[1] = esp_netif_htonl(0x12345678);
netif.addresses[2].addr[2] = esp_netif_htonl(0x9abcdef0);
netif.addresses[2].zone = 42;
handle_got_ip(&r, NULL);
assert(s_shared.snapshot.ipv6_count == 3);
for (int i = 0; i < 3; ++i)
assert(memcmp(s_shared.snapshot.ipv6_addresses[i].addr, netif.addresses[i].addr, 16) == 0);
print_ipv6_addresses(&s_shared.snapshot);
assert(strstr(rendered, "IPv6 preferred link-local: fe80:0000:0000:0000:0000:0000:0000:0001\n"));
assert(strstr(rendered, "IPv6 preferred ULA: fd12:0000:0000:0000:0000:0000:0000:0001\n"));
assert(strstr(rendered, "IPv6 preferred GUA: 2001:0000:1234:5678:9abc:def0:0000:0001\n"));
assert(strstr(rendered, "client's interface") && !strstr(rendered, "%42"));
netif.state[0] = TENTATIVE; netif.state[1] = DUPLICATE; netif.state[2] = DEPRECATED;
handle_got_ip(&r, NULL); assert_empty_addresses();
rendered[0] = 0; print_ipv6_addresses(&s_shared.snapshot);
assert(strstr(rendered, "IPv6 preferred addresses: none") && !strstr(rendered, "IPv6 preferred GUA:"));
for (int failure = 0; failure < 5; ++failure) {
address(0, 0xfe, 0x80, PREFERRED); address(1, 0xfd, 0x12, PREFERRED);
handle_got_ip(&r, NULL); assert(s_shared.snapshot.ipv6_count == 2);
if (failure == 0) tcpip_error = ESP_FAIL;
if (failure == 1) tcpip_skip = true;
if (failure == 2) up = false;
if (failure == 4) ip_info_error = ESP_FAIL;
if (failure == 3) clear_station_network_snapshot();
else handle_got_ip(&r, NULL);
assert_empty_addresses();
tcpip_error = ip_info_error = ESP_OK; tcpip_skip = false; up = true;
}
printf("snapshot=%zu settings=%zu shared=%zu observation=%zu bytes\n",
sizeof(wifi_manager_snapshot_t), sizeof(wifi_manager_settings_t),
sizeof(manager_shared_t), sizeof(station_addresses_t));
puts("wifi_phase12: hostname, IPv4/IPv6 lifecycle, deadlines, offline mDNS retries, TCP/IP ownership and event filtering PASS");
}