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 - Derives the APRS-IS passcode from your callsign — nothing to look up
- Beacons the iGate position every 15 minutes
- Keeps listening while APRS-IS is down and queues what it hears
- Blinks the activity LED on each received packet
- Logs to the systemd journal — no logging config, no network code in the log path
One line on a fresh Raspberry Pi OS (64-bit) image:
wget -qO /tmp/install-igate.sh https://raw.githubusercontent.com/Guru-RF/lora-aprs-igate/main/install-igate.sh && sudo bash /tmp/install-igate.shThat is the whole install. install-igate.sh:
- Upgrades the Pi —
apt-get update,full-upgrade,autoremove,clean. - Installs the build dependencies —
build-essential,git,curl, and gum for the prompts (distro package first, then Charm's apt repo; plain text prompts if neither is reachable, so the install never dead-ends on a cosmetic dependency). - Enables SPI and the LED GPIOs in
/boot/firmware/config.txt— see below. The file it found is kept asconfig.txt.aprsigate.bak. - Builds and installs — compile,
make check, binary and defaultigate.confinto/opt/APRSiGate/,APRSiGate.serviceenabled. - Asks for your callsign and APRS SSID —
ON6UREand2becomeON6URE-2— and optionally your position, by street address. It writes them intoigate.confand validates the result withaprsigate -t, which prints the passcode it derived from your call. - Offers to reboot, which SPI needs before
/dev/spidev0.xappears.
Run it again any time to upgrade: your igate.conf is preserved, and every
prompt is pre-filled with what is already configured, so you can press Enter
straight through.
wget -qO- … | sudo bashworks too — the script notices it was piped, where stdin is the script rather than you, and re-attaches to your terminal so the prompts still work.
Nobody knows their own latitude, so the position step takes an address and looks it up — the same idea as the SVXLink hotspot config:
Location -- a full street address gives the most accurate fix, and Belgium is
assumed if you leave the country off. Empty to type coordinates yourself:
> Grote Markt 1, Brugge
Looking up Grote Markt 1, Brugge, Belgium ...
Historium, 1, Markt, Brugge-Centrum, Brugge, West-Vlaanderen, 8000, België
Found 51.2092401, 3.2250659
Ground elevation there is about 13 m
Latitude, longitude and altitude are then offered pre-filled, so you can accept them with Enter or correct them. What you can type:
| Input | What happens |
|---|---|
Brugge |
Geocoded; , Belgium is appended when you name no country |
Grote Markt 1, Brugge |
Full street address, most accurate |
Utrecht, Netherlands |
Any country works, name it and it is used as-is |
51.2194, 4.4025 |
Recognised as coordinates, used directly, no lookup |
| (empty) | Straight to the manual prompts, no network call |
Geocoding is Nominatim and the elevation
comes from Open-Meteo — both free, neither needs an
API key. The whole step soft-fails: no curl, no internet or no match keeps
whatever igate.conf already holds, and the address you type is never stored,
only the coordinates. Those coordinates do go out in every beacon, so round
them off if you would rather not publish your doorstep.
SPI and the LEDs are written as one managed block, rewritten in place instead of appended to, so re-running the installer never stacks duplicates:
# >>> RF.Guru LoRa APRS iGate -- managed by install-igate.sh >>>
dtparam=spi=on
gpio=13,19=op,dl
# <<< RF.Guru LoRa APRS iGate <<<dtparam=spi=on is what creates /dev/spidev0.x; without it the radio cannot
be reached at all. The gpio= line declares the power and activity LED lines
(pin_pwrled and pin_led, read back from your igate.conf) as outputs driven
low, so they stay dark from power-on until the daemon claims them. GPIO itself
needs no switch in config.txt — the iGate drives it through the kernel GPIO
character device, which is always available.
| File | Purpose |
|---|---|
igate.conf |
Station configuration (callsign, location, hosts, pins) — read at runtime |
config.c/.h |
Config file parser, defaults + startup validation |
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, callsign + passcode |
gpio.c/.h |
GPIO output via the kernel character device (uAPI v2) |
selftest.c |
Known-answer tests (make check) — runs anywhere, no Pi needed |
Makefile |
Build |
install-igate.sh |
One-liner installer: upgrade the Pi, enable SPI/GPIO, build, configure, reboot |
install.sh |
Build + install as a systemd service |
aprsigate.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.
By hand, if you would rather not use the installer above:
sudo apt update
sudo apt install -y build-essential git
git clone https://github.com/Guru-RF/lora-aprs-igate.git
cd lora-aprs-igate
makeThis produces the aprsigate binary. make check runs the built-in
known-answer tests (passcode, position encoder, config parser); they need no
radio and run on any machine.
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, GPIO pins, frequency, TX power). Editing it does not require a
recompile — just restart the service:
sudo vi /opt/APRSiGate/igate.conf
sudo /opt/APRSiGate/aprsigate -t /opt/APRSiGate/igate.conf # check it
sudo systemctl restart APRSiGate-t validates the file, prints the settings the daemon would actually use
(including the resolved passcode) and exits — so you find a typo before the
service does. The config is also validated at startup, and the daemon refuses
to start on an unusable value (bad callsign, out-of-range position, invalid
symbol, duplicate GPIO lines, …) instead of beaconing something wrong.
The config path is resolved in this order: the command-line argument
(aprsigate /path/to/igate.conf), then /opt/APRSiGate/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).
The passcode is not a secret — it is a plain hash of your callsign, so the iGate computes it itself:
call = ON6URE-5
passcode = auto # derived from `call`, no website neededChange the callsign and the passcode follows. Overrides are still accepted: put
a number there to force one, or -1 for a receive-only (unverified) login that
cannot gate packets. The SSID is ignored by the algorithm, so ON6URE and
ON6URE-5 share a passcode.
The daemon now also checks the server's login response and says so:
APRS-IS login verified as ON6URE-5
If the login is rejected it logs login NOT verified — check call/passcode and
waits 5 minutes before retrying, instead of silently reconnecting forever while
nothing is gated. A hand-set passcode that does not match the callsign is
flagged at startup too.
From a checkout you already have — this is the step
install-igate.sh runs for you, without the apt upgrade, the
prompts or the reboot:
sudo ./install.shThis installs build dependencies, ensures SPI is enabled in
/boot/firmware/config.txt, compiles, runs make check, installs the binary
and a default igate.conf (an existing one is preserved) to
/opt/APRSiGate/, and enables the APRSiGate.service systemd unit.
Re-running it upgrades in place: if the service is already active it is
restarted onto the new build. (systemctl daemon-reload alone re-reads the
unit file but leaves the running process on the old one, so an upgrade used to
appear to do nothing.)
If you just enabled SPI for the first time, reboot before starting so
/dev/spidev0.xappears.
sudo systemctl start APRSiGate
sudo systemctl status APRSiGateThe iGate writes to stdout and nothing else — systemd captures it in the
journal. There is no logging configuration and no networking code in the log
path; the daemon prefixes each line with the systemd priority marker so
journalctl -p err filters correctly. View the journal with journalctl:
# Follow live (most common — like tail -f)
journalctl -u APRSiGate -f
# Last 100 lines
journalctl -u APRSiGate -n 100
# Everything since the last boot
journalctl -u APRSiGate -b
# Only today / a time window
journalctl -u APRSiGate --since today
journalctl -u APRSiGate --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 APRSiGate -p err
# Newest first, no pager (dump to terminal)
journalctl -u APRSiGate -r --no-pager
# Grep received/sent packets
journalctl -u APRSiGate -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/APRSiGate/aprsigate /opt/APRSiGate/igate.conf— and the same lines print straight to stdout, with a UTC timestamp and a level word instead of the journald priority marker.
The iGate does not talk to a remote syslog itself; the machine's own logger does that job better and is already installed. To forward the journal to a collector (Papertrail, your own rsyslog, …), drop one file in and restart:
# /etc/rsyslog.d/10-aprsigate.conf
sudo tee /etc/rsyslog.d/10-aprsigate.conf >/dev/null <<'EOF'
*.* @logs4.papertrailapp.com:24262
EOF
sudo systemctl restart rsyslog@ is UDP, @@ is TCP. rsyslog reads the journal through imjournal on
Raspberry Pi OS, so the iGate's lines arrive tagged aprsigate with the right
severity, alongside everything else on the box. systemd-journal-upload is the
alternative if you run a systemd-journal-remote collector.
The old syslog_host / syslog_port keys are gone. If they are still in your
igate.conf the daemon says so at startup and ignores them — delete the lines.
- The Python
asynciodesign (three concurrent tasks + a queue) is replaced by one cooperative loop that polls the radio every 10 ms, fires the beacon, reads the APRS-IS socket, and advances a non-blocking LED blink state machine so the blink never stalls reception. Pacing and the socket read are a singlepoll(), so a disconnect is noticed immediately. - The radio listens from startup and keeps listening while APRS-IS is down. Packets heard in that window go into a 16-slot queue and are flushed once the link is back; entries older than 5 minutes are dropped as stale rather than injected late.
- Reconnects back off 5 → 10 → 20 … → 300 s instead of retrying every second, and reset as soon as a login succeeds. A rejected login waits 5 minutes, since a wrong passcode will not fix itself.
- Connects are non-blocking with a 10 s timeout, so a black-holed APRS-IS server cannot stall reception for the kernel's full SYN timeout.
- TCP keepalive options (
SO_KEEPALIVE,TCP_KEEPIDLE=300,TCP_KEEPINTVL=30,TCP_KEEPCNT=5) match the original. Lines are sent CR LF terminated as the APRS-IS spec asks. - Received frames are checked before they are gated: strict UTF-8 (no overlong
forms or surrogates) plus a
SRC>DEST:infoshape check, so a corrupted RF frame is logged and dropped rather than injected into the global network. - Logging is one
printf. There is no syslog client in the binary: lines go to stdout with a<N>priority prefix that journald decodes, so the daemon carries no socket, no name resolution and no retry logic for something the system logger already does. Forwarding is configured once, at the OS level. 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, andmake checkpins it to the APRS 1.0 spec example. lora_timeoutis a radio watchdog: after that many seconds without a packet the driver checks the SX127x is still in RX mode and re-arms it if it has fallen out. (It used to be cosmetic.)beacon_interval— new, default 900 s — replaces the hard-coded 15 minutes.
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!