USB network personality and the IP gateway
The radio can be a USB network adapter instead of a serial port, and route IPv4 between the host and other radios
over DMR packet data. Switches: CONFIG_USB_NETWORK (the adapter), CONFIG_DMR_IP_GATEWAY (the gateway on top of
it) and CONFIG_DMR_CONTROL_STATION (section 5), on wherever the radio has USB and DMR (docs/build-config.md). On
the link the radio also runs its own services: mDNS (mdns.md), a TFTP server for the codeplug (tftp.md) and the
TCP services (tcp-services.md). The same gateway and services also run over PPP on the serial host port, for radios
without USB (ppp.md).
1. One personality at a time
CDC-ACM needs two IN endpoints (notification, bulk IN) and CDC-NCM needs two. The USB cores in the radios with USB
have three IN endpoints besides EP0 (the STM32F405's OTG_FS), so a
composite ACM + NCM device does not fit: the radio is either a serial port (board_usb_ids.pid_serial) or a network
adapter (pid_network), with their own descriptors. Switching drops the D+ pull-up for
100 ms, so the host sees an unplug and enumerates the other personality.
- Boot is serial unless the stored setting
usb_modeis 1 (Settings > Connections > USB network at start, debug link radio op 27, view keyusb.boot_network): then the personality is chosen before the USB stack starts, and the radio enumerates as the adapter straight away. At run time the switch is a request: debug link radio op 25 (rfoundry_debug.py radio usb network) or Tools > USB network, carried out after its reply has gone out; it doesn't change the stored setting. - In network mode the serial protocols stay reachable over UDP: datagrams to the radio's own address, port 3334,
carry the same byte stream as the serial port (debug link, MMDVM, CPS), and the replies go back to the address and
port that last sent there, in datagrams of at most 512 bytes.
rfoundry_debug.py --port udp:<radio IP>uses it, and so can MMDVMHost (Protocol=udp,ModemAddress=<radio IP>,ModemPort=3334). The MMDVM session's DTR rule does not apply (there is no DTR); the 20 s idle timeout does. - Windows 10 binds NCM through Microsoft OS 1.0 descriptors (string 0xEE "MSFT100", vendor code 0x47, extended compat ID "WINNCM" for interface 0); Windows 11, Linux and macOS bind it by class.
- The link is up while the host has the adapter configured and the bus isn't suspended (
hal_netif_t.up, view keyusb.net_up). The STM32 radios run without VBUS sensing, so a pulled cable reads only as the bus going idle (suspend): the link goes down at once, and comes back with the configuration after the cable is plugged in again. - macOS sometimes never activates the link after a cold boot in network mode: with the device configured and no frame from the host within 5 s, the radio re-enumerates once.
- A host that never configures the network personality at all (seen on macOS 26, Apple Silicon, 2026-10-10: the
device shows unmatched, no interfaces) would leave no serial port and no debug link to switch back. After a switch
to network mode, at run time or by the power-on setting, the radio goes back to serial when the host hasn't
configured it within 10 s of its bus reset (
usb_ncm.c,switch_fallbacks). The time counts only while a host is on the bus (tud_connected), so a radio switched on away from a computer still comes up as the adapter when it is plugged in later. The fallback lasts for that boot only: the setting stays, and the UI (Settings > Connections) or the debug link over the serial port it fell back to turns it off.
2. Frames
TinyUSB's NCM class (NTB16): OUT NTBs up to 2048 bytes, IN NTBs one datagram each. Frames from the host are copied in
the USB task into a ring of 4 frames of at most 600 bytes (the gateway's limit); longer frames and frames arriving
while the ring is full are dropped and counted (usb_ncm_stats_t), never held, so the class driver's buffer is free
at once. One frame to the host is in flight at a time; a second send reports busy and the caller retries. The class
driver is touched only from the USB task (usbd_defer_func). The interface reaches the core as board_t.netif
(hal/hal_netif.h).
MAC addresses are locally administered and derived from the chip's unique ID: the host adapter's (the descriptor's iMACAddress) ends in an even byte, the radio's side of the link is the same address with the low bit set.
3. The gateway (core/dmr/ipGateway.c, core/hostlink/hostlink_net.c)
Addressing, ARP, DHCP, ping and the raw and monitor ports are as core/dmr/ipGateway.h describes: the host gets
12.
- Host to air: IP for a radio ID or talkgroup, and datagrams to the raw port, go to the air ops registered with
hostlink_net_set_air()(radio ID, IP to air, raw data as SAP 10). They are called from the host link task and must not block. The default ones (core/hostlink/hostlink_net_air.c, registered by the radio core's data service glue inradio_sm.c) copy the packet into a one-packet mailbox and return false while it is full. At the radio task's data tick an IP packet goes to the data service (dmrDataServiceSendIP), which builds it (rate 1/2, 2 preamble CSBKs): UDP with the compressed UDP/IPv4 header (SAP 3) whenudpCompressallows it (default auto: ports outside 4000-4099), else the full IPv4 header (SAP 4); confirmed data with retries when a confirmed rule names the destination radio or a UDP port, else unconfirmed (dmr-data.md11, 12). The mailbox stays taken until the service is done with the packet (on air; confirmed: ACKed or failed after its tries). Raw data is built here as an unconfirmed SAP 10 packet fordmrDataServiceSendBursts. A service that can't take the packet (another one going, not a DMR channel, receive only) is tried again on every tick for up to 2 s, then the packet is dropped; one it took and couldn't deliver counts asnet.air_failed. The host's address follows the radio's DMR ID. Refusals are counted (net.to_air_refused,net.air_busy,net.air_dropped); only confirmed packets are retried over the air. - Air to host: the data service hands every packet it receives to
hostlink_net_packet()(itsdmrDataEnv_t.packethook). Two packets wait for the host link task, which delivers SAP 4 as IP (ICMP, UDP, SCTP), SAP 3 as UDP, and anything else as raw data on port 40078; a packet that finds the IN endpoint busy goes on the next pass, and packets are dropped while the link is down. Confirmed data responses are the data service's and don't go up. - The radio's own services (
core/net,core/snmp;ipGateway.h"The radio's own services"). Thecore/netones run in the host link task, started and polled bycore/net/net.c(net_start/net_pollfromhostlink_net.c); the SNMP agent is started byhostlink.c(it answers over the air in either USB personality). Receive: TCP segments to the radio's address go to the one TCP handler (ipGatewaySetTCP, tcp-services.md); UDP datagrams to the radio's address, or to the link-local block 224.0.0.0/24, on a port in the gateway's one table (ipGatewayUdpBind, at mostIPGW_UDP_PORTS= 8) go to that service, from any source address on the link. 224.0.0.0/24 never goes over the air, bound or not. A port bound withipGatewayUdpBindAiralso takes UDP received over the air for our own DMR ID (SAP 4 UDP or SAP 3, handed toipGatewayUdpFromAirbyhostlink_net_packetin the radio task, link up or not) instead of the host, with the sender's DMR ID andfromAirset; the service answers over the air itself. Bound now: mDNS on 5353 (mdns.md), TFTP on 69 and its transfer's own port (tftp.md), SNMP on 161, from the air too (snmp.md). Send to the link: one buffer and one path for TCP and UDP, layer 4 built atipGatewayLocalPayload()and sent withipGatewaySendLocal(ipGatewayUdpSendadds the UDP header first): from the radio's address, IP TTL 255, to the host's MAC, the broadcast MAC or a group's multicast MAC. - Debug link view, section
hotspot:usb.network usb.net_up usb.boot_network net.passthrough net.passthrough_active net.fixed_link net.link_ip net.link_in_force net.host_ip net.radio_ip net.frames_in net.to_air net.to_air_refused net.packets_air net.packets_up net.packets_dropped net.air_sent net.air_busy net.air_dropped net.air_failed; the compression and confirmed delivery counters are in thedmr_ipsection (dmr-data.md12.5), the radio's own services' in thenetsection. - Test hook: the debug link's DMR_DATA IP_SEND hands a UDP datagram to
ipGatewayIPToAir, the same path as a datagram from the host, without switching USB (tests/hil/test_dmr_ip.py).
The radio's own TCP services on its address (status page, KISS TNC, rigctld, APRS-IS) are in tcp-services.md; TCP to other radios is not carried.
Voice over the network personality (USRP audio, host PTT, COS) is designed in usb-audio.md 4.2; not built yet.
4. Tests
- Host:
tests/host/hostlinkNetTest.c(the gateway with a fake interface and fake air ops: unicast, multicast and raw to air, refusals, UDP serial both ways with chunking and a busy endpoint, IP and short data from the air, link down; then the default air side against a fake data service: the mailbox, the bursts decoded back by the codec's receiver into the IP / SAP 10 packet, SAP 3 for ports outside 4000-4099 and SAP 4 for 4007, the mailbox held while the service has the packet, a busy service retried, the 2 s give-up, the packet hook), and the gateway's ownipGatewayTest*with pcaps checked by tshark. Control station mode: section 5.6. - HIL:
tests/hil/test_usb_network.py(--allow-usb-switch).tests/hil/test_usb_network_services.pyruns the host port's protocols over UDP 3334: the split duplex extension (protocol, and the RX half's labels against a bench call with--allow-rf --allow-service-stop) and the KISS TNC (TNC mode, a host position from SetHardware 'N'). All pass on 4a9c002. One protocol at a time: the radio answers the sender of the last frame, so a tool closes its socket before another protocol's tool opens one.tests/hil/test_dmr_ip.py(on air, the bench modem decoding; its last test sends from the host over the USB network). The radio's own services:tests/hil/test_tcp_services.py,tests/hil/test_net_services.py(mdns.md5,tftp.md6).
5. Control station mode
Dispatch software on a PC (TRBOnet, SmartPTT and the like) uses a radio on USB as its control station: the
PC sends and receives the fleet's data through it, and it answers for the radio's own DMR ID itself (text messages,
registration, location). Switch: CONFIG_DMR_CONTROL_STATION (= CONFIG_DMR_IP_GATEWAY). The pieces:
5.1 Boot as the adapter
The stored usb_mode (section 1): the radio comes up as the network adapter at power-on, so the PC finds it
without anyone switching it, with the 10 s fallback to serial for a host that doesn't configure it.
5.2 The PC is the endpoint (passthrough)
Stored setting net_flags bit SETTINGS_NET_PASSTHROUGH (Settings > Connections > PC answers data). In force while
it is set and the network link is up (host configured: hostlink_net_passthrough_active(), view key
net.passthrough_active); with the PC unplugged the radio's own services answer again, so a radio left in this mode
is never deaf.
What passthrough changes is defined by one rule: everything with an IP header (SAP 4, or SAP 3 compressed UDP/IP) addressed to the radio's ID or one of its groups belongs to the PC; the link layer stays with the radio, because the PC can't send it.
| Packet to the radio | Without passthrough | With passthrough |
|---|---|---|
| any data packet | forwarded to the PC (as now) | forwarded to the PC |
| confirmed data (C_HEAD, A bit) | ETSI response from the radio | ETSI response from the radio (the PC can't send it) |
| ARS, UDP 4005 (answers to the radio's registration, queries) | the radio's ARS | ignored; the radio's own registration, refresh and deregistration stop (the PC registers) |
| Motorola TMS, UDP 4007 | inbox, TMS ACK from the radio | PC only: no ACK, no inbox entry |
| ETSI IP text, UDP 5016 | inbox | PC only |
| LRRP, UDP 4001 | answered by the radio | PC only; running triggers and queued requests are dropped when passthrough starts |
| other UDP, ICMP, SCTP | PC (no service of the radio's) | PC |
| ETSI defined short data, UDT text (no IP header) | inbox, ETSI response | unchanged (and forwarded raw on 40078) |
| call alert, radio check, emergency alarm (CSBK) | answered by the radio per its settings | unchanged |
| the TMS ACK of a message the radio's own user sent | completes it | still completes it (nothing is sent) |
The PC's replies (and anything else it sends to a radio ID or talkgroup) go out as now: built by the radio with its own
DMR ID as the source, so to the fleet they come from the control station. The data service counts what it left to the
PC in data.pt_yielded and shows data.pt_active (debug link section dmr_ext). Spec of the data side:
dmr-data.md 10.
5.3 Link addressing
Stored setting net_flags bit SETTINGS_NET_FIXED_LINK with net_link_ip (Settings > Connections > USB link
address: "By DMR ID" / "192.168.10.x"). It fits the gateway's design because the gateway's addressing has only two
link-local facts, the radio's address and the host's, and the rest (the 12/8 and 225/8 ranges, the air side) doesn't
depend on them:
| By DMR ID (default) | Fixed link | |
|---|---|---|
| radio | 12. |
net_link_ip, default 192.168.10.1 |
| host (DHCP) | 12. |
the next address, 192.168.10.2, netmask 255.255.255.0 |
| option 121 / 249 | the radio on the link, 12/8 and 225/8 via the radio | 12/8 and 225/8 (and the group range) via the radio |
| ARP answered | every address but the host's | the radio's address only |
| packets to the radio's own ID from the air | to 12. |
rewritten to the host's fixed address |
| broadcasts to the host (all call, monitor) | 255.255.255.255 | 255.255.255.255 |
| host to air broadcast | 255.255.255.255, the all call address | also the link's 192.168.10.255 |
| radios per host | several (each has its own pair) | one (every radio has the same pair) |
net_link_ip must be a unicast address outside the 12/8, 225/8 (and group) ranges with a last octet of 1-253;
anything else (or 0) means the default 192.168.10.1 when set from the UI, or the DMR ID addressing when the debug link
stores an unusable address (ipGatewaySetFixedLink refuses it; net.link_in_force shows what is used). A change
applies at once on the radio's side; the PC picks it up at its next DHCP exchange (the radio refuses the renewal of
the old address, so at the latest when the 10 min lease runs out; unplugging the cable is quicker).
What the dispatch application must be told, in either mode: the control station's IP address (the radio's link
address: 192.168.10.1 or 12.dmr-data.md 11: by default UDP
outside 4000-4099, ETSI text 5016 among them, goes with the SAP 3 compressed header; set ip.compress off for
full IPv4 headers). An application that expects to talk to
the radio itself over a vendor control protocol (XNL / XCMP and the like) can't use it: the radio is a router, not a
vendor control head.
5.4 Settings and the debug link
Setting (settings_t) |
Values | UI (Settings > Connections) | Debug link |
|---|---|---|---|
usb_mode |
0 serial (default), 1 network at power-on | USB network at start | radio op 27 bit 0, usb.boot_network |
net_flags & SETTINGS_NET_PASSTHROUGH |
off (default) / on | PC answers data | op 27 bit 1, net.passthrough, net.passthrough_active |
net_flags & SETTINGS_NET_FIXED_LINK |
off (default: by DMR ID) / on | USB address | op 27 bit 2, net.fixed_link |
net_link_ip |
0 = 192.168.10.1 | (the default only) | op 27 IP, net.link_ip, net.link_in_force |
Radio op 27 (debuglink.md 3.8): flags u8, link IP u32 little endian; stored, and applied at once
(hostlink_settings_changed). rfoundry_debug.py radio usb-settings [boot] [passthrough] [fixed[=IP]]; no words: all off.
5.5 Not done
- Confirmed data from the PC per packet: the PC can't ask for it itself; the data service confirms the gateway's
packets by its rules (per destination ID or UDP port,
dmr-data.md12), the same for the PC's packets as for any host's. - Voice: the control station passes data only (voice over the network personality is
usb-audio.md4.2).
5.6 Tests
- Host:
ipGatewayTest*testFixedLink(refused addresses, DHCP offer and routes, the renewal NAK, ARP, to air from the fixed address, the link broadcast, UDP serial, delivery to our ID rewritten to the host, all call and monitor broadcasts, ping, back to the DMR ID; written to the pcaps tshark checks);hostlinkNetTest(passthrough follows the link, the fixed link end to end: an ETSI IP text from the air reaches 192.168.10.2, the PC's answer goes over the air from the radio's ID);dmrDataServiceTesttestPassthrough(the table in 5.2);radioCoreTest(the settings records). - HIL:
tests/hil/test_control_station.py(opt-in, below). The lead runs it against a dispatch application.
HIL plan (tests/hil/README.md):
test_usb_settings_stored(radio,--allow-usb-switch): op 27 stores each bit; the view keys follow; all off at the end.test_boot_network(--allow-usb-switch --allow-reboot): withusb_mode1, a reboot comes back as 1FC9:0096 and the debug link answers overudp:<radio IP>; the setting is then cleared over UDP and the radio rebooted to serial. Manually on macOS 26: a host that doesn't configure the adapter gets the serial port back 10 s after plugging in.test_fixed_link(network mode): op 27 fixed, the host renews DHCP, gets 192.168.10.2, pings 192.168.10.1, the debug link answers atudp:192.168.10.1.test_passthrough_text(--allow-rf --allow-service-stop, SDR bench): passthrough on, the bench sends an ETSI IP text and a TMS with an ACK request to the radio's ID; the PC's socket on UDP 5016 / 4007 gets them, the radio's inbox count doesn't change,data.tms_acks_sentdoesn't move,data.pt_yieldeddoes; a confirmed packet still gets the radio's ETSI response (the bench hears it).test_passthrough_reply(same): the PC sends UDP 4007 to the bench's ID; the bench hears the packet with the radio's DMR ID as the source.- A dispatch application (manual): its control station set to the radio's link address, its own ARS / TMS / LRRP on. With passthrough on, a text from a fleet radio appears in the application and only there, its reply reaches the fleet radio from the control station's ID, a location request from it is answered by the fleet radio (the control station doesn't answer for itself), and the radio's own ARS registration does not appear in its log.