feat(usbnet): make USB-NCM enablement a reusable fragment for any capable board

Move the env boilerplate (HAS_USB_NET macro, include/link setup, pinned
esp_tinyusb/tinyusb components, and the NCM/DHCP sdkconfig) into a shared
[usbnet] section in variants/esp32/usbnet.ini, so enabling the gadget on a
board is a short <board>_usbnet env that splices the fragments in. The
meshnology_w12_usbnet env becomes the reference consumer, keeping only its
board-specific pieces (start delay, coredump-to-flash debugging config).

Eligibility is enforced at compile time in USBNetEsp32.cpp: HAS_USB_NET on a
SoC without native USB-OTG (C3/C6/H2, classic ESP32) or on a board without
BOARD_HAS_PSRAM fails the build with an explanation, since the phone API
session's connect-time allocations need PSRAM in the heap and USB-Serial-JTAG
parts cannot present a USB device at all.
This commit is contained in:
Ben Meadors committed 2026-08-12 10:34:52 -05:00
1 parent 3a7871a1b8
commit cb6a738cc6
4 files changed
+156 -75

No files matched your search

+11
View File
@@ -26,6 +26,17 @@ Verified on hardware:
| Board stays flashable | ✅ many flash cycles |
| **Real API session completes** | ✅ `TCPInterface` + `getMyNodeInfo()`, twice in a row, node stays up |
## Enabling on other boards
The enablement is a reusable fragment: `[usbnet]` in `variants/esp32/usbnet.ini`
carries the build flags, managed components, and sdkconfig any adopter needs,
and documents the adoption recipe (a `<board>_usbnet` env that splices the
fragments in). `[env:meshnology_w12_usbnet]` is the reference consumer.
Eligibility - a native USB-OTG SoC (ESP32-S2/S3/P4) and PSRAM
(`-D BOARD_HAS_PSRAM`) - is enforced at compile time by `#error` guards in
`USBNetEsp32.cpp`, so an ineligible variant fails the build with an
explanation instead of misbehaving on hardware.
## The crash that blocked this branch - root cause and fix
**Symptom:** the node rebooted within seconds whenever a client opened a real
+15
View File
@@ -2,6 +2,21 @@
#if HAS_USB_NET && defined(ARCH_ESP32)
#include <soc/soc_caps.h>
// Hardware eligibility, enforced here so a variant that sets HAS_USB_NET=1
// without the prerequisites fails at compile time with an explanation instead
// of misbehaving on hardware (see variants/esp32/usbnet.ini for the adoption
// recipe).
#if !SOC_USB_OTG_SUPPORTED
#error \
"HAS_USB_NET requires a native USB-OTG SoC (ESP32-S2/S3/P4). C3/C6/H2 only have USB-Serial-JTAG and cannot present a USB device."
#endif
#ifndef BOARD_HAS_PSRAM
#error \
"HAS_USB_NET requires PSRAM (-D BOARD_HAS_PSRAM). Without PSRAM in the heap the first phone-API session's ~15KB contiguous allocation aborts the node with std::bad_alloc."
#endif
#include "USBNetPolicy.h"
#include "configuration.h"
#include "mesh/api/WiFiServerAPI.h"
+115
View File
@@ -0,0 +1,115 @@
; ---------------------------------------------------------------------------
; USB-Ethernet (CDC-NCM) gadget + phone API over USB ("usbnet")
;
; Presents the node to a USB host as a standard USB Ethernet adaptor so the
; Meshtastic apps can reach the existing TCP phone API (port 4403) over a
; plain USB-C cable - including on iOS/iPadOS in airplane mode, where USB
; serial is not an option (Apple's own drivers win IOKit matching). Apple's
; built-in NCM driver binds it; no dext, no MFi
; (Apple DTS: developer.apple.com/forums/thread/772812).
;
; This section is a reusable fragment, not an env. To enable usbnet for a
; board, add a `<board>_usbnet` env to the board's platformio.ini that
; splices these fragments in. Reference consumer:
; [env:meshnology_w12_usbnet] in variants/esp32s3/meshnology-w12/platformio.ini.
;
; [env:myboard_usbnet]
; extends = env:myboard
; board_level = extra
; extra_scripts =
; ${esp32_common.extra_scripts}
; ${usbnet.extra_scripts}
; build_flags =
; ${env:myboard.build_flags}
; ${usbnet.build_flags}
; custom_component_add = ${usbnet.custom_component_add}
; custom_sdkconfig =
; ${esp32s3_base.custom_sdkconfig}
; ${usbnet.custom_sdkconfig}
;
; Hardware requirements, enforced at compile time in USBNetEsp32.cpp:
; * a native USB-OTG SoC (ESP32-S2/S3/P4) with D+/D- wired to the USB
; connector. C3/C6/H2 only have USB-Serial-JTAG and physically cannot do
; this; boards whose USB-C goes through a CP2102/CH9102 UART bridge
; (e.g. Heltec V3) are equally out. Proxy check: ARDUINO_USB_CDC_ON_BOOT=1
; in the board's build flags.
; * PSRAM, declared with -D BOARD_HAS_PSRAM. The gadget stack (TinyUSB task,
; NCM transfer buffers, esp_netif, DHCP server, API server) lives in
; internal SRAM; without PSRAM in the heap the first phone-API session's
; ~15KB contiguous allocation aborts the node with std::bad_alloc.
;
; Operational notes for adopters:
; * Once the gadget claims the USB pads the serial console and esptool's
; auto-reset disappear until the next power-on reset. USB_NET_START_DELAY_MS
; (default 8000 in USBNetThread.cpp) is the reflash window after reset;
; raise it per-env while iterating (the W12 pilot uses 30000).
; * A panic reboot re-enters the gadget without ever enumerating
; USB-Serial-JTAG, so panics are invisible on the console. Enable
; coredump-to-flash in the env's custom_sdkconfig if your partition table
; carries a coredump partition (see the W12 env), and mind that
; CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=n must be set explicitly.
; * custom_component_add rewrites the SHARED
; framework-arduinoespressif32/idf_component.yml (backed up once as .orig).
; The platform restores it after the build; bin/restore-idf-component-yml.sh
; is the manual fallback before building other ESP32 envs.
; ---------------------------------------------------------------------------
[usbnet]
build_flags =
-D HAS_USB_NET=1
; esp_tinyusb is built as an IDF component, but PlatformIO compiles src/ with
; its own CPPPATH which does not pick up the project's managed_components.
; Ours must come first: a stale arduino_tinyusb copy on the path would supply
; a tusb_config.h where CFG_TUD_NCM is 0, silently disagreeing with the
; component the app is linked against. USBNetEsp32.cpp static-asserts
; CFG_TUD_NCM to turn that into a build failure rather than a runtime mystery.
-I managed_components/espressif__esp_tinyusb/include
-I managed_components/espressif__tinyusb/src
; HybridCompile builds libespressif__{esp_,}tinyusb.a but never adds them to
; pioarduino-build.py's LIBS, so the app links without them. Pull them in
; explicitly, inside a group because esp_tinyusb and tinyusb reference each
; other. The -L search path for those archives is added by
; extra_scripts/usbnet_link.py -- a relative -L here is silently dropped by
; PlatformIO.
-Wl,--start-group
-lespressif__esp_tinyusb
-lespressif__tinyusb
-Wl,--end-group
extra_scripts = extra_scripts/usbnet_link.py
custom_component_add =
espressif/esp_tinyusb@^2.2.0
; Pin TinyUSB >= 0.21.0 explicitly. esp_tinyusb only requires ">=0.17.0~2",
; but PR #3630 (first released in 0.21.0) is what makes NCM work on
; iOS/iPadOS 26 -- without it, SET_ETHERNET_PACKET_FILTER (0x43) and
; SET/GET_NTB_INPUT_SIZE (0x85/0x86) STALL, iOS deactivates the interface,
; and DHCP succeeds only ~30% of the time (hathach/tinyusb#3505).
espressif/tinyusb@^0.21.0
custom_sdkconfig =
CONFIG_TINYUSB_NET_MODE_NCM=y
; NOTE: do not add CONFIG_TINYUSB_TASK_STACK_SIZE / CONFIG_TINYUSB_TASK_AFFINITY_CPU0
; here. Those Kconfig symbols do not exist in esp_tinyusb 2.x - task size,
; priority and core affinity moved into tinyusb_config_t.task, and anything
; set here is silently ignored. USBNetEsp32.cpp sets them via
; TINYUSB_TASK_CUSTOM().
CONFIG_LWIP_DHCPS=y
; Suppress DHCP option 6 (DNS). dhcpserver.c only emits it when the DNS offer
; flag is set -- EXCEPT that with CONFIG_LWIP_DHCPS_ADD_DNS=y (the default)
; the else-branch vends the server's own address as the DNS server. That
; would point the host at 192.168.7.1 for name resolution, which answers
; nothing. Apple's guidance for an accessory is to vend neither a router nor
; a DNS - vending either makes iOS elect the link, fail its captive-network
; probe, and kill the interface.
CONFIG_LWIP_DHCPS_ADD_DNS=n
; KCONFIG NAMESPACE COLLISION -- do not set these to 'y'.
; Two different components share the CONFIG_TINYUSB_* prefix:
; * arduino_tinyusb -- the Arduino core's own, NOT built by Meshtastic
; (HybridCompile omits it), owns CONFIG_TINYUSB_ENABLED
; * espressif/esp_tinyusb -- the managed component we add above
; The core's USBCDC.cpp gates on CONFIG_TINYUSB_CDC_ENABLED and USBMSC.cpp on
; CONFIG_TINYUSB_MSC_ENABLED. Enabling either esp_tinyusb symbol un-gates the
; matching Arduino file, which then fails to compile because it needs
; arduino_tinyusb's esp32-hal-tinyusb.h helpers that are not in this build.
; esp_tinyusb defaults MSC to ON, so it must be turned off explicitly.
; Consequence: NCM-only for now; a composite CDC-ACM console needs a
; different mechanism.
CONFIG_TINYUSB_CDC_ENABLED=n
CONFIG_TINYUSB_MSC_ENABLED=n
+15 -75
View File
@@ -34,75 +34,36 @@ build_flags =
-I variants/esp32s3/meshnology-w12
; ---------------------------------------------------------------------------
; USB-Ethernet (CDC-NCM) gadget bring-up target.
; USB-Ethernet (CDC-NCM) gadget target - reference consumer of the shared
; [usbnet] fragment (variants/esp32/usbnet.ini; requirements and the adoption
; recipe are documented there).
;
; Presents the node to a USB-C iPad/iPhone as a standard USB Ethernet adaptor
; so the Meshtastic iOS app can reach the existing TCP phone API (port 4403)
; over a wire, including in airplane mode. Apple's built-in NCM driver binds
; it; no dext, no MFi (Apple DTS: developer.apple.com/forums/thread/772812).
;
; Requires native USB on the connector (S3 D+/D- = GPIO19/20). The W12
; qualifies: ARDUINO_USB_CDC_ON_BOOT=1 above, and it is already
; requires_dfu = true, so taking over USB-OTG regresses no auto-reset circuit.
;
; NOTE: custom_component_add rewrites the SHARED
; framework-arduinoespressif32/idf_component.yml (backed up once as .orig) and
; is NOT part of the HybridCompile cache key. Run bin/restore-idf-component-yml.sh
; after building this env before building any other ESP32 env.
; The W12 qualifies: native S3 USB on the connector (ARDUINO_USB_CDC_ON_BOOT=1
; above), PSRAM (BOARD_HAS_PSRAM above), and it is already requires_dfu = true,
; so taking over USB-OTG regresses no auto-reset circuit.
; ---------------------------------------------------------------------------
[env:meshnology_w12_usbnet]
extends = env:meshnology_w12
board_level = extra
extra_scripts =
${esp32_common.extra_scripts}
extra_scripts/usbnet_link.py
${usbnet.extra_scripts}
custom_meshtastic_hw_model_slug = MESHNOLOGY_W12_USBNET
custom_meshtastic_display_name = Meshnology W12 (USB-NCM)
custom_meshtastic_actively_supported = false
build_flags =
${env:meshnology_w12.build_flags}
-D HAS_USB_NET=1
; 30s, not 8s: once the gadget claims the USB pads the serial port disappears
; and esptool has no RTS line to auto-reset with, so every reflash would need a
; manual BOOT+RST. A 30s window is long enough to start `pio run -t upload`
; after a reset and keep the dev loop self-service. Lower it for a real build.
${usbnet.build_flags}
; 30s, not the 8s code default: once the gadget claims the USB pads the serial
; port disappears and esptool has no RTS line to auto-reset with, so every
; reflash would need a manual BOOT+RST. A 30s window is long enough to start
; `pio run -t upload` after a reset and keep the dev loop self-service.
; Lower it for a real build.
-D USB_NET_START_DELAY_MS=30000
; esp_tinyusb is built as an IDF component, but PlatformIO compiles src/ with
; its own CPPPATH which does not pick up the project's managed_components.
; Ours must come first: a stale arduino_tinyusb copy on the path would supply a
; tusb_config.h where CFG_TUD_NCM is 0, silently disagreeing with the component
; the app is linked against. USBNetEsp32.cpp static-asserts CFG_TUD_NCM to turn
; that into a build failure rather than a runtime mystery.
-I managed_components/espressif__esp_tinyusb/include
-I managed_components/espressif__tinyusb/src
; HybridCompile builds libespressif__{esp_,}tinyusb.a but never adds them to
; pioarduino-build.py's LIBS, so the app links without them. Pull them in
; explicitly, inside a group because esp_tinyusb and tinyusb reference each
; other. Two -L paths because the archives land in different places depending
; on whether the IDF libs were rebuilt for this env (project build dir) or
; reused from the shared package (framework-libs lib dir, already on LIBPATH).
; The search path for those archives is added by extra_scripts/usbnet_link.py
; below -- a relative -L here is silently dropped by PlatformIO.
-Wl,--start-group
-lespressif__esp_tinyusb
-lespressif__tinyusb
-Wl,--end-group
custom_component_add =
espressif/esp_tinyusb@^2.2.0
; Pin TinyUSB >= 0.21.0 explicitly. esp_tinyusb only requires ">=0.17.0~2",
; but PR #3630 (first released in 0.21.0) is what makes NCM work on
; iOS/iPadOS 26 -- without it, SET_ETHERNET_PACKET_FILTER (0x43) and
; SET/GET_NTB_INPUT_SIZE (0x85/0x86) STALL, iOS deactivates the interface,
; and DHCP succeeds only ~30% of the time (hathach/tinyusb#3505).
espressif/tinyusb@^0.21.0
custom_component_add = ${usbnet.custom_component_add}
custom_sdkconfig =
${esp32s3_base.custom_sdkconfig}
CONFIG_TINYUSB_NET_MODE_NCM=y
; NOTE: do not add CONFIG_TINYUSB_TASK_STACK_SIZE / CONFIG_TINYUSB_TASK_AFFINITY_CPU0
; here. Those Kconfig symbols do not exist in esp_tinyusb 2.x - task size,
; priority and core affinity moved into tinyusb_config_t.task, and anything set
; here is silently ignored. USBNetEsp32.cpp sets them via TINYUSB_TASK_CUSTOM().
CONFIG_LWIP_DHCPS=y
${usbnet.custom_sdkconfig}
; Panic backtraces are otherwise unrecoverable on this build: the console is
; HWCDC on USB-Serial-JTAG, and the gadget takes those pads, so a crash after
; the 30s mark prints into the void. default_16MB.csv already carries a 64KB
@@ -118,24 +79,3 @@ custom_sdkconfig =
CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y
CONFIG_ESP_COREDUMP_CHECKSUM_CRC32=y
CONFIG_ESP_COREDUMP_MAX_TASKS_NUM=64
; Suppress DHCP option 6 (DNS). dhcpserver.c only emits it when the DNS offer
; flag is set -- EXCEPT that with CONFIG_LWIP_DHCPS_ADD_DNS=y (the default) the
; else-branch vends the server's own address as the DNS server. That would
; point the iPad at 192.168.7.1 for name resolution, which answers nothing.
; Apple's guidance for an accessory is to vend neither a router nor a DNS.
CONFIG_LWIP_DHCPS_ADD_DNS=n
; KCONFIG NAMESPACE COLLISION -- do not set these to 'y'.
; Two different components share the CONFIG_TINYUSB_* prefix:
; * arduino_tinyusb -- the Arduino core's own, NOT built by Meshtastic
; (HybridCompile omits it), owns CONFIG_TINYUSB_ENABLED
; * espressif/esp_tinyusb -- the managed component we add above
; The core's USBCDC.cpp gates on CONFIG_TINYUSB_CDC_ENABLED and USBMSC.cpp on
; CONFIG_TINYUSB_MSC_ENABLED. Enabling either esp_tinyusb symbol un-gates the
; matching Arduino file, which then fails to compile because it needs
; arduino_tinyusb's esp32-hal-tinyusb.h helpers (tinyusb_add_string_descriptor,
; TUD_CDC_DESCRIPTOR, TU_VERIFY, ...) that are not in this build.
; esp_tinyusb defaults MSC to ON, so it must be turned off explicitly.
; Consequence: NCM-only for now; the composite CDC-ACM console needs a
; different mechanism (see P4). Console stays on UART0 until then.
CONFIG_TINYUSB_CDC_ENABLED=n
CONFIG_TINYUSB_MSC_ENABLED=n