aprs, digirig and ft991a stuff

This commit is contained in:
Craig McDaniel
2026-02-20 19:29:46 -06:00
parent 9c6dfb3e35
commit c7a2de13a9
11 changed files with 586 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
# Role: scanner_airspy_aprs
# Configure AirSpy SDR for APRS reception on VHF 144.390 MHz
#
# This role sets up:
# - airspy-fmradion for FM demodulation
# - Direwolf for APRS packet decoding
# - Full AX.25 stack integration (receive-only)
#
# NOTE: This role depends on scanner_digirig having been run first to set up:
# - AX.25 packages and kernel modules
# - System-wide configuration files (axports, nrports, etc.)
# - Base directory structure
#
- name: Create direwolf directories
file:
path: "{{item}}"
state: directory
owner: busnet
group: busnet
mode: u=rwx,g=rwx,o=rx
with_items:
- /opt/busnet/direwolf
- /opt/busnet/direwolf/config
- /opt/busnet/direwolf/logs
###################################################################################################
# Package Dependencies
###################################################################################################
- name: Install socat for PTY bridging
ansible.builtin.apt:
name:
- socat
state: present
###################################################################################################
# AirSpy bash script and Direwolf config
###################################################################################################
- name: Copy airspy_aprs.sh AX.25 stack script
copy:
src: "templates/airspy_aprs.sh"
dest: "/opt/busnet/direwolf/airspy_aprs.sh"
owner: busnet
group: busnet
mode: "0755"
- name: Copy Direwolf config for AirSpy APRS
template:
src: "templates/direwolf_airspy_aprs.conf"
dest: "/opt/busnet/direwolf/config/airspy-aprs.conf"
owner: busnet
group: busnet
###################################################################################################
# SYSTEMD
###################################################################################################
- name: Install systemd unit file for AirSpy APRS
template:
src: "templates/airspy-aprs.service.j2"
dest: "/etc/systemd/system/airspy-aprs.service"
owner: root
group: root
mode: "0644"
- name: Enable airspy-aprs service
systemd:
name: "airspy-aprs"
daemon_reload: true
enabled: true

View File

@@ -0,0 +1,18 @@
[Unit]
Description=AirSpy APRS Receiver with Direwolf and AX.25 Stack
Documentation=https://github.com/wb2osz/direwolf
After=network.target
[Service]
Type=simple
ExecStart=/opt/busnet/direwolf/airspy_aprs.sh
Restart=on-failure
RestartSec=10
User=busnet
Group=busnet
# Give it time to start up properly
TimeoutStartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,87 @@
#!/bin/bash
# @author Craig McDaniel
#
# This script runs airspy-fmradion to demodulate FM signals from the AirSpy SDR, pipes the audio
# to Direwolf for APRS packet decoding, then sets up the AX.25 stack for kernel integration.
#
# This is receive-only APRS monitoring on VHF 144.390 MHz.
#
# Configuration
FMRADION_CMD="airspy-fmradion -m nbfm -t airspy -q -M -c freq=144390000,srate=2500000,lagc,magc,lgain=14,mgain=15,vgain=15"
DIREWOLF_CMD="direwolf -q d -c /opt/busnet/direwolf/config/airspy-aprs.conf -n 1 -r 48000 -b 16 -"
SOCAT_CMD="/usr/bin/socat PTY,raw,echo=0,link=/dev/radio/airspy-tnc TCP4:127.0.0.1:8003"
KISSATTACH_CMD="/usr/sbin/kissattach /dev/radio/airspy-tnc airspy"
KISSPARAMS_CMD="kissparms -c 5 -p airspy"
cleanup() {
echo
echo "Exit signal detected. Cleaning up..."
trap - SIGTERM # prevent recursion
kill 0
exit
}
# Trap exit signals
trap cleanup SIGINT SIGTERM
echo
echo "======================================================================"
echo "Starting AirSpy FM Demodulator → Direwolf pipeline..."
echo "======================================================================"
echo
# Start the pipeline: airspy-fmradion pipes audio to direwolf
$FMRADION_CMD | $DIREWOLF_CMD &
PIPELINE_PID=$!
sleep 1
if ! kill -0 $PIPELINE_PID 2>/dev/null; then
echo "Pipeline failed to start."
exit 1
fi
# Wait 3 seconds for Direwolf to initialize and open KISS TCP port 8003
sleep 3
echo
echo "======================================================================"
echo "Starting socat PTY bridge..."
echo "======================================================================"
echo
# Start this in the background
sudo $SOCAT_CMD &
SOCAT_PID=$!
if ! kill -0 $SOCAT_PID 2>/dev/null; then
echo "Socat failed to start."
kill $PIPELINE_PID
exit 1
fi
# Wait for PTY device to be created
sleep 1
echo
echo "======================================================================"
echo "Starting kissattach and setting kissparams..."
echo "======================================================================"
echo
# Kissattach will automatically fork itself in background.
sudo $KISSATTACH_CMD
if [ $? -ne 0 ]; then
echo "kissattach failed to start."
cleanup
fi
# Configure AX.25 parameters for receive only. This is probably not necessary?
# Maybe somebody will do a bunch of tests and see if it actually matters one day.
sudo $KISSPARAMS_CMD
echo
echo "AirSpy APRS stack online! Monitoring processes..."
# 'wait -n' waits for ANY background process to exit.
# It returns as soon as the pipeline or socat dies.
wait -n
# If we get here, one of the processes died.
cleanup

