diff --git a/components/ml_dev_tether/src/ml_dev_tether.c b/components/ml_dev_tether/src/ml_dev_tether.c index 77853470..9ccf0a1a 100644 --- a/components/ml_dev_tether/src/ml_dev_tether.c +++ b/components/ml_dev_tether/src/ml_dev_tether.c @@ -106,6 +106,63 @@ static esp_err_t on_usb_rx(void * buffer, uint16_t len, void * ctx) return r; } +/* === USB bus state -> netif link state ============================ */ + +/* The tether netif was brought up with a synthetic "link connected" and + * nothing ever took it down again: a host that de-enumerated us, suspended + * the bus, or simply went away left the netif up with its lease, so the net + * supervisor kept ranking a dead tether as the best uplink and never failed + * over (bench, 2026-09-18: host port de-authorised, device kept 10.42.0.x, + * no WiFi for minutes). Mirror the bus state onto the netif: detached or + * suspended -> link down (lease dropped, netif_usable() false, supervisor + * fails over); attached or resumed -> link up (DHCP restarts). + * Runs on the TinyUSB task; the same lock on_usb_rx() uses keeps it from + * racing ml_dev_tether_stop()'s esp_netif_destroy(). */ +static void on_usb_event(tinyusb_event_t * event, void * arg) +{ + (void)arg; + if (event == NULL) { + return; + } + bool link_up; + const char * what; + switch (event->id) { + case TINYUSB_EVENT_ATTACHED: + link_up = true; + what = "attached"; + break; + case TINYUSB_EVENT_DETACHED: + link_up = false; + what = "detached"; + break; +#ifdef CONFIG_TINYUSB_SUSPEND_CALLBACK + case TINYUSB_EVENT_SUSPENDED: + link_up = false; + what = "suspended"; + break; +#endif +#ifdef CONFIG_TINYUSB_RESUME_CALLBACK + case TINYUSB_EVENT_RESUMED: + link_up = true; + what = "resumed"; + break; +#endif + default: + return; + } + if (s_rx_lock) xSemaphoreTake(s_rx_lock, portMAX_DELAY); + esp_netif_t * n = s_netif; + if (n) { + ESP_LOGW(TAG, "USB %s -> tether link %s", what, link_up ? "up" : "down"); + if (link_up) { + esp_netif_action_connected(n, 0, 0, 0); + } else { + esp_netif_action_disconnected(n, 0, 0, 0); + } + } + if (s_rx_lock) xSemaphoreGive(s_rx_lock); +} + /* === DHCP lease signal ============================================ */ static void on_got_ip(void * arg, esp_event_base_t base, int32_t id, void * data) @@ -163,7 +220,7 @@ esp_err_t ml_dev_tether_try_start(uint32_t timeout_ms) /* --- TinyUSB up --- */ if (!s_tusb_installed) { - const tinyusb_config_t tusb_cfg = TINYUSB_DEFAULT_CONFIG(); + const tinyusb_config_t tusb_cfg = TINYUSB_CONFIG_EVENT(on_usb_event); err = tinyusb_driver_install(&tusb_cfg); if (err != ESP_OK) { ESP_LOGE(TAG, "tinyusb_driver_install: %s", esp_err_to_name(err)); @@ -291,11 +348,17 @@ esp_err_t ml_dev_tether_try_start(uint32_t timeout_ms) } ml_usb_tx_set_enabled(1); - /* Use the SAME MAC for the lwIP netif as the NCM endpoint. The IDF - * sta2eth example uses different MACs because it bridges to a separate - * DHCP server; in our DHCP-CLIENT topology there's a single logical - * endpoint and ARP/DHCP must resolve to the same MAC the host sees. */ - esp_netif_set_mac(s_netif, net_cfg.mac_addr); + /* The MAC handed to tinyusb_net_init() is the one the HOST adopts for its + * side of the link (CDC-NCM iMACAddress); the lwIP netif is the other end + * and needs its own address, like any two NICs on a cable. Sharing one + * address (as before) had the host learn the device's IP at the host's + * own MAC — it happened to work on Linux, but is a spec violation and + * indistinguishable frames in any capture. Flip one more bit: still + * locally administered, still derived from the unit's WiFi MAC. */ + uint8_t dev_mac[6]; + memcpy(dev_mac, net_cfg.mac_addr, sizeof(dev_mac)); + dev_mac[5] ^= 0x02; + esp_netif_set_mac(s_netif, dev_mac); /* Bring up the netif and also signal "link connected" — without this, * the Ethernet-class netif stays in admin-up/link-down state and the diff --git a/firmware/components/dcs_support/src/dcs_net_supervisor.c b/firmware/components/dcs_support/src/dcs_net_supervisor.c index 388f1f02..a8acbbf3 100644 --- a/firmware/components/dcs_support/src/dcs_net_supervisor.c +++ b/firmware/components/dcs_support/src/dcs_net_supervisor.c @@ -35,6 +35,7 @@ #include "esp_timer.h" #include "esp_wifi.h" #include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" #include "freertos/task.h" #include "ml_app.h" @@ -45,6 +46,53 @@ static const char * TAG = "dcs_netsup"; * poll period. Only DOWN transitions kick — UP/promote stays on the periodic * loop for natural anti-flap debounce (asymmetric by design). */ static TaskHandle_t s_sup_task = NULL; + +/* dcs_wifi_set_enabled() reaches flash: the wifi_list NVS blob, and + * esp_wifi_init/start's PHY-calibration NVS access. Flash access asserts when + * the caller's stack is in PSRAM (cache_utils.c:127, + * esp_task_stack_is_sane_cache_disabled) — and the supervisor's is. So the + * toggle runs on a short-lived INTERNAL-stack worker and the supervisor only + * waits on a dedicated semaphore (its task notification is the link-down + * wake-up and must not be consumed here). Bench 2026-09-19 (cad4): tether + * link-down -> "bringing up WiFi" -> assert abort in net_sup, boot loop. */ +typedef struct +{ + bool on; + esp_err_t result; +} wifi_toggle_req_t; + +static SemaphoreHandle_t s_wifi_toggle_done = NULL; +static wifi_toggle_req_t s_wifi_toggle_req; /* one caller (this task), one toggle at a time */ + +static void wifi_toggle_task(void * arg) +{ + wifi_toggle_req_t * req = (wifi_toggle_req_t *)arg; + req->result = dcs_wifi_set_enabled(req->on); + (void)xSemaphoreGive(s_wifi_toggle_done); + vTaskDelete(NULL); +} + +static esp_err_t wifi_set_enabled_on_internal_stack(bool on) +{ + if (s_wifi_toggle_done == NULL) { + s_wifi_toggle_done = xSemaphoreCreateBinary(); + if (s_wifi_toggle_done == NULL) { + return ESP_ERR_NO_MEM; + } + } + s_wifi_toggle_req.on = on; + s_wifi_toggle_req.result = ESP_FAIL; + /* Internal stack by construction (plain xTaskCreate): 4 KB, transient. */ + if ( + xTaskCreatePinnedToCore(wifi_toggle_task, "wifi_tog", 4096, &s_wifi_toggle_req, 4, NULL, tskNO_AFFINITY) != pdPASS) + { + ESP_LOGW(TAG, "WiFi toggle worker: no internal RAM for its stack — retry next tick"); + return ESP_ERR_NO_MEM; + } + (void)xSemaphoreTake(s_wifi_toggle_done, portMAX_DELAY); /* worker always gives before exiting */ + return s_wifi_toggle_req.result; +} + static atomic_uint s_link_down_kicks = 0; #define SUPERVISOR_PERIOD_MS 1000 @@ -209,12 +257,12 @@ static void supervisor_task(void * arg) if (dcs_boot_alt_network_won()) { if ((!high_ok) && (high_bad_s >= WIFI_FAILOVER_AFTER_S) && (!dcs_wifi_is_enabled())) { ESP_LOGW(TAG, "no Eth/USB for %us — bringing up WiFi", (unsigned)high_bad_s); - if (dcs_wifi_set_enabled(true) == ESP_OK) { + if (wifi_set_enabled_on_internal_stack(true) == ESP_OK) { wifi_auto = true; } } else if (high_ok && (high_ok_s >= FAILOVER_DROP_HOLD_S) && wifi_auto && dcs_wifi_is_enabled()) { ESP_LOGW(TAG, "Eth/USB stable %us — dropping WiFi", (unsigned)high_ok_s); - if (dcs_wifi_set_enabled(false) == ESP_OK) { + if (wifi_set_enabled_on_internal_stack(false) == ESP_OK) { wifi_auto = false; } } else { @@ -239,10 +287,9 @@ static void supervisor_task(void * arg) void dcs_net_supervisor_start(void) { atomic_store(&g_dcs_active_iface, (int)DCS_IFACE_NONE); - /* 4608 B: the task now calls dcs_wifi_set_enabled() (esp_wifi_init/deinit, - * esp_netif create/destroy) on auto-failover, which needs more stack than - * the bare route-arbitration loop. */ - /* PSRAM stack: route supervisor is non-safety, does no flash/NVS. */ + /* PSRAM stack: route supervisor is non-safety and does no flash/NVS itself — + * the WiFi failover toggle, which does, runs on an internal-stack worker + * (wifi_set_enabled_on_internal_stack). */ s_sup_task = dcs_task_spawn_psram(supervisor_task, "net_sup", 4608, NULL, 4, tskNO_AFFINITY); /* Event-driven demote: wake the supervisor the instant a link drops so it diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index e8a6320e..68713374 100644 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -196,6 +196,10 @@ CONFIG_TINYUSB_CDC_COUNT=1 CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=n CONFIG_TINYUSB_CDC_RX_BUFSIZE=512 CONFIG_TINYUSB_CDC_TX_BUFSIZE=512 +# Bus suspend/resume events: the tether mirrors them onto its netif link +# state so the net supervisor fails over when the host goes away. +CONFIG_TINYUSB_SUSPEND_CALLBACK=y +CONFIG_TINYUSB_RESUME_CALLBACK=y # USB descriptors CONFIG_TINYUSB_DESC_MANUFACTURER_STRING="Polymath" CONFIG_TINYUSB_DESC_PRODUCT_STRING="MicroLink dual_core_safety" diff --git a/machn/sdkconfig.defaults b/machn/sdkconfig.defaults index 4d2ebb23..cff896d0 100644 --- a/machn/sdkconfig.defaults +++ b/machn/sdkconfig.defaults @@ -163,6 +163,10 @@ CONFIG_TINYUSB_CDC_COUNT=1 CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=n CONFIG_TINYUSB_CDC_RX_BUFSIZE=512 CONFIG_TINYUSB_CDC_TX_BUFSIZE=512 +# Bus suspend/resume events: the tether mirrors them onto its netif link +# state so the net supervisor fails over when the host goes away. +CONFIG_TINYUSB_SUSPEND_CALLBACK=y +CONFIG_TINYUSB_RESUME_CALLBACK=y # USB descriptors CONFIG_TINYUSB_DESC_MANUFACTURER_STRING="Polymath" CONFIG_TINYUSB_DESC_PRODUCT_STRING="MicroLink dual_core_safety"