When exploring raspberry pi projects for ham radio, the most practical and universally useful build is an APRS (Automatic Packet Reporting System) iGate. An iGate bridges local RF packet traffic to the global APRS-IS (Internet Service) network, allowing your local station to push GPS, weather, and telemetry data worldwide. While pre-packaged software suites exist, building a custom Python-based iGate from scratch gives you total control over KISS (Keep It Simple, Stupid) frame parsing, GPIO-based PTT (Push-To-Talk) control, and error logging.

This guide walks through building a headless APRS iGate using a Raspberry Pi, a serial KISS TNC, and an opto-isolated PTT circuit. We will cover the exact hardware decisions, GPIO pin mapping, and a complete Python script with robust error handling.

Choosing the Right Hardware for Ham Radio Digital Modes

The ham shack environment demands 24/7 reliability, low power consumption, and adequate DSP (Digital Signal Processing) headroom if you ever decide to add software modems like Dire Wolf. Here is the decision matrix for selecting your compute module.

Board Variant Power Draw (Idle) DSP Headroom Thermal Profile Verdict
Raspberry Pi Zero 2 W ~1.2W Low (Struggles with multi-channel Dire Wolf) Throttles under sustained RF decoding Pass for iGates; okay for simple WSPR beacons.
Raspberry Pi 4 Model B (4GB) ~2.7W High (Easily handles 2+ audio channels) Warm, but stable with passive heatsink Default Pick. The gold standard for headless shack nodes.
Raspberry Pi 5 (8GB) ~3.8W Overkill Requires active cooling (fan) Pass. Unnecessary cost and thermal noise for basic packet.
Decision Path Termination: Buy the Raspberry Pi 4 Model B (4GB). It offers native USB 3.0 for SDRs, Gigabit Ethernet for reliable APRS-IS uplinks, and runs cool enough to live inside a sealed Pelican case for portable go-kit deployments.

Parts List and Pin/Port Mapping

This build targets the Raspberry Pi 4 Model B (4GB) running Raspberry Pi OS Bookworm (64-bit, headless). We are using the hardware UART (PL011) to communicate with a serial TNC, and a GPIO pin to trigger the radio's PTT line safely via an optocoupler.

Bill of Materials

  • Compute: Raspberry Pi 4 Model B (4GB) with 16GB+ microSD (Class 10)
  • TNC: Byonics TinyTrak4 or any serial KISS TNC (e.g., Kantronics KPC-3+)
  • Isolation: 4N35 Optocoupler IC
  • Passives: 330Ω resistor (1/4W), 10kΩ pull-down resistor
  • Radio: Any VHF/UHF FM transceiver with a data/PTT port (e.g., Yaesu FT-7900, Baofeng UV-5R with APRS cable)

GPIO Pin Mapping Table

Pi 4 GPIO (Physical Pin) Function Destination Notes
GPIO 14 / TXD (Pin 8) Serial Transmit TNC RXD Cross TX to RX. Use a logic level shifter if TNC expects strict 5V, though 3.3V usually works on modern opto-isolated TNCs.
GPIO 15 / RXD (Pin 10) Serial Receive TNC TXD Cross RX to TX.
GPIO 17 (Pin 11) PTT Control (Output) 4N35 Anode (via 330Ω) Drives the optocoupler LED. High = Transmit.
GND (Pin 9) Common Ground TNC GND & 4N35 Cathode Do not mix Pi GND with Radio chassis ground directly; let the optocoupler isolate the PTT circuit.

Wiring the Serial TNC and PTT Control

