A lightweight APRS iGate written in native C for the Raspberry Pi Zero 2 W with the 64-bit Raspberry Pi OS. It receives LoRa (433 MHz) packets from a Guru-RF RX-only LoRa HAT and forwards them to APRS-IS. No runtime dependencies beyond libc — no Python, no Blinka, no pip packages.
What it does:
- Receives LoRa packets at 433.775 MHz (SF12 / BW125 / CR4:5, RadioHead-style)
- Forwards packets carrying the
0x3C 0xFF 0x01prefix to APRS-IS over TCP - Beacons the iGate position every 15 minutes
- Blinks the activity LED on each received packet
- Logs to a remote RFC5424 syslog (and to stdout / journald)
| File | Purpose |
|---|---|
igate.conf |
Station configuration (callsign, location, hosts, pins) — read at runtime |
config.c/.h |
Config file parser + defaults |
igate.c |
Main loop: APRS-IS connection, RX polling, beacon, LED |
rfm9x.c/.h |
SX127x LoRa driver over Linux spidev |
aprs.c/.h |
Base-91 APRS position + timestamp encoding |
gpio.c/.h |
GPIO output via the kernel character device (uAPI v2) |
log5424.c/.h |
RFC5424 syslog over UDP |
Makefile |
Build |
install.sh |
Build + install as a systemd service |
piaprsigate.service |
systemd unit |
- SPI uses
/dev/spidev0.1. On the Pi,spidev0.1is chip-select CE1 = BCM GPIO7, which is the pin the original Python used as the radio CS (board.D7). Each register transaction is a single SPI message so CS stays asserted across the address + data bytes, matching the CircuitPython driver. If your HAT wires CS to CE0 (BCM GPIO8) instead, setspi_device = /dev/spidev0.0inigate.conf. - GPIO (reset BCM25, activity LED BCM19, power LED BCM13) uses the Linux GPIO character device (uAPI v2). This works on Bullseye, Bookworm and Trixie without depending on a specific libgpiod version, and the chip that owns the 40-pin header is auto-detected.
On the Pi:
sudo apt update
sudo apt install -y build-essential git
git clone https://github.com/Guru-RF/PILoRa433APRSiGate.git
cd PILoRa433APRSiGate
makeThis produces the piaprsigate binary.
All configuration lives in igate.conf, a plain key = value text file
read at startup (callsign, passcode, lat/lon, altitude, symbol, APRS-IS
host/port, syslog host/port, GPIO pins, frequency, TX power). Editing it does
not require a recompile — just restart the service:
sudo vi /opt/PiAPRSiGate/igate.conf
sudo systemctl restart PiAPRSiGateThe config path is resolved in this order: the command-line argument
(piaprsigate /path/to/igate.conf), then /opt/PiAPRSiGate/igate.conf, then
./igate.conf in the current directory. Any key you omit keeps its built-in
default; unknown keys and malformed lines are warned about and skipped.
# and ; start comments; # is also allowed inline (preceded by a space).
sudo ./install.shThis installs build dependencies, ensures SPI is enabled in
/boot/firmware/config.txt, compiles, installs the binary and a default
igate.conf (an existing one is preserved) to /opt/PiAPRSiGate/, and enables
the PiAPRSiGate.service systemd unit.
If you just enabled SPI for the first time, reboot before starting so
/dev/spidev0.xappears.
sudo systemctl start PiAPRSiGate
sudo systemctl status PiAPRSiGateThe service logs to stdout, which systemd captures in the journal (it also
sends RFC5424 syslog to syslog_host if configured). View it with
journalctl:
# Follow live (most common — like tail -f)
journalctl -u PiAPRSiGate -f
# Last 100 lines
journalctl -u PiAPRSiGate -n 100
# Everything since the last boot
journalctl -u PiAPRSiGate -b
# Only today / a time window
journalctl -u PiAPRSiGate --since today
journalctl -u PiAPRSiGate --since "2026-06-08 09:00" --until "2026-06-08 17:00"
# Errors only (the app logs RX/TX at info, failures at err)
journalctl -u PiAPRSiGate -p err
# Newest first, no pager (dump to terminal)
journalctl -u PiAPRSiGate -r --no-pager
# Grep received/sent packets
journalctl -u PiAPRSiGate -f | grep -E "Received|Sent packet"Useful flags: -u selects the unit, -f follows, -n N limits lines, -b
is this boot, -p err filters by priority, -o short-iso shows ISO
timestamps, and --no-pager prints straight to the terminal. If the journal
isn't persistent across reboots, enable it once with
sudo mkdir -p /var/log/journal && sudo systemctl restart systemd-journald.
Not running under systemd? Start it in a terminal —
sudo /opt/PiAPRSiGate/piaprsigate /opt/PiAPRSiGate/igate.conf— and the same lines print straight to stdout.
- The Python
asynciodesign (three concurrent tasks + a queue) is replaced by one cooperative loop that polls the radio every 10 ms, fires the 15-minute beacon, drains the APRS-IS socket to detect disconnects, and advances a non-blocking LED blink state machine so the blink never stalls reception. - TCP keepalive options (
SO_KEEPALIVE,TCP_KEEPIDLE=300,TCP_KEEPINTVL=30,TCP_KEEPCNT=5) match the original. SIGTERM/SIGINTshut the radio down and turn the LEDs off cleanly.- The base-91 position encoder was verified to produce byte-identical output to
the original
APRS.py. lora_timeoutis cosmetic (logged once at startup). Because reception is a non-blocking poll rather than a blocking timed receive, it does not bound a receive window. The default (900) is kept for parity with earlier versions.
Built by RF.Guru for experimental APRS use with LoRa on
Raspberry Pi. The rfm9x driver is a C port of Adafruit's MIT-licensed
CircuitPython RFM9x library (© 2017 Tony DiCola / Jerry Needell, Adafruit
Industries).
MIT License — see LICENSE. Use it, fork it, improve it!