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
+10 -2
View File
@@ -40,7 +40,7 @@ A complete example (values are illustrative, never defaults to install):
{"index": 3, "enabled": false, "priority": 0, "security": "mixed", "ssid": "", "password_configured": false}
]
},
"runtime": {"started": true, "state": "online", "active_profile": 0, "ip": "192.168.1.20", "ap_running": false, "ap_clients": 0, "last_error": 0},
"runtime": {"started": true, "state": "online", "active_profile": 0, "ip": "192.168.1.20", "ipv6_linklocal": true, "ipv6_routable": true, "ap_running": false, "ap_clients": 0, "last_error": 0},
"mdns": {"generation": 3, "suffix": "example", "hostname": "sak-example", "announced": true, "last_error": 0}
}
```
@@ -51,6 +51,14 @@ Runtime states are canonical `stopped`, `starting`, `connecting`, `waiting-ip`,
`.local`. The existing responder is STA-only. `announced` is the service's
expected-announcement status, not a client-observed DNS verification.
`ip` is IPv4 only; `0.0.0.0` means absent, including when `state` is `online`.
The required boolean `ipv6_linklocal` and `ipv6_routable` fields report preferred
IPv6 address availability (link-local and ULA/GUA respectively). They do not
assert a default route or Internet reachability. No actual IPv6 literal addresses
are available in this snapshot; reporting is flags only. Settings, quick Network,
and the main status summary label IPv4 absence separately from IPv6 availability.
The OLED network header uses `LL` and `ULA/GUA` with `Y`/`N` (`?` if unavailable).
Wi-Fi working configuration and runtime are copied together under its mutex;
mDNS is a separate consistent projection, not an atomic cross-domain snapshot.
Both acquisitions use zero wait. Either unavailable/contended yields HTTP 503
@@ -202,7 +210,7 @@ remain independent. No terminal lease/transport changes are made by this module.
- 768-byte POST, at most four receives, at most 13 distinct flat keys, 64-byte
parser value scratch; enough for one fully escaped 32-byte SSID and 63-byte
replacement plus the typed fields. No heap JSON tree/cJSON.
- 2,048-byte snapshot buffer. Maximum escaped fixture: 1,877 payload bytes
- 2,048-byte snapshot buffer. Escaped fixture remains below the fixed limit with both IPv6 booleans
(five 32-byte SSIDs at six bytes/byte, four profiles, full-width numbers,
55-byte mDNS suffix plus hostname, longest booleans/state/security/policy).
- 128-byte operation response buffer; one static operation and one small timer.
+33 -1
View File
@@ -15,6 +15,7 @@ sys.dont_write_bytecode = True
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
HEADERS = {
'sdkconfig.h': '#pragma once\n#define CONFIG_MDNS_PREDEF_NETIF_STA 1\n#define CONFIG_MDNS_PREDEF_NETIF_AP 0\n#define CONFIG_MDNS_PREDEF_NETIF_ETH 0\n#define CONFIG_LWIP_IPV6_NUM_ADDRESSES 3\n',
'esp_err.h': '''#pragma once
typedef int esp_err_t;
#define ESP_OK 0
@@ -34,6 +35,12 @@ typedef int esp_err_t;
#include <stdint.h>
#define pdTRUE 1
#define portMAX_DELAY UINT32_MAX
typedef int portMUX_TYPE;
#define portMUX_INITIALIZER_UNLOCKED 0
void fake_enter(portMUX_TYPE *);
void fake_exit(portMUX_TYPE *);
#define portENTER_CRITICAL(mux) fake_enter(mux)
#define portEXIT_CRITICAL(mux) fake_exit(mux)
''',
'freertos/semphr.h': '''#pragma once
#include <stdint.h>
@@ -62,8 +69,33 @@ void nvs_close(nvs_handle_t);
#define ESP_MAC_WIFI_SOFTAP 1
esp_err_t esp_read_mac(uint8_t *,int);
''',
'mdns.h': '''#pragma once
'esp_timer.h': '#pragma once\n#include <stdint.h>\nint64_t esp_timer_get_time(void);\n',
'esp_netif.h': '''#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
typedef struct { int unused; } esp_netif_t;
typedef struct { uint32_t addr; } esp_ip4_addr_t;
typedef struct { uint32_t addr[4]; } esp_ip6_addr_t;
typedef struct { esp_ip4_addr_t ip, netmask, gw; } esp_netif_ip_info_t;
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *);
bool esp_netif_is_netif_up(esp_netif_t *);
esp_err_t esp_netif_get_ip_info(esp_netif_t *,esp_netif_ip_info_t *);
int esp_netif_get_all_ip6(esp_netif_t *,esp_ip6_addr_t *);
esp_err_t esp_netif_tcpip_exec(esp_err_t (*)(void *),void *);
''',
'mdns.h': '''#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#include "esp_netif.h"
typedef enum {
MDNS_EVENT_ENABLE_IP4 = 1 << 1, MDNS_EVENT_ENABLE_IP6 = 1 << 2,
MDNS_EVENT_DISABLE_IP4 = 1 << 5, MDNS_EVENT_DISABLE_IP6 = 1 << 6
} mdns_event_actions_t;
esp_err_t mdns_netif_action(esp_netif_t *,mdns_event_actions_t);
esp_err_t mdns_service_add(const char *,const char *,const char *,uint16_t,const void *,size_t);
esp_err_t mdns_service_remove(const char *,const char *);
esp_err_t mdns_init(void);
esp_err_t mdns_hostname_set(const char *);
esp_err_t mdns_instance_name_set(const char *);
+73 -2
View File
@@ -8,6 +8,8 @@
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "nvs.h"
#include "mdns.h"
#include "esp_timer.h"
static int wifi_mutex, mdns_mutex;
static SemaphoreHandle_t s_mutex=&wifi_mutex;
@@ -23,8 +25,15 @@ static void lock_shared(void) { assert(!wifi_mutex); wifi_mutex=1; }
static void unlock_shared(void) { assert(wifi_mutex); wifi_mutex=0; }
static int s_drop_mux, s_queue;
static uint64_t s_queue_drops;
#define portENTER_CRITICAL(mux) do { assert((mux)==&s_drop_mux && !s_drop_mux); s_drop_mux=1; } while (0)
#define portEXIT_CRITICAL(mux) do { assert((mux)==&s_drop_mux && s_drop_mux); s_drop_mux=0; } while (0)
static portMUX_TYPE *critical;
void fake_enter(portMUX_TYPE *mux) {
assert(!critical && !*mux);
if(mux!=&s_drop_mux) assert(!wifi_mutex && !mdns_mutex);
critical=mux; *mux=1;
}
void fake_exit(portMUX_TYPE *mux) {
assert(critical==mux && *mux); *mux=0; critical=NULL;
}
static int xQueueSend(int queue,const manager_message_t *message,uint32_t wait) {
assert(queue==s_queue && !wait && wifi_mutex && !s_drop_mux);
if(queue_fail) return 0;
@@ -32,6 +41,7 @@ static int xQueueSend(int queue,const manager_message_t *message,uint32_t wait)
}
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return &mdns_mutex; }
int xSemaphoreTake(SemaphoreHandle_t mutex,uint32_t wait) {
assert(!critical);
if(wait==0 && (snapshot_contention || *mutex)) return 0;
assert(!*mutex); *mutex=1; return pdTRUE;
}
@@ -63,6 +73,39 @@ esp_err_t nvs_set_blob(nvs_handle_t handle,const char *key,const void *data,size
}
esp_err_t nvs_commit(nvs_handle_t handle) { (void)handle; ++commits; return commit_error; }
void nvs_close(nvs_handle_t handle) { (void)handle; }
/* Minimal synchronous netif boundary; queue-loss/repair faults live in mdns_phase12. */
static esp_netif_t sta;
static bool tcpip;
static unsigned netif_families=3, requested_families;
static int64_t clock_us;
int64_t esp_timer_get_time(void) { assert(!mdns_mutex && !critical); return clock_us; }
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *key) {
assert(!mdns_mutex && !critical && !strcmp(key,"WIFI_STA_DEF")); return &sta;
}
bool esp_netif_is_netif_up(esp_netif_t *netif) { assert(tcpip && netif==&sta); return true; }
esp_err_t esp_netif_get_ip_info(esp_netif_t *netif,esp_netif_ip_info_t *ip) {
assert(tcpip && netif==&sta); memset(ip,0,sizeof(*ip));
ip->ip.addr=(netif_families&1) ? 1 : 0; return ESP_OK;
}
int esp_netif_get_all_ip6(esp_netif_t *netif,esp_ip6_addr_t *ip) {
assert(tcpip && netif==&sta);
if(!(netif_families&2)) return 0;
memset(ip,0,sizeof(*ip)); ip->addr[0]=0xfe80; return 1;
}
esp_err_t esp_netif_tcpip_exec(esp_err_t (*callback)(void *),void *context) {
assert(!mdns_mutex && !critical && !tcpip); tcpip=true;
esp_err_t error=callback(context); tcpip=false; return error;
}
esp_err_t mdns_netif_action(esp_netif_t *netif,mdns_event_actions_t action) {
assert(netif==&sta && !mdns_mutex && !critical && !tcpip);
assert(action && !(action & ~(MDNS_EVENT_ENABLE_IP4 | MDNS_EVENT_ENABLE_IP6 |
MDNS_EVENT_DISABLE_IP4 | MDNS_EVENT_DISABLE_IP6)));
if(action&MDNS_EVENT_ENABLE_IP4) requested_families|=1;
if(action&MDNS_EVENT_ENABLE_IP6) requested_families|=2;
if(action&MDNS_EVENT_DISABLE_IP4) requested_families&=~1U;
if(action&MDNS_EVENT_DISABLE_IP6) requested_families&=~2U;
return ESP_OK;
}
static char announced_hostname[60];
esp_err_t mdns_init(void) { return ESP_OK; }
esp_err_t mdns_hostname_set(const char *hostname) {
@@ -70,6 +113,13 @@ esp_err_t mdns_hostname_set(const char *hostname) {
}
esp_err_t mdns_instance_name_set(const char *name) { assert(name); return ESP_OK; }
void mdns_free(void) {}
esp_err_t mdns_service_add(const char *name,const char *type,const char *proto,uint16_t port,const void *txt,size_t count) {
assert(!mdns_mutex && !name && type && !strcmp(proto,"_tcp") && (port==443 || port==22) && !txt && !count);
return ESP_OK;
}
esp_err_t mdns_service_remove(const char *type,const char *proto) {
assert(!mdns_mutex && type && !strcmp(proto,"_tcp")); return ESP_OK;
}
#include "manager_production.h"
static uint32_t generation(void) { return s_shared.snapshot.config_generation; }
@@ -85,6 +135,20 @@ int main(void) {
snapshot_contention=false;
assert(wifi_manager_get_settings(&projection)==ESP_OK && projection.ap_password_configured);
assert(!projection.profiles[0].password_configured && projection.runtime.active_profile==-1);
for(unsigned flags=0;flags<8;++flags) {
s_shared.snapshot.state=WIFI_MANAGER_STATE_ONLINE;
s_shared.snapshot.ip=(flags&4) ? UINT32_C(0x080200c0) : 0;
s_shared.snapshot.ipv6_linklocal=(flags&1)!=0;
s_shared.snapshot.ipv6_routable=(flags&2)!=0;
assert(wifi_manager_get_settings(&projection)==ESP_OK);
assert(projection.runtime.state==WIFI_MANAGER_STATE_ONLINE);
assert(projection.runtime.ip==s_shared.snapshot.ip);
assert(projection.runtime.ipv6_linklocal==s_shared.snapshot.ipv6_linklocal);
assert(projection.runtime.ipv6_routable==s_shared.snapshot.ipv6_routable);
}
s_shared.snapshot.state=WIFI_MANAGER_STATE_STOPPED; s_shared.snapshot.ip=0;
s_shared.snapshot.ipv6_linklocal=false; s_shared.snapshot.ipv6_routable=false;
puts("PASS real manager projection: IPv6-only/dual-stack preserve independent availability flags and zero IPv4 online");
wifi_manager_patch_t p={.profile=0,.fields=WIFI_PATCH_SSID,.ssid_len=32};
for(unsigned i=0;i<32;++i) p.ssid[i]=(uint8_t)(i*8);
assert(patch(&p)==ESP_OK && queued==0 && !s_shared.config.profiles[0].enabled);
@@ -188,12 +252,19 @@ int main(void) {
assert(mdns_service_update_current(1,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
assert(mdns_service_update_current(1,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_ERR_NOT_FOUND);
assert(mdns_service_start()==ESP_OK && !strcmp(announced_hostname,"sak-first"));
assert(requested_families==3);
netif_families=2;
assert(mdns_service_reconcile()==ESP_OK && requested_families==2);
netif_families=3; clock_us+=30000000;
assert(mdns_service_reconcile()==ESP_OK && requested_families==3);
mdns_service_stop();
assert(requested_families==0);
assert(mdns_service_get_settings(&m)==ESP_OK && !m.announced);
memset(config.suffix,0,sizeof(config.suffix)); strcpy(config.suffix,"offline"); config.suffix_len=7;
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
unsigned calls=hostname_calls;
assert(mdns_service_start()==ESP_OK && hostname_calls==calls+1 && !strcmp(announced_hostname,"sak-offline"));
assert(requested_families==3 && !critical && !tcpip);
assert(mdns_service_get_settings(&m)==ESP_OK);
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_OK);
strcpy(config.suffix,"another"); assert(mdns_service_set_config(&config)==ESP_OK);