Safety Callout: Always disconnect the radio from power and the Pi from its PSU before wiring GPIO pins. Incorrectly wiring 5V from a radio's accessory port into the Pi's 3.3V GPIO will instantly destroy the Pi's SoC. Never connect radio audio/PTT lines directly to GPIO without galvanic isolation (the 4N35 optocoupler).
  1. Enable the Hardware UART: By default, the Pi 4 maps the hardware UART (/dev/ttyAMA0) to the Bluetooth module. Edit /boot/firmware/config.txt and add dtoverlay=disable-bt. Reboot. This frees the PL011 UART for the GPIO pins.
  2. Wire the Serial Lines: Connect Pi Pin 8 (TXD) to the TNC's RXD. Connect Pi Pin 10 (RXD) to the TNC's TXD. Connect Pi Pin 9 (GND) to the TNC's Signal GND.
  3. Build the PTT Isolator: Connect Pi GPIO 17 (Pin 11) to one leg of the 330Ω resistor. Connect the other leg to the Anode (Pin 1) of the 4N35 optocoupler. Connect the Cathode (Pin 2) to Pi GND.
  4. Wire the Radio Side: Connect the 4N35 Collector (Pin 5) to your radio's PTT line. Connect the Emitter (Pin 4) to the radio's PTT ground. Add a 10kΩ pull-down resistor between the PTT line and PTT ground to prevent floating-trigger transmissions.
  5. Verify with a Multimeter: Before connecting the radio, power the Pi and set GPIO 17 HIGH via Python. Measure across the 4N35 Collector and Emitter; it should read near 0Ω (closed switch). When LOW, it should read OL (open).

The Python APRS KISS Parser and iGate Script

This script reads KISS frames from the serial TNC, strips the KISS framing bytes, and forwards the raw AX.25 payload to the APRS-IS network. It includes hardware PTT control and robust error handling.

Prerequisites: pip install pyserial aprslib RPi.GPIO

import serial
import aprslib
import RPi.GPIO as GPIO
import logging
import time

# --- CONFIGURATION & PIN DEFINITIONS ---
PTT_PIN = 17                  # Physical Pin 11
SERIAL_PORT = '/dev/ttyAMA0'  # Hardware UART on Pi 4 (Bluetooth disabled)
BAUD_RATE = 9600
APRS_CALLSIGN = 'N0CALL-10'   # Replace with your callsign and SSID
APRS_PASSCODE = '12345'       # Replace with your generated APRS-IS passcode
APRS_SERVER = 'rotate.aprs2.net'
APRS_PORT = 14580

# --- LOGGING SETUP ---
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[logging.FileHandler("igate.log"), logging.StreamHandler()]
)
logger = logging.getLogger(__name__)

# --- GPIO SETUP ---
GPIO.setmode(GPIO.BCM)
GPIO.setup(PTT_PIN, GPIO.OUT, initial=GPIO.LOW)

def set_ptt(state: bool):
    """Asserts or de-asserts the PTT line via the optocoupler."""
    GPIO.output(PTT_PIN, GPIO.HIGH if state else GPIO.LOW)
    # Allow physical relay/opto switching time
    time.sleep(0.05) 

def strip_kiss_frame(raw_data: bytes) -> bytes:
    """Removes KISS framing bytes (0xC0 start/end, 0x00 data cmd)."""
    # KISS frames start and end with 0xC0. Data command byte is usually 0x00.
    frame = raw_data.strip(b'\xc0')
    if frame.startswith(b'\x00'):
        frame = frame[1:]
    return frame

def main():
    logger.info(f"Starting APRS iGate for {APRS_CALLSIGN}")
    
    # 1. Initialize Serial Connection to TNC
    try:
        ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1)
        logger.info(f"Serial port {SERIAL_PORT} opened successfully.")
    except serial.SerialException as e:
        logger.critical(f"Failed to open serial port: {e}")
        return

    # 2. Initialize APRS-IS Connection
    try:
        ais = aprslib.IS(APRS_CALLSIGN, passwd=APRS_PASSCODE, host=APRS_SERVER, port=APRS_PORT)
        ais.connect()
        logger.info("Connected to APRS-IS network.")
    except Exception as e:
        logger.critical(f"APRS-IS Connection failed: {e}")
        ser.close()
        return

    # 3. Main Read/Forward Loop
    try:
        while True:
            try:
                if ser.in_waiting > 0:
                    # Read until the KISS end byte (0xC0)
                    raw = ser.read_until(b'\xc0')
                    if len(raw) > 5:  # Ignore empty or noise frames
                        payload = strip_kiss_frame(raw)
                        if payload:
                            logger.info(f"RX RF Frame: {payload.hex()}")
                            # Push to APRS-IS (aprslib handles the raw AX.25 upload)
                            ais.sendall(payload)
                            logger.info("TX -> APRS-IS successful.")
            except serial.SerialException as e:
                logger.error(f"Serial read error: {e}")
                time.sleep(5)
                
            except aprslib.exceptions.ConnectionError as e:
                logger.error(f"APRS-IS dropped: {e}. Reconnecting in 30s...")
                time.sleep(30)
                ais.connect()
                
            except Exception as e:
                logger.error(f"Unexpected parsing error: {e}")
                
    except KeyboardInterrupt:
        logger.info("Shutting down iGate...")
    finally:
        set_ptt(False)
        GPIO.cleanup()
        ser.close()
        logger.info("GPIO cleaned up and serial port closed.")