View File

@@ -0,0 +1,15 @@
# This direwolf configuration is for piping the magic output of airspy_fmradion to stdin so it can
# read the...whatever it is that airspy_fmradion outputs (sound?) and demodulate the 1200 baud tones
# into AX.25 frames.
#
# This is for a receive only AX.25, generally for receiving APRS. It could receive any AX.25 though.
#
ACHANNELS 1
ADEVICE stdin null
ARATE 48000
MYCALL K0BIT-2
MODEM 1200
AGWPORT 8002
KISSPORT 8003

View File

@@ -0,0 +1,10 @@
# /etc/ax25/axports
#
# THIS FILE IS MANAGED BY ANSIBLE. LOCAL CHANGES WILL BE OVERWRITTEN.
#
# The format of this file is:
# name callsign speed paclen window description
#
digirig K0BIT-1 9600 255 5 VHF Port using ICOM IC-V8000 and Compactenna 2M/440+
airspy N0CALL 9600 255 5 AirSpy for VHF packet receive only.
ft991a K0BIT-2 9600 255 2 Used when the FT-991A radio is connected

View File

@@ -0,0 +1,19 @@
[Unit]
Description=AX.25 Packet Radio mheardd Daemon
After=digirig-direwolf.service
BindTo=digirig-direwolf.service
# systemd has trouble with this daemon. Presumably it's old code and systemd cannot determine if
# the daemon starts. These settings seem to work.
[Service]
Type=simple
RemainAfterExit=yes
# -i sends a routing update immediately on start
# -t 15 sets the nodes broadcast interval
ExecStart=/usr/sbin/mheardd -l 1000
ExecStop=pkill -x mheardd
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,22 @@
[Unit]
Description=Initialize NET/ROM Interfaces at Boot
Documentation=man:nrattach(8)
After=network.target
Before=digirig-direwolf.service ft991a-direwolf.service mheardd.service netromd.service
ConditionPathExists=/etc/ax25/nrports
[Service]
Type=oneshot
RemainAfterExit=yes
# Create NET/ROM interfaces for each port in nrports
# nrattach finds first free device: digirig-nr gets nr0, ft991a-nr gets nr1
ExecStart=/usr/sbin/nrattach digirig-nr
ExecStart=/usr/sbin/nrattach ft991a-nr
# On stop/shutdown, bring down the interfaces cleanly
ExecStop=/usr/sbin/ifconfig nr0 down
ExecStop=/usr/sbin/ifconfig nr1 down
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,19 @@
[Unit]
Description=NET/ROM Routing Daemon
After=digirig-direwolf.service
BindTo=digirig-direwolf.service
# systemd has trouble with this daemon. Presumably it's old code and systemd cannot determine if
# the daemon starts. These settings seem to work.
[Service]
Type=simple
RemainAfterExit=yes
# -i sends a routing update immediately on start
# -t 15 sets the nodes broadcast interval
ExecStart=/usr/sbin/netromd -i -t 45
ExecStop=pkill -x netromd
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,12 @@
# /etc/ax25/nrbroadcast
#
# THIS FILE IS MANAGED BY ANSIBLE. CHANGES WILL BE OVERWRITTEN.
#
# This file tells the netromd program how to transmit the nodes it knows about. In here you specify
# the AX25 port in axports to use for transmitting.
#
# The format of this file is:
# ax25_name min_obs def_qual worst_qual verbose
#
digirig 1 100 50 1
#ft991a 1 100 50 0

