Files
firmware/extra_scripts/usbnet_link.py
T
Ben Meadors 8f1d253b3c feat(usbnet): USB-Ethernet (CDC-NCM) gadget + phone API for native-USB ESP32
Presents the node to a USB host as a standard USB Ethernet adaptor and serves
the existing TCP phone API (port 4403) over it, so a USB-C iPad/iPhone can reach
a node over a wire - including in airplane mode, since the link is not a radio.
USB *serial* is permanently closed on iPadOS (Apple's own dexts win IOKit
matching and expose no user client), so USB-Ethernet is the only wired route.
Apple DTS recommends exactly this shape for an ESP32-S3 (developer forums
thread 772812); network adaptors are generally not MFi-licensed.

Opt-in per variant via HAS_USB_NET; every file is self-gated so non-opted-in
environments are byte-identical and no build_src_filter changes are needed.
Pilot env meshnology_w12_usbnet at board_level = extra (absent from the PR and
release CI matrices).

STATUS: the transport works end to end and is hardware-verified. iPadOS 26
enumerates the gadget and joins the network. On macOS the host gets a DHCP lease
of 192.168.7.2 with NO router option and NO DNS option, the default route is not
hijacked, ping is ~1.3ms, and 4403 accepts while 4404/80 correctly refuse.
The phone API over that transport does NOT work yet: the node reboots when a
client opens a session on 4403. Reproducible from macOS with no Meshtastic app
involved, so it is firmware, and it needs a real TCP session (connecting to an
address with nothing on it does not trigger it). Two theories are now ruled out
by measurement: thread-table exhaustion (boot logs 20/40 threads used) and
HWCDC console blocking (the screen keeps running). Coredump-to-flash is wired
into the env for the next session but did NOT take effect in this build -
HybridCompile reused cached libs and CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH is
absent from the generated sdkconfig; force an IDF rebuild before trusting it.

Design notes, all load-bearing:
- L3 policy follows Apple guidance (Quinn, developer forums 779796): vend no
  gateway and no DNS, so iOS cannot elect the link as a default route, never
  runs its captive probe against it, and never marks it dead. gw is 0.0.0.0,
  the router offer flag is cleared, and CONFIG_LWIP_DHCPS_ADD_DNS is off -
  without that last one dhcpserver.c vends the node's own address as a DNS
  server that answers nothing. Deliberately NOT warthog's NAT gateway model.
- Bring-up order is the correctness argument: netif + DHCP + API listener all
  come up in setup(), and only then does the USB device start and the NCM link
  get raised. iOS runs DHCP exactly once on link-up and never retries.
- tud_network_default_link_state_cb() is overridden to start the link DOWN.
- The USB task is pinned to core 0 via TINYUSB_TASK_CUSTOM(). esp_tinyusb 2.x
  moved task config out of Kconfig, so CONFIG_TINYUSB_TASK_STACK_SIZE and
  CONFIG_TINYUSB_TASK_AFFINITY_CPU0 do not exist and are silently ignored; the
  default is core 1 at priority 5, the same core as the Arduino loop task at
  priority 1.
- usbNetTransmit refuses early and waits only briefly: with
  CONFIG_LWIP_TCPIP_CORE_LOCKING it runs inline on the caller, and
  tinyusb_net_send_sync enqueues with an infinite wait. Dropping a frame is
  free because TCP retransmits; blocking the loop task is not.
- TinyUSB must resolve to >= 0.21.0 (pinned explicitly): PR #3630 is what makes
  NCM work on iOS/iPadOS 26, and esp_tinyusb alone only requires >= 0.17.0~2.
- USBNetPolicy is free of Arduino/IDF deps so the DHCP option policy and MAC
  derivation can be covered by the native test suite (tests not yet written).

Known rough edges for the next session:
- Requires native USB on the connector (ESP32-S2/S3/P4). The C3/C6/H2 have
  USB-Serial-JTAG only and cannot do this. Heltec V3 is disqualified - its
  USB-C is a UART bridge.
- The gadget owns the USB pads, so the serial console dies once it starts and
  esptool has no RTS line to auto-reset with. USB_NET_START_DELAY_MS is 30s to
  keep a reflash window; BOOT+RST parks the chip in ROM download mode with no
  time limit.
- extra_scripts/usbnet_link.py adds the managed-component archives to LIBPATH;
  HybridCompile builds them but never adds them to pioarduino-build.py's LIBS.
- bin/restore-idf-component-yml.sh restores the shared framework
  idf_component.yml after a custom_component_add build. The platform does
  auto-restore it, so this is belt and braces - do not run it before building
  the usbnet env or the component is stripped from the dependency list.
2026-08-06 12:17:25 -05:00

28 lines
1.2 KiB
Python

"""Link the esp_tinyusb managed components into a USB-Ethernet gadget build.
`custom_component_add = espressif/esp_tinyusb` makes pioarduino build
libespressif__esp_tinyusb.a and libespressif__tinyusb.a, but it never adds them
to pioarduino-build.py's LIBS, so the application links without them and fails
on tinyusb_driver_install / tinyusb_net_init.
The archives land in one of two places depending on whether the IDF libs were
rebuilt for this environment (project build dir) or reused from the shared
framework package (already on LIBPATH), so add the project location here and let
`-lespressif__*` in the env's build_flags resolve from whichever exists.
Doing this in a script rather than as `-L` build_flags is deliberate: PlatformIO
routes `-L` into LIBPATH but does not resolve a relative path from the project
root, so the entry silently misses. $BUILD_DIR is unambiguous.
"""
import os
Import("env") # noqa: F821
build_dir = env.subst("$BUILD_DIR") # noqa: F821
idf_dir = os.path.join(build_dir, "esp-idf")
for component in ("espressif__esp_tinyusb", "espressif__tinyusb"):
path = os.path.join(idf_dir, component)
env.Append(LIBPATH=[path]) # noqa: F821