if __name__ == '__main__':
    main()

Debugging: Serial Permissions and Connection Refusals

When deploying headless nodes, you will inevitably hit environment-level roadblocks. Here is how to diagnose the two most common fatal errors.

Error 1: Serial Port Permission Denied

Exact Error String: serial.serialutil.SerialException: [Errno 13] Permission denied: '/dev/ttyAMA0'

Ranked Causes:

  1. User not in dialout group: The default 'pi' or custom user lacks hardware access rights.
  2. Serial Console is active: The OS is using the UART for shell output, locking the port.
  3. Bluetooth not disabled: The PL011 UART is still mapped to the Bluetooth chip, leaving /dev/ttyAMA0 unattached to the GPIO pins.

The Fix: Run sudo usermod -a -G dialout $USER and reboot. Then run sudo raspi-config, navigate to Interface Options -> Serial Port, select No to "Would you like a login shell to be accessible over serial?", and Yes to "Would you like the serial port hardware to be enabled?".

Error 2: APRS-IS Connection Refused

Exact Error String: aprslib.exceptions.ConnectionError: [Errno 111] Connection refused (or aprslib.exceptions.ConnectionError: Unable to connect to APRS-IS)

Ranked Causes:

  1. Invalid Passcode: You used a fake passcode or generated it for a different callsign. APRS-IS requires a mathematically verified hash of your callsign for upstream traffic.
  2. Firewall Blocking Port 14580: Your local network or ISP is blocking outbound TCP traffic on the standard APRS-IS port.
  3. Server Rotation Failure: The DNS resolution for rotate.aprs2.net failed or the specific regional server is down.

The Fix: Generate a valid passcode using the MagicBug APRS Passcode Generator. If the passcode is correct, test connectivity via terminal: telnet rotate.aprs2.net 14580. If it times out, check your router's outbound firewall rules.

The First Three Things to Check When It Fails:
  1. Verify UART mapping: Run ls -l /dev/serial*. Ensure /dev/serial0 symlinks to ttyAMA0, not ttyS0.
  2. Check PTT isolation: Use a multimeter in continuity mode across the radio's PTT plug. If it reads shorted while the Pi is idle, your optocoupler is wired backward or damaged.
  3. Validate APRS-IS credentials: Ensure your callsign string in the Python script exactly matches the passcode generator input (case-insensitive, but drop the SSID for the hash generation).

Extending or Simplifying the Build

Depending on your shack's needs, you may want to pivot from this custom Python script to a more specialized setup.

How to Simplify (The Pre-Built Route)

If you don't want to maintain custom Python code, abandon this script and flash Dire Wolf or the Pi-Star image. Dire Wolf acts as a software TNC, meaning you can bypass the hardware Byonics TinyTrak entirely. You simply plug a Digirig Mobile USB soundcard into the Pi, connect the audio cables to your radio, and let Dire Wolf decode the audio natively via the Pi's CPU. This eliminates the GPIO UART wiring and KISS framing logic entirely.

How to Extend (Adding SDR and WSPR)

To expand this node into a multi-mode digital shack computer:

  • Add an SDR: Plug an RTL-SDR Blog V4 into the Pi's USB 3.0 port. Use rtl_fm to pipe raw audio directly into a virtual ALSA loopback device, allowing the Pi to monitor the APRS calling frequency (144.390 MHz in North America) without tying up your primary transceiver.
  • Add WSPR: Install WSPR (Weak Signal Propagation Reporter) software. You can use the same GPIO PTT optocoupler circuit to key an HF transceiver. WSPR requires precise timing, so ensure your Pi is running an NTP daemon (chrony or systemd-timesyncd) to keep the system clock synced to within a few milliseconds, as WSPR transmission windows are strictly time-slotted.

For further reading on APRS network topology and acceptable iGate behavior, consult the ARRL APRS documentation and the APRS-IS server guidelines. Always ensure your RF transmissions comply with your local amateur radio licensing regulations regarding automated unattended stations.