TrailLink, an amateur radio data link

A hackathon build log for a custom 4-FSK packet link: phone, Raspberry Pi field node, FM handheld, HackRF base station, and short replies over RF.

TrailLink was my Blackbird hackathon project: an amateur radio data link for off-grid comms. A phone with no internet sends a short message to a base station, gets a reply, and receives that reply back over RF.

The phone joins a Raspberry Pi hotspot and uses a small web UI. The Pi turns the message into audio modem tones, keys an FM handheld, and sends a packet. At the other end, a HackRF and laptop demodulate the packet, optionally ask Claude for a short reply, and transmit the response back through the same link.

The link is built for short plain-text messages, predictable framing, and diagnostics that say where it failed: sync, CRC, FEC, audio level, or the radio path.

TrailLink architecture diagram showing a phone, Raspberry Pi field node, amateur radio link, HackRF receiver, laptop base station, and Claude API.
The hackathon architecture. The Rust modem runs 600 symbols per second at two bits per symbol, so the raw rate is 1200 bps, with CRC blocks and optional FEC in the frame layer.

The modem

The modem crate is written in Rust because I like Rust. I wanted to test the protocol in software before blaming a sound card, squelch, handheld audio path, or RF noise, so the crate has no hardware or async dependencies: it takes frames to samples and samples back to frames.

Each symbol chooses one of four audio tones: 600, 1200, 1800, or 2400 Hz. At 48 kHz and 600 symbols per second, every symbol is exactly 80 samples. To keep the demodulator simple, the tones are spaced at the symbol rate, which makes them orthogonal over one symbol window.

Restarting the sine wave at every symbol boundary would put a jump in the audio and spread extra energy around whenever the symbol changes. To keep the transmitted audio clean, the modulator uses a continuous phase accumulator instead.

What the radio words mean

4-FSK means four-frequency shift keying. Each symbol is one of four tones, so each tone carries two bits. At 600 symbols per second, that gives 1200 raw bits per second before headers, CRC bytes, and FEC overhead.

CRC is a checksum for catching corrupted frames. The receiver runs the same CRC-16 calculation over the received bytes and compares the result with the CRC that came in the packet. If they differ, the frame got corrupted and should be dropped.

FEC is forward error correction: extra bits that let the receiver repair a damaged frame instead of dropping it. It burns airtime, but it can turn a marginal packet back into a valid one. I used Hamming(7,4): split a byte into two nibbles, add parity bits to each nibble, and the receiver can correct a single bad bit in each 7-bit codeword.

The receiver only needs to know which of four tones is loudest, so a full FFT for every symbol is overkill. A Goertzel filter is a cheap way to measure one specific frequency: the receiver checks the four expected tones and picks the one with the most energy.

What a packet looks like

A packet starts with an alternating-symbol preamble, then a 32-bit sync word. After sync, the receiver expects a compact frame: version and message type, a 16-bit payload length, payload bytes, and interleaved CRC-16 blocks every 255 bytes.

To help short field messages survive a rough channel, optional Hamming(7,4) FEC is applied after CRC insertion, at the cost of airtime. Each byte becomes two protected nibbles.

Waterfall and spectrum view of recorded TrailLink 4-FSK modem tones.
Recorded modem tones. The bright vertical bands are the packet symbols moving between the four audio frequencies.

Receiving without fancy DSP

The receiver has no reference for the phase of the incoming tones, so the demodulator is noncoherent: it only cares how much energy each tone has. For each symbol window it checks tone energy, chooses the strongest bin, and rebuilds bytes from two-bit symbols.

The receiver also doesn't know where a packet starts, so it finds one by correlation: scan for the 32-bit sync pattern, refine around the best candidate, then decode the header and payload from there.

A radio packet link fails in parts. Did it hear energy but miss sync? Did the header decode but CRC fail? Did the frame pass with a pile of corrections? To tell which part failed, the receiver reports sync score, SNR estimate, CRC pass or fail, FEC corrections, and the last decode error.

The field node

The field side is a Raspberry Pi target with an axum HTTPS server and a compact phone UI. The browser can send text, attach a manual or browser-provided location, start repeated SOS transmissions, and upload small JPEGs that get split into 250-byte chunks.

Audio I/O can use command-line audio paths to feed the handheld through a USB sound card. Not every burst of incoming audio is a packet, so software VOX and gating decide when it is worth trying to decode.

Raspberry Pi field node hardware with a battery pack, cables, and a handheld radio.
Field node hardware before pack-up. For the demo it was rigged onto my rucksack, with the Pi, battery, audio path, and handheld arranged as a carryable radio node.

The base station

The base program is a ratatui dashboard around the receiver, transmitter, link stats, image handling, and reply mode. In manual mode, I type the reply. In Claude mode, the reply still has to fit the radio frame budget, so the base station asks for a short answer and trims it before transmitting it back with the callsign prefix.

Model output ends up as a low-bandwidth radio payload, so the prompt is part of the radio design. It asks for terse plain text, avoids tables and long lists, and includes a field location hint when the incoming packet provides one.

Laptops, radio hardware, cables, and waterfall displays from the TrailLink bench test setup.
Base station side of the prototype: HackRF, base laptop, cables, and waterfall views while tuning packet levels.

Next work

Next is range testing with better antennas and a cleaner audio path, then better retransmission. Image chunks already have the shape of selective retransmit, but the main win is still text: a short-message link with explicit diagnostics and no dependency on mobile data at the field end.

I also want a cleaner enclosure and battery story for the field node. A hackathon prototype can be cables and tape. Field gear has to keep working when the demo-room assumptions disappear.