Skip to content

Repository files navigation

PiLoRa 433 APRS iGate

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 0x01 prefix 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

Install

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.sh

That is the whole install. install-igate.sh:

  1. Upgrades the Piapt-get update, full-upgrade, autoremove, clean.
  2. Installs the build dependenciesbuild-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).
  3. Enables SPI and the LED GPIOs in /boot/firmware/config.txt — see below. The file it found is kept as config.txt.aprsigate.bak.
  4. Builds and installs — compile, make check, binary and default igate.conf into /opt/APRSiGate/, APRSiGate.service enabled.
  5. Asks for your callsign and APRS SSIDON6URE and 2 become ON6URE-2 — and optionally your position, by street address. It writes them into igate.conf and validates the result with aprsigate -t, which prints the passcode it derived from your call.
  6. Offers to reboot, which SPI needs before /dev/spidev0.x appears.

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 bash works 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.

Finding your position

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.

What goes into config.txt

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.

Files

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

How the hardware is accessed

  • SPI uses /dev/spidev0.1. On the Pi, spidev0.1 is 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, set spi_device = /dev/spidev0.0 in igate.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.

Build

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
make

This 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.

Configure

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 APRS-IS passcode is automatic

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 needed

Change 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.

Install as a service

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.sh

This 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.x appears.

sudo systemctl start APRSiGate
sudo systemctl status APRSiGate

Logs

The 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.

Sending logs off-box

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.

Design notes

  • The Python asyncio design (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 single poll(), 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:info shape 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/SIGINT shut 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, and make check pins it to the APRS 1.0 spec example.
  • lora_timeout is 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.

Credits

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).

License

MIT License — see LICENSE. Use it, fork it, improve it!

About

The code for running an LoRa APRS iGate on a raspberry pi with the RF.Guru LoRa hat!

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages