diff --git a/ansible/roles/scanner_airspy_aprs/tasks/main.yml b/ansible/roles/scanner_airspy_aprs/tasks/main.yml new file mode 100644 index 0000000..fa4db94 --- /dev/null +++ b/ansible/roles/scanner_airspy_aprs/tasks/main.yml @@ -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 diff --git a/ansible/roles/scanner_airspy_aprs/templates/airspy-aprs.service.j2 b/ansible/roles/scanner_airspy_aprs/templates/airspy-aprs.service.j2 new file mode 100644 index 0000000..ded3e56 --- /dev/null +++ b/ansible/roles/scanner_airspy_aprs/templates/airspy-aprs.service.j2 @@ -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 diff --git a/ansible/roles/scanner_airspy_aprs/templates/airspy_aprs.sh b/ansible/roles/scanner_airspy_aprs/templates/airspy_aprs.sh new file mode 100644 index 0000000..21ebced --- /dev/null +++ b/ansible/roles/scanner_airspy_aprs/templates/airspy_aprs.sh @@ -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 diff --git a/ansible/roles/scanner_airspy_aprs/templates/direwolf_airspy_aprs.conf b/ansible/roles/scanner_airspy_aprs/templates/direwolf_airspy_aprs.conf new file mode 100644 index 0000000..2913167 --- /dev/null +++ b/ansible/roles/scanner_airspy_aprs/templates/direwolf_airspy_aprs.conf @@ -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 diff --git a/ansible/roles/scanner_digirig/templates/axports b/ansible/roles/scanner_digirig/templates/axports new file mode 100644 index 0000000..3b3b301 --- /dev/null +++ b/ansible/roles/scanner_digirig/templates/axports @@ -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 \ No newline at end of file diff --git a/ansible/roles/scanner_digirig/templates/mheardd.service b/ansible/roles/scanner_digirig/templates/mheardd.service new file mode 100644 index 0000000..cb7486c --- /dev/null +++ b/ansible/roles/scanner_digirig/templates/mheardd.service @@ -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 \ No newline at end of file diff --git a/ansible/roles/scanner_digirig/templates/netrom-init.service.j2 b/ansible/roles/scanner_digirig/templates/netrom-init.service.j2 new file mode 100644 index 0000000..dfd16ea --- /dev/null +++ b/ansible/roles/scanner_digirig/templates/netrom-init.service.j2 @@ -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 diff --git a/ansible/roles/scanner_digirig/templates/netromd.service b/ansible/roles/scanner_digirig/templates/netromd.service new file mode 100644 index 0000000..637905f --- /dev/null +++ b/ansible/roles/scanner_digirig/templates/netromd.service @@ -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 \ No newline at end of file diff --git a/ansible/roles/scanner_digirig/templates/nrbroadcast b/ansible/roles/scanner_digirig/templates/nrbroadcast new file mode 100644 index 0000000..4032bdb --- /dev/null +++ b/ansible/roles/scanner_digirig/templates/nrbroadcast @@ -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 \ No newline at end of file diff --git a/ansible/roles/scanner_digirig/templates/nrports b/ansible/roles/scanner_digirig/templates/nrports new file mode 100644 index 0000000..614c50c --- /dev/null +++ b/ansible/roles/scanner_digirig/templates/nrports @@ -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 \ No newline at end of file diff --git a/ansible/roles/scanner_ft991a/templates/ft991a-monitor.sh b/ansible/roles/scanner_ft991a/templates/ft991a-monitor.sh new file mode 100644 index 0000000..0b015bf --- /dev/null +++ b/ansible/roles/scanner_ft991a/templates/ft991a-monitor.sh @@ -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