View File

@@ -0,0 +1,9 @@
# /etc/ax25/nrports
#
# THIS FILE IS MANAGED BY ANSIBLE. LOCAL CHANGES WILL BE OVERWRITTEN.
#
# The format of this file is:
# name callsign alias paclen description
#
digirig-nr K0BIT-8 8BITS 235 NET/ROM Port
ft991a-nr K0BIT-7 7BITS 235 HF NET/ROM Port

View File

@@ -0,0 +1,303 @@
#!/bin/bash
# @author Craig McDaniel
#
# This script polls the Yaesu FT-991a by connecting to a running rigctld daemon and asking that
# daemon what the current radio's frequency is. If the radio is in any HF frequency, it makes
# sure Direwolf is running with a HF AX.25 configuration at 300 baud. If a VHF or UHF frequency
# is detected, it restarts Direwolf with a configuration suitable for that band.
#
# If the radio is unreachable via rigctl due to powering off the radio, then this script will
# shut down the AX.25 stack and wait for the radio to become available again. It will then enable
# the AX.25 stack automatically.
#
# By AX.25 stack, we mean:
# - Run direwolf
# - Run socat to set up a PTY between Direwolf's TCP KISS port and the filesystem.
# - Run kissattach to the socat PTY to create a kernel AX.25 interface.
# - Make sure mheardd daemon is restarted so it can see new AX.25 interface.
#
# === Configuration ===
DIREWOLF_PATH="/usr/local/bin/direwolf"
PORT_NAME="ft991a"
TNC_DEV="/dev/radio/ft991a-tnc"
KISS_TCP_PORT=8006
NR_PORT="ft991a-nr"
POLL_INTERVAL=5
RIGCTLD_HOST="localhost"
RIGCTLD_PORT=4000
POLL_RETRIES=1
# Frequency ranges (Hz)
HF_MIN=1800000
HF_MAX=30000000
VHF_MIN=144390000
VHF_MAX=145100000
UHF_MIN=432000000
UHF_MAX=433000000
# Config files per band
CONFIG_HF="/opt/busnet/direwolf/config/ft991a-hf.conf"
CONFIG_VHF="/opt/busnet/direwolf/config/ft991a-vhf.conf"
CONFIG_UHF="/opt/busnet/direwolf/config/ft991a-uhf.conf"
# State tracking
CUR_BAND="NONE"
DIREWOLF_PID=""
SOCAT_PID=""
CONSECUTIVE_ERRORS=0
MAX_CONSECUTIVE_ERRORS=5
WAITING_FOR_RADIO=false
# Poll rigctld to get the current band of the FT991A. It does this by asking the radio what the current
# VFO frequency is and then determining what band that frequency is in. This returns the band string.
#
# Returns band name string: "HF", "VHF", "UHF", "UNKNOWN", or "ERROR"
poll_rigctld()
{
local RESP=$(echo "+\\get_freq" | nc -w 2 $RIGCTLD_HOST $RIGCTLD_PORT 2>/dev/null)
if [ -n "$RESP" ]; then
# Parse frequency from response
local FREQ=$(echo "$RESP" | grep "Frequency:" | awk '{print $2}')
if [ -n "$FREQ" ] && [ "$FREQ" -eq "$FREQ" ] 2>/dev/null; then
# Determine band
if [ $FREQ -ge $HF_MIN ] && [ $FREQ -le $HF_MAX ]; then
echo "HF"
return 0
elif [ $FREQ -ge $VHF_MIN ] && [ $FREQ -le $VHF_MAX ]; then
echo "VHF"
return 0
elif [ $FREQ -ge $UHF_MIN ] && [ $FREQ -le $UHF_MAX ]; then
echo "UHF"
return 0
else
echo "UNKNOWN"
return 0
fi
fi
fi
echo "ERROR"
return 1
}
# Cleanup the AX.25 stack by shutting down kissattach, socat and Direwolf. This will also automatically
# set the corresponding kernel AX.25 interface to down because the carrier is lost.
cleanup_stack()
{
if [ "$CUR_BAND" == "NONE" ]; then
return
fi
echo "Shutting down FT991a AX.25 ${CUR_BAND} stack..."
# kissattach is annoying! It forks itself in the background, so we can't directly keep track of its
# PID in here. We use pkill to match the full process name. This is a bit hackish, but we have no
# other choice util somebody updates is and provides a way to stop it from forking.
sudo pkill -f "kissattach ${TNC_DEV}"
sleep 1
if [ -n "$SOCAT_PID" ]; then
sudo kill $SOCAT_PID 2>/dev/null
sleep 1
fi
if [ -n "$DIREWOLF_PID" ] && kill -0 $DIREWOLF_PID 2>/dev/null; then
kill $DIREWOLF_PID 2>/dev/null
wait $DIREWOLF_PID 2>/dev/null
fi
echo "FT991a AX.25 ${CUR_BAND} stack is now shut down."
CUR_BAND="NONE"
DIREWOLF_PID=""
SOCAT_PID=""
}
# Start the AX.25 stack for the current frequency.
start_stack()
{
local BAND=$1
local CONFIG=""
case $BAND in
HF) CONFIG="$CONFIG_HF" ;;
VHF) CONFIG="$CONFIG_VHF" ;;
UHF) CONFIG="$CONFIG_UHF" ;;
*)
echo "ERROR: Invalid band '$BAND'" >&2
return 1
;;
esac
echo "Starting $BAND stack..."
# Start Direwolf
echo "Starting Direwolf..."
$DIREWOLF_PATH -t 0 -X 1 -c "$CONFIG" &
DIREWOLF_PID=$!
sleep 2
if ! kill -0 $DIREWOLF_PID 2>/dev/null; then
echo "FATAL: Direwolf failed to start" >&2
DIREWOLF_PID=""
return 1
fi
echo "Direwolf started (PID: $DIREWOLF_PID)"
# Start socat PTY bridge
echo "Starting socat PTY bridge..."
sudo socat PTY,raw,echo=0,link=$TNC_DEV TCP4:127.0.0.1:$KISS_TCP_PORT &
SOCAT_PID=$!
sleep 1
if ! kill -0 $SOCAT_PID 2>/dev/null; then
echo "FATAL: socat failed to start" >&2
cleanup_stack
return 1
fi
# Wait for PTY device to exist
local WAIT_COUNT=0
while [ ! -L $TNC_DEV ] && [ $WAIT_COUNT -lt 10 ]; do
sleep 0.5
((WAIT_COUNT++))
done
if [ ! -L $TNC_DEV ]; then
echo "FATAL: PTY device $TNC_DEV not created" >&2
cleanup_stack
return 1
fi
echo "socat started (PID: $SOCAT_PID)"
# Kissattach connects the PTY device created by socat to the kernel.
echo "Running kissattach..."
sudo kissattach $TNC_DEV $PORT_NAME
if [ $? -ne 0 ]; then
echo "FATAL: kissattach failed" >&2
cleanup_stack
return 1
fi
echo "kissattach completed"
# Set kissparams depending on what band we're operating on.
echo "Setting kissparms..."
case $BAND in
HF)
sudo kissparms -p $PORT_NAME -c 1 -t 350
echo "Set kissparams for HF: window=1, txdelay=350ms"
;;
VHF)
sudo kissparms -p $PORT_NAME -c 3 -t 200
echo "Set kissparams for VHF: window=3, txdelay=200ms"
;;
UHF)
sudo kissparms -p $PORT_NAME -c 3 -t 150
echo "Set kissparams for UHF: window=3, txdelay=150ms"
;;
esac
# Restart mheardd daemon. If mheardd was started before ax1, ax2 or other AX.25 kernel interface
# was created, and now we receive AX.25 packets on this new interface we just created, mheardd
# just throws errors about a new, unknown interface. Ugh.
if systemctl is-active --quiet mheardd.service; then
echo "Restarting mheardd..."
sudo systemctl restart mheardd.service
fi
CUR_BAND="$BAND"
echo "FT991a AX.25 ${CUR_BAND} stack is online."
echo ""
return 0
}
# This is our POSIX signal handler so we cleanly shut down before exiting.
handle_signal()
{
echo "We received a SIGINT or SIGTERM signal. Shutting down FT991a AX.25 stack."
cleanup_stack
exit 0
}
###################################################################################################
#
# MAIN LOOP!
#
###################################################################################################
echo ""
echo "======================================================================"
echo "FT-991A AX.25 Monitor Started"
echo "======================================================================"
echo ""
# This is the bash way of intercepting these signals. We execute the cleanup function when we receive
# either one.
trap handle_signal SIGINT SIGTERM
# Check if Direwolf exists
if [ ! -f $DIREWOLF_PATH ]; then
echo "FATAL: Direwolf executable not found at $DIREWOLF_PATH" >&2
exit 1
fi
while true; do
TARGET_BAND=$(poll_rigctld)
if [ "$WAITING_FOR_RADIO" = true ]; then
# In waiting mode - check if radio is back online
if [ "$TARGET_BAND" != "ERROR" ]; then
echo "Radio detected! Bringing up AX.25 stack for FT-991a..."
WAITING_FOR_RADIO=false
CONSECUTIVE_ERRORS=0
fi
# Continue to next iteration if still waiting or if we just detected the radio
sleep $POLL_INTERVAL
continue
fi
# Normal operation mode
if [ "$TARGET_BAND" == "ERROR" ]; then
((CONSECUTIVE_ERRORS++))
echo "Radio unreachable ($CONSECUTIVE_ERRORS/$MAX_CONSECUTIVE_ERRORS)"
if [ $CONSECUTIVE_ERRORS -ge $MAX_CONSECUTIVE_ERRORS ]; then
echo "Radio offline. Waiting for radio to be reachable again."
cleanup_stack
WAITING_FOR_RADIO=true
CONSECUTIVE_ERRORS=0
fi
else
# Reset error counter on any successful poll
CONSECUTIVE_ERRORS=0
# Only act on valid digital bands (HF, VHF, UHF)
if [[ "$TARGET_BAND" =~ ^(HF|VHF|UHF)$ ]]; then
# Band switch needed?
if [ "$TARGET_BAND" != "$CUR_BAND" ]; then
if [ "$CUR_BAND" != "NONE" ]; then
echo "Band change detected: $CUR_BAND -> $TARGET_BAND"
cleanup_stack
fi
start_stack "$TARGET_BAND"
if [ $? -ne 0 ]; then
echo "FATAL: Failed to start FT991a AX.25 stack for $TARGET_BAND" >&2
exit 1
fi
fi
elif [ "$TARGET_BAND" == "UNKNOWN" ]; then
# Radio is on a non-packet frequency - do nothing (idempotent)
: # No-op
fi
fi
# Poll interval sleep
sleep $POLL_INTERVAL
done