> Source: https://nexusatlas.dev/docs/2.0.0/concepts/serial-link — Nexus Atlas developer documentation (Concepts). Converted from the HTML page; the page is canonical. This is the 2.0.0 release line; the current line is 3.0.0 (https://nexusatlas.dev/docs/concepts/serial-link).

# Native serial link

A `[[link]] type = "serial"` owns a serial device — a SiK-family radio such as an RFD900, or any byte pipe — and puts Atlas datagrams on it directly. There is no pppd, no pseudo-terminal, no IP or UDP and no address plan per radio pair between Atlas and the radio. Above the frame nothing changes: the outer and inner headers, session sealing, sealed probes, the scheduler, routing, transport classes and end-to-end encryption see one more link in the bond. Both ends of the radio run Atlas; devices that only offer PPP or IP keep the SiK adapter and pppd.

## What the link replaces

```
through pppd:  radio ⇄ /dev/ttyUSB ⇄ SiK adapter ⇄ PTY ⇄ pppd
                     ⇄ ppp0 ⇄ IP + UDP ⇄ atlasd
native:        radio ⇄ /dev/ttyUSB ⇄ atlasd
```

Per packet the link drops the IPv4 header (20 bytes), the UDP header (8), PPP's protocol field, FCS and flag, and HDLC byte escaping. Per link it drops the adapter process, the pseudo-terminal, pppd and its setuid root, the IP plan, LCP negotiation after every reset, LCP echo next to Atlas probes, and the separate telemetry path over a socket. The radio's signal figures and its transmit-buffer fill now come from the same byte stream as the data.

## Framing

```
[0x00]  COBS( body ‖ CRC-16/X-25(body), low byte first )  0x00
body = Atlas datagram                         point to point
     | 0xA0 ‖ dst ‖ src ‖ Atlas datagram      addressed (multipoint)
```

- **COBS with a 0x00 delimiter.** Consistent Overhead Byte Stuffing removes every zero from the block, so 0x00 only ever marks a frame boundary. The cost is fixed: one code byte per 254 bytes. A datagram of up to 254 bytes costs **4 bytes** on the wire (code byte, CRC, delimiter); one more for every further 254. Because the cost is known before a frame is sent, the pacer and the fragment ceiling compute airtime exactly.

- **CRC-16/X-25** is PPP's FCS-16, so the link keeps exactly the error detection of the path it replaces. It discards damage early and counts it; integrity comes from the AEAD on every Atlas datagram above it, so a frame that slips past the CRC is still refused.

- **Resynchronisation is free.** A lost or corrupt byte damages one frame and the next delimiter starts a clean one. After the port opens, everything up to the first delimiter is discarded and counted in `resync_bytes`.

- **A leading delimiter** goes in front of a frame after a quiet line (20 ms or more) and in front of every addressed frame, which closes whatever partial bytes a reset radio, a replug or another node's packet left in the receiver.

The `0xA0` marker can never open an Atlas datagram (byte 0 is the protocol version), so a receiver tells the two forms apart without a negotiated mode.

## Bytes per packet

What each packet type costs on the line, natively and through pppd with `asyncmap 0` (UDP/IPv4, PPP protocol field, FCS, flag and HDLC escaping of the actual bytes):

| Packet | Atlas datagram | Native | Through pppd |
|--------|----------------|--------|--------------|
| Sealed probe or ack | 44 B | 48 B | ~76 B |
| Voice, 20 ms frame (60-byte IP packet) | 104 B | 108 B | ~137 B |
| Voice with the previous packet carried | 168 B | 172 B | ~201 B |
| Voice, 40 ms frame (92-byte IP packet) | 136 B | 140 B | ~169 B |

Fifty 20 ms voice frames a second are about 43 kbit/s on the wire natively against about 55 kbit/s through pppd. Measured on radios at 115 200 baud with 64 kbit/s air, a voice datagram of about 101 bytes cost **105.0 bytes** on the wire, against 133.6 through pppd with `asyncmap 0` (28.6 bytes saved per packet) and 182.6 with pppd's default asyncmap, which escapes 32 more byte values. The heartbeat that makes the radio report (below) is 17 bytes every 250 ms, about 0.85 % of 64 kbit/s.

## The radio's own frames

With MAVLink framing on (`ATS6=1`), a SiK-family radio injects a RADIO_STATUS frame into its serial output each time it sees a MAVLink HEARTBEAT from its host. For `radio = "rfd900"` or `"sik"` the link writes that heartbeat every [heartbeat_ms](https://nexusatlas.dev/docs/2.0.0/config#cfg-link-heartbeat_ms) (default 250); for `radio = "generic"`, the default, it writes none. The radio forwards the heartbeat over the air too, and the far end strips it.

The radio inserts its status frame between air packets, so it can land in the middle of an Atlas frame, and it contains zero bytes. The extractor therefore runs on the raw stream *before* the deframer. It consumes only bytes that match a MAVLink v1 or v2 frame of length 9 carrying a HEARTBEAT or RADIO_STATUS with a valid checksum; anything else passes through byte for byte, and a half-seen candidate is released as data after 100 ms of silence. RSSI, noise, remote RSSI and noise and the buffer fill go straight into the link monitor, the same call the telemetry socket makes: the link score, RSSI-trend prediction and the link-state advert carry them with source `atlasd-serial`, with no adapter process.

## Pacing on the radio's buffer

Three queues stand between a released frame and the air:

1. **The writer hand-off** — frames given to the writer thread and not yet in the kernel, counted exactly.

2. **The serial line** — the kernel's tty queue (read with `TIOCOUTQ`), the USB adapter and the UART, drained at baud ÷ 10 bytes per second and integrated from what was written.

3. **The radio's transmit buffer** — each RADIO_STATUS report gives its free space (`txbuf` percent × [radio_buffer_bytes](https://nexusatlas.dev/docs/2.0.0/config#cfg-link-radio_buffer_bytes)); between reports the model drains it at the air rate. Two consecutive reports with the buffer busy measure the air rate the radio actually gets, never above the declared `air_rate`.

The sum is the link's queue reading for the low-band pacer, the same pacer, counters and dashboard chip a qdisc link uses. The high band — Control, Voice, Position — is never held; it goes to the writer at once. The low band waits in Atlas's own queue while the bytes below exceed `max(rate × [qos] low_band_target_ms, floor)`. While the high band has been used in the last second, the floor is capped at one fragment's airtime (20 ms), so a voice frame waits behind at most that and the frame just released. With no high band in use the floor is at least 100 ms of airtime, so a half-duplex radio fills its transmit windows. The link is paced whenever [buffer_pacing](https://nexusatlas.dev/docs/2.0.0/config#cfg-link-buffer_pacing) is true (the default), with or without `[qos]`; `false` writes as fast as the port takes it, as pppd's path does.

Measured on radios at 115 200 baud with 64 kbit/s air, 50 voice frames a second beside 1 400-byte video offered at about 110 kbit/s: paced, voice one-way p95 was **100.6 ms** full duplex and **131.4 ms** half duplex, 97–100 % delivered, with the video still carrying about 17 kbit/s; unpaced, about 1.95 s and 6 % delivered.

[flow_control = "rtscts"](https://nexusatlas.dev/docs/2.0.0/config#cfg-link-flow_control) sets hardware flow control: the radio stops the line when its buffer is full instead of dropping the excess. Pacing works with or without it.

## Multipoint addressing

A radio channel shared by several nodes — multipoint firmware with every radio's destination set to broadcast — delivers every node's bytes to every other node. Setting [address](https://nexusatlas.dev/docs/2.0.0/config#cfg-link-address) (1–254) switches the link to addressed frames: three bytes, `0xA0 ‖ dst ‖ src`, in front of the datagram. A receiver keeps frames for its own address and for 255 (broadcast), counts the rest in `not_for_us`, and refuses frames whose source is 0, 255 or its own address. [peer_addresses](https://nexusatlas.dev/docs/2.0.0/config#cfg-link-peer_addresses) tells this node where a peer sits on the channel; a peer not listed is addressed to broadcast until its first authenticated frame arrives, and from then on to the address that frame came from, the way endpoint roaming learns a UDP address. Three radios on one channel reach each other in every pair.

## Liveness and recovery

Atlas probes are the liveness check, as on every link: dead after `dead_probe_count` misses at the link's `probe_interval_ms`, alive on the next completed round trip. There is no LCP and no second state machine. The session belongs to the peer, not the link, so a link that comes back carries traffic on its first probe. When the device goes away the reader closes it and reopens it every 500 ms; a `/dev/serial/by-id/` path finds the same radio after re-enumeration. Once open, the device is held exclusively (`TIOCEXCL`), so a forgotten pppd or adapter gets `EBUSY` instead of splitting the byte stream. Measured: traffic back **53 ms** after a radio reset and **194 ms** after an unplug and replug. Each reopen journals `link.serial_reopened`.

SiK-family radios are half duplex on air: one side talks at a time and both directions share the air rate. Set the link's `probe_interval_ms` to 500–1000 and leave `unusable_rtt_ms` at its default.

## Configuration

```
[[link]]
name = "rfd"
type = "serial"
device = "/dev/serial/by-id/usb-FTDI_FT231X_USB_UART_D30A1B2C-if00-port0"
baud = 57600              # 8N1; must match the radio (ATS1)
radio = "rfd900"          # rfd900 | sik | generic (default)
air_rate = "64 kbps"      # ATS2; capacity defaults to min(baud × 0.8, air_rate)
flow_control = "none"     # or "rtscts"
probe_interval_ms = 1000
# peers = ["10.0.100.2"]  # who is at the other end (default: any peer)

# Several nodes on one broadcasting channel:
# address = 1
# peer_addresses = { "10.0.100.2" = 2, "10.0.100.3" = 3 }
```

The other keys are `heartbeat_ms`, `radio_buffer_bytes` (default 2048, 64 to 1 048 576) and `buffer_pacing`. A serial link has no socket: it takes no `bind_device` or `bind_address`, and its peers need no endpoint. Config load refuses, by name: a serial key on any other link type, a baud outside 9 600–921 600, address 0 or 255, an address given twice, `peer_addresses` without an own `address`, `peers` on an addressed link, and two serial links on one device. Every key is in the [configuration reference](https://nexusatlas.dev/docs/2.0.0/config#cfg-link); the [serial radio guide](https://nexusatlas.dev/guides/serial-radio) walks a radio pair from pppd to native.

## Seeing it

The snapshot carries `links[].serial` for each serial link:

| Fields | What they tell you |
|--------|--------------------|
| device, baud, flow_control, radio, address | The link as configured. |
| open, reopens, open_errors, last_error | Whether the device is held now, how often it came back, and why the last open failed. |
| frames_tx, frames_rx, wire_tx_bytes, wire_rx_bytes | Traffic, with framing and the radio's frames counted in the wire bytes. |
| crc_errors, framing_errors, resync_bytes, uart_frame_errors, uart_overruns | Damage on the line. A baud mismatch shows here while frames_rx stays flat; the UART counters exist on real serial devices. |
| not_for_us, rx_overflow, tx_dropped_closed | Frames for other nodes on a shared channel, datagrams the engine did not take in time, sends while the device was closed. |
| radio_status_frames, heartbeats_tx, heartbeats_rx, radio_status_age_secs | Whether the radio reports, and how fresh its last report is. |
| handoff_bytes, tty_queued_bytes, line_bytes_est, radio_bytes_est, radio_buffer_free_pct, air_rate_kbps, radio_overflow_bytes_est | The three queues below Atlas, the air rate the pacer drains at, and what the radio is estimated to have dropped for lack of room. |

RSSI and noise appear in the link's existing `radio_*` fields, and the queue below the link in `egress_queue.serial_bytes` and `queue_delay_ms`, which the scheduler already reads (see [Queues below the tunnel](https://nexusatlas.dev/docs/2.0.0/concepts/scheduling#queues)). The dashboard's link card shows a `serial <baud>` chip, with the node address on a shared channel and amber while the device is closed, and rows for the device, frames, errors and the radio's buffer; the error row turns amber when more than 1 % of arriving frames are damaged.

## Boundaries

- Atlas to Atlas only: both ends of the radio run a build with the serial link. A device that offers only PPP or IP — a dial-up modem, a satcom terminal, a tactical radio with a PPP port — keeps the SiK adapter and pppd on an ordinary direct link.

- On a shared channel the engine sends one unicast copy per peer for link-state floods and gossip, where one broadcast frame would reach every node.

- When a radio's transmit slot ends mid-frame, frames from two senders can interleave at a receiver: the split frame is lost and counted in `framing_errors`, and the next frame survives on its leading delimiter.

- Half-duplex radios share the air rate between both directions; the pacer drains at the rate the radio reports it actually gets.

- Atlas does not configure the radio. Baud, air rate, MAVLink framing, flow control and multipoint destinations are set on the radio with its own AT commands.

- The link counts only its own writes; another process writing to the same radio is locked out by the exclusive open rather than accounted for.

The engineering view, with the two chains, a packet's bytes on the wire and pacing over time drawn out, is on [nexusatlas.io](https://nexusatlas.io/serial-link).
