This article details how to build a Winlink RMS gateway and APRS digipeater/iGate on Linux with native software only. Dire Wolf handles 1200 baud and 9600 baud packet, XARA handles VARA FM, and LinBPQ connects all three modes to the Winlink system. The setup uses no Wine.
The finished station answers Winlink connections on a single frequency in three modes at the same time: 1200 baud packet, 9600 baud packet, and VARA FM. One radio, one USB sound interface, and one PTT line serve all three modems. The same LinBPQ node also runs an APRS digipeater and iGate, and the entire station starts on its own at boot.
XARA is open source. VARA is a commercial program, and XARA is an independent, MIT-licensed, VARA-compatible modem for Linux, macOS, and FreeBSD. The project reports testing against VARA FM 4.4.0 and VARA HF 4.9.0. Holders of a registered VARA license enable the higher registered speeds by building XARA with the registered feature and starting it with the registered option. Both steps appear in the XARA section below.
VARA FM is a VHF/UHF protocol, so this build targets 2 meters or 70 cm. The last sections cover changing the mode XARA uses for HF and other frequencies. The reference interface keys the radio through a CM108 GPIO pin, and a dedicated section covers interfaces that key through a serial port (such as the DigiRig) and interfaces that key by VOX (such as the Tigertronics SignaLink USB).
The article begins after the operating system is installed. A bare-metal installation and a virtual machine behave the same once the USB sound interface is visible to the operating system. The reference build is Ubuntu 26.04 LTS Server in a Proxmox virtual machine.
Definitions
- Winlink: the worldwide radio email system.
- RMS gateway: a radio station that connects Winlink clients on the air to the Winlink system over the internet.
- CMS: the Winlink Common Message Server. The RMS gateway relays each radio session to a CMS.
- LinBPQ: the Linux version of the BPQ32 packet switch by John Wiseman, G8BPQ. In this build it is the RMS gateway, the packet node, and the APRS digipeater/iGate.
- Dire Wolf: a software TNC (sound card modem) for AX.25 packet radio. It decodes and generates 1200 baud AFSK and 9600 baud G3RUH signals.
- XARA: a VARA-compatible modem for Linux. It provides the VARA FM mode.
- VARA FM: the FM-radio mode of the VARA protocol, available as narrow and wide.
- KISS: the simple framing protocol that packet software uses to talk to a TNC. Dire Wolf offers it over TCP.
- PTT: push-to-talk, the line that keys the transmitter.
- RTS and DTR: control lines of a serial port. Many interfaces key the radio by switching one of them.
- VOX: voice-operated transmit. The interface keys the radio when audio arrives from the computer, and no PTT line is involved.
- CM108: a USB audio chip that includes general-purpose I/O pins. Many USB radio interfaces use it and key the radio by switching one of those pins (GPIO 3 on the interface used here).
- ALSA: the Linux sound system. Its dsnoop plugin lets several programs share one capture stream, and its dmix plugin mixes several playback streams into one.
- APRS: the Automatic Packet Reporting System.
- iGate: an APRS station that passes packets between the radio channel and the APRS-IS internet network.
Expectations
The station is built from four cooperating parts. The radio connects to a CM108 USB interface. ALSA shares the interface audio between Dire Wolf and XARA. A small program called the PTT arbiter combines the PTT requests of all three modems into the single GPIO line. LinBPQ sits on top and talks to the modems over local TCP ports.
Radio (data port, 145.050 MHz)
|
CM108 USB interface
| audio | PTT (GPIO 3)
| |
ALSA dsnoop (one capture) PTT arbiter
ALSA dmix (mixed playback) ^ ^ ^
| | | |
+--> Dire Wolf ch 0: 1200 baud -+ |
+--> Dire Wolf ch 1: 9600 baud -+ | (KISS, TCP 8001)
+--> XARA: VARA FM -------------------+ (VARA, TCP 8300/8301)
|
LinBPQ ----> Winlink CMS (internet)
----> APRS-IS (internet)
The reference station was built and tested with the following versions.
| Item | Version |
|---|---|
| Operating system | Ubuntu 26.04.1 LTS Server (kernel 7.0) |
| Dire Wolf | 1.8.1 (Ubuntu package) |
| XARA | 0.1.3 (commit 09a9201) |
| LinBPQ | 6.0.26.1 (linbpq64) |
| Radio | Yaesu FT-7800, 9600 baud data port |
| Interface | Allscan URI 125 (CM108) |
Terminal output in this article was captured on the working station. Callsigns and names are replaced with the placeholders listed below.
Hardware Needed
- A computer or virtual machine running a recent Debian or Ubuntu based Linux. The reference virtual machine has 4 CPU cores, 1.6 GB of RAM, and 31 GB of disk.
- A VHF/UHF FM radio with a data port. The 9600 baud packet mode and VARA FM wide use the flat-audio 9600 baud data port. The reference radio is a Yaesu FT-7800 with its data speed set to 9600.
- A USB sound interface that can key the radio. The reference interface is an Allscan URI 125 (CM108 chip, GPIO PTT). Interfaces that key through a serial port line (such as the DigiRig) and interfaces that key by VOX (such as the Tigertronics SignaLink USB) work with the same build, and the section on other PTT interfaces covers the differences.
- A cable between the radio data port and the interface.
- An internet connection with outbound access to the Winlink servers and to APRS-IS.
- An amateur radio license for the callsign used by the station.
- Winlink gateway credentials: the gateway password for the callsign, obtained through Winlink’s RMS gateway registration at https://winlink.org.
- An APRS-IS passcode for the callsign (needed only for the iGate).
- A second receiver tuned to the station frequency. It allows the transmit audio of each mode to be heard during testing.
A virtual machine receives the interface through USB passthrough. In Proxmox VE the device is added under “Hardware”, “Add”, “USB Device”, using the vendor/device ID so the assignment follows the interface itself. The interface then appears inside the guest exactly as it would on bare metal.
Software To Download
- Dire Wolf, installed from the distribution package (version 1.8.1 on the reference station).
- XARA, built from source: https://github.com/x0rro/xara (reference commit 09a9201, version 0.1.3).
- LinBPQ, the 64-bit binary: https://www.cantab.net/users/john.wiseman/Downloads/Beta/linbpq64
- The LinBPQ web page set: https://www.cantab.net/users/john.wiseman/Downloads/Beta/HTMLPages.zip
The PTT arbiter, the ALSA configuration, and the test scripts appear in full in this article.
Placeholders
Every command and file below uses the following placeholders. Each one is replaced with the real value when the file is created.
| Placeholder | Meaning |
|---|---|
| N0CALL | the base station callsign |
| N0CALL-10 | the RMS gateway callsign (SSID 10 is the usual choice) |
| N0CALL-7 | the packet node callsign |
| N0CALL-5 | the APRS iGate callsign |
| AA00aa | the six-character Maidenhead grid square |
| GATEWAY_PASSWORD | the Winlink gateway password |
| SYSOP_USER, SYSOP_PASSWORD | the login for the LinBPQ web interface and node sysop |
| APRS_PASSCODE | the APRS-IS passcode for N0CALL |
| DDMM.mmN, DDDMM.mmW | the station latitude and longitude in APRS format |
| 145050000 | the station frequency in Hz |
| USERNAME | the Linux administrator account |
| SERIAL_PORT | the serial port of the interface under /dev/serial/by-id/ (serial PTT interfaces) |
| CARDNAME | the ALSA card name of the sound interface, when it differs from Device |
Preparing the Operating System
The system starts from a fresh install with network access and a user with sudo rights. The package index is refreshed and the installed packages are brought up to date first.
sudo apt update
sudo apt upgrade -y
Winlink sessions and APRS-IS logins depend on an accurate clock. Ubuntu enables time synchronization by default, and the state is confirmed with:
timedatectl
Local time: Wed 2026-11-04 13:40:12 UTC
Universal time: Wed 2026-11-04 13:40:12 UTC
RTC time: Wed 2026-11-04 13:40:12
Time zone: Etc/UTC (UTC, +0000)
System clock synchronized: yes
NTP service: active
RTC in local TZ: no
The packages for every part of the build install in one command. The list covers the compiler and audio libraries needed to build XARA, Dire Wolf itself, the ALSA tools, and the helpers used later.
sudo apt install -y git build-essential pkg-config rustc cargo \
libasound2-dev libpulse-dev alsa-utils direwolf usbutils \
unzip curl ufw python3
The Ubuntu 26.04 Rust toolchain (1.93) satisfies XARA’s minimum version of 1.85.
Identifying the USB Interface
With the interface connected, the operating system lists it as a USB device and as a sound card.
lsusb | grep -i c-media
Bus 002 Device 002: ID 0d8c:0012 C-Media Electronics, Inc. USB Audio Device
The vendor ID 0d8c and product ID 0012 identify this interface. Other CM108/CM119 interfaces report different product IDs, and the udev rule later in this article uses whichever pair lsusb shows.
cat /proc/asound/cards
0 [Device ]: USB-Audio - USB Audio Device
C-Media Electronics Inc. USB Audio Device at usb-0000:02:1b.0-1, full speed
The card is named Device in square brackets. That name appears in the ALSA configuration, so it stays valid even if the card number changes.
arecord -l
aplay -l
**** List of CAPTURE Hardware Devices ****
card 0: Device [USB Audio Device], device 0: USB Audio [USB Audio]
Subdevices: 1/1
Subdevice #0: subdevice #0
**** List of PLAYBACK Hardware Devices ****
card 0: Device [USB Audio Device], device 0: USB Audio [USB Audio]
Subdevices: 1/1
Subdevice #0: subdevice #0
The interface also exposes a HID node. That node carries the GPIO that keys the radio. In a virtual machine the emulated USB tablet creates a HID node as well, so the vendor and product IDs identify the right one.
for h in /dev/hidraw*; do echo -n "$h: "; udevadm info -q property -n $h | grep -E '^ID_(VENDOR|MODEL)_ID' | tr '\n' ' '; echo; done
/dev/hidraw0: ID_MODEL_ID=0001 ID_VENDOR_ID=0627
/dev/hidraw1: ID_MODEL_ID=0012 ID_VENDOR_ID=0d8c
Here /dev/hidraw1 is the interface. The number can change between boots, so the udev rule in the PTT arbiter section creates a stable name. Interfaces that key through a serial port or by VOX have no HID node, and the section on other PTT interfaces covers them.
Service Accounts
Each program runs under its own unprivileged account. The accounts share the audio group so that all of them can open the same sound devices. The Dire Wolf package already creates a direwolf account.
sudo usermod -aG audio direwolf
sudo useradd --system --no-create-home --shell /usr/sbin/nologin --user-group pttmux
sudo usermod -aG audio,plugdev pttmux
sudo useradd --system --home-dir /opt/linbpq --no-create-home --shell /usr/sbin/nologin --user-group linbpq
The xara account is created by the XARA installer, and the administrator account joins the audio group so that it can run the audio tests.
sudo usermod -aG audio USERNAME
The group change applies to new login sessions, so a fresh SSH login follows this step.
Sharing the Radio Audio Between Modems (ALSA dsnoop and dmix)
A USB sound card opened directly by one program is unavailable to the next program. Three modems on one radio need the opposite: every modem must hear the same received audio, and every modem must be able to transmit through the same output. ALSA provides both with two plugins.
- The dsnoop plugin takes the single capture stream from the interface and hands a copy to every program that opens it.
- The dmix plugin accepts playback streams from several programs and mixes them into the single stream that goes to the interface.
Programs open the shared devices by name instead of opening the hardware. The names are defined in /etc/asound.conf.
sudo nano /etc/asound.conf
The file contains the following.
# Shared access to the Allscan URI 125 (C-Media CM108) for several modems at once.
#
# capture : one hardware stream fanned out to every reader (dsnoop)
# playback: every writer mixed into one hardware stream (dmix)
#
# ipc_perm/ipc_gid let different service users (direwolf, xara, ...) share the
# same dsnoop/dmix instance; they must all be in group "audio".
#
# IMPORTANT: keep ipc_key values well apart (>= 10). dmix also uses key+1 for its
# sum buffer and dsnoop's segment lands at a nearby key; adjacent keys (e.g. 1 and 2)
# collide and dmix then fails with "unable to initialize sum ring buffer".
#
# RX/TX audio levels: set with alsamixer/amixer on card "Device" (AGC must be off).
pcm.uri_capture {
type dsnoop
ipc_key 584410
ipc_perm 0660
ipc_gid audio
slave {
pcm "hw:CARD=Device,DEV=0"
channels 1
rate 48000
format S16_LE
period_size 480
buffer_size 4800
}
}
pcm.uri_playback {
type dmix
ipc_key 584420
ipc_perm 0660
ipc_gid audio
slave {
pcm "hw:CARD=Device,DEV=0"
channels 2
rate 48000
format S16_LE
period_size 480
# 200 ms: Xara insists the playback device buffer >= 4x its tx-buffer-ms (40 ms
# default -> 160 ms; its minimum setting of 30 ms still needs 120 ms).
buffer_size 9600
}
bindings {
0 0
1 1
}
}
# --- Dire Wolf: stereo pair = two modems (left=1200 baud, right=9600 baud) -----
# RX: the single mono radio audio is copied to both channels.
pcm.dw_in {
type plug
slave.pcm {
type route
slave.pcm "uri_capture"
slave.channels 1
ttable.0.0 1
ttable.1.0 1
}
}
# TX: left and right are summed onto both radio channels.
pcm.dw_out {
type plug
slave.pcm {
type route
slave.pcm "uri_playback"
slave.channels 2
ttable.0.0 1
ttable.0.1 1
ttable.1.0 1
ttable.1.1 1
}
}
# --- Xara (mono modem) ---------------------------------------------------------
pcm.xara_in {
type plug
slave.pcm "uri_capture"
}
pcm.xara_out {
type plug
slave.pcm {
type route
slave.pcm "uri_playback"
slave.channels 2
ttable.0.0 1
ttable.0.1 1
}
}
The file defines six names, and each one has a specific job.
uri_captureis the dsnoop device. It reads the interface once, as mono at 48000 Hz.uri_playbackis the dmix device. It writes to the interface as stereo at 48000 Hz.dw_ingives Dire Wolf a stereo capture stream. The mono radio audio is copied to both channels, so the 1200 baud modem on the left channel and the 9600 baud modem on the right channel both receive everything the radio hears.dw_outaccepts Dire Wolf’s stereo output. The left channel (1200 baud modem) and the right channel (9600 baud modem) are both summed onto both channels of the interface, so either modem reaches the radio.xara_inandxara_outgive XARA a mono capture and playback path on the same shared devices.
Two numbers in the file matter for the rest of the build.
The ipc_key values are 584410 and 584420. ALSA keeps shared state in System V shared memory, and dmix uses the key plus one for its mixing buffer, while dsnoop’s segment lands at a nearby key. Keys at least ten apart keep the two plugins from colliding.
The dmix buffer_size is 9600 frames, which is 200 ms at 48000 Hz. XARA requires the playback device to buffer at least four times its transmit buffer setting (40 ms by default, so 160 ms). The same buffer also determines the transmit tail time set later in the Dire Wolf and LinBPQ configuration.
The ipc_perm 0660 and ipc_gid audio lines let accounts in the audio group share the same dsnoop and dmix instances.
ALSA reads the file whenever a program opens a device. The new names appear in the device list.
aplay -L | grep -E '^(uri_|dw_|xara_)'
uri_capture
uri_playback
dw_in
dw_out
xara_in
xara_out
Verifying the shared devices
The following script runs two captures and two playbacks at the same time under two different accounts. The playback is digital silence and PTT stays unkeyed, so nothing is transmitted.
nano ~/audiotest.py
#!/usr/bin/env python3
"""Shared-audio check for the virtual ALSA devices.
Two different users capture the same radio audio at once and play digital
silence into the shared mixer at once. PTT is never keyed, so nothing is
transmitted. Run with: python3 audiotest.py
"""
import array
import getpass
import math
import subprocess
import sys
import threading
results = {}
def rms(samples):
return math.sqrt(sum(v * v for v in samples) / max(len(samples), 1))
def capture(user, device, channels):
p = subprocess.run(
["sudo", "-u", user, "arecord", "-q", "-D", device, "-f", "S16_LE", "-r", "48000",
"-c", str(channels), "-d", "4", "-t", "raw"], capture_output=True)
data = array.array("h")
data.frombytes(p.stdout[: len(p.stdout) // 2 * 2])
left = data[0::channels]
right = data[1::channels] if channels > 1 else left
results[f"RX {user:10s} {device:12s}"] = (
p.returncode, f"frames={len(data) // channels} rmsL={rms(left):.1f} rmsR={rms(right):.1f}"
+ (f" {p.stderr.decode().strip()}" if p.stderr else ""))
def play(user, device, channels):
p = subprocess.run(
["sudo", "-u", user, "aplay", "-q", "-D", device, "-f", "S16_LE", "-r", "48000",
"-c", str(channels), "-d", "4", "/dev/zero"], capture_output=True)
results[f"TX {user:10s} {device:12s}"] = (
p.returncode, "played 4 s of silence" + (f" {p.stderr.decode().strip()}" if p.stderr else ""))
me = getpass.getuser()
jobs = [
(capture, (me, "uri_capture", 1)),
(capture, ("direwolf", "dw_in", 2)),
(play, (me, "uri_playback", 2)),
(play, ("direwolf", "dw_out", 2)),
]
threads = [threading.Thread(target=fn, args=args) for fn, args in jobs]
for t in threads:
t.start()
for t in threads:
t.join()
failed = 0
for key in sorted(results):
code, message = results[key]
print(("OK " if code == 0 else "FAIL ") + key + " " + message)
failed += code != 0
sys.exit(1 if failed else 0)
python3 -I ~/audiotest.py
OK RX direwolf dw_in frames=192000 rmsL=3444.5 rmsR=3444.5
OK RX admin uri_capture frames=192000 rmsL=3444.4 rmsR=3444.4
OK TX direwolf dw_out played 4 s of silence
OK TX admin uri_playback played 4 s of silence
Four OK lines confirm that two accounts read and write the interface simultaneously. Identical rmsL and rmsR values on the dw_in line confirm that the mono radio audio reaches both modem channels. A non-zero RMS value shows that the radio data port is delivering audio to the interface.
Mixer Levels
The interface exposes three mixer controls.
amixer -c 0 scontrols
Simple mixer control 'Speaker',0
Simple mixer control 'Mic',0
Simple mixer control 'Auto Gain Control',0
Data modems need a fixed, predictable audio path, so automatic gain control is switched off. The Speaker control sets the transmit audio level into the radio. The reference station settled on step 21 of 37 (-16 dB), which is a good starting point for the transmit level adjustment later in this article.
amixer -c 0 sset 'Auto Gain Control' off
amixer -c 0 sset Speaker 21
sudo alsactl store
amixer -c 0 sget Speaker | grep -E 'Limits|Front'
Limits: Playback 0 - 37
Front Left: Playback 21 [57%] [-16.00dB] [on]
Front Right: Playback 21 [57%] [-16.00dB] [on]
The alsactl store command saves the state, and the ALSA restore service reapplies it at every boot.
PTT Arbiter
The problem
Dire Wolf keys PTT for the 1200 baud and 9600 baud channels independently, and XARA keys PTT for VARA FM. All three drive the one GPIO line of the interface. With all three modems driving the line directly, whichever modem finishes first releases PTT while another modem is still transmitting, and the end of that transmission is lost.
The solution
Each modem keys a named pipe (FIFO) instead of the real interface. A small program, the PTT arbiter, reads all the pipes and drives the interface itself. PTT is asserted while any modem wants to transmit and is released only after every modem has released.
- Dire Wolf accepts any file path in its
PTT CM108setting, and XARA accepts any path inptt = cm108:PATH:GPIO. Both write the same five-byte CM108 HID report to that path:[0, 0, data, mask, 0], where the data bit for the GPIO is 1 for transmit and 0 for receive. - The output side of the arbiter is selectable. The default writes the CM108 HID report to the real interface, and
--output rtsor--output dtrdrives a serial port line instead, as covered in the section on other PTT interfaces. - The arbiter keeps one state per modem, ignores reports for other GPIO pins, and logs the moments when two modems key at the same time as
OVERLAP. - PTT is forced off when the arbiter starts and when it exits. A per-modem watchdog releases a modem that holds PTT for more than 120 seconds.
- The FIFOs survive an arbiter restart, because XARA holds its pipe open for as long as it runs. Stale bytes left in a pipe are discarded at start-up so that only fresh transmit requests key the radio.
- The
--dry-runoption logs the actions and leaves the interface untouched.
The udev rule
The HID node number can change between boots. A udev rule creates a stable name, /dev/uri125-ptt, from the vendor and product IDs found earlier with lsusb.
sudo nano /etc/udev/rules.d/61-uri125.rules
# Stable name for the Allscan URI 125 (CM108) PTT HID node, independent of
# /dev/hidrawN numbering. Used by pttmux.
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0d8c", ATTRS{idProduct}=="0012", SYMLINK+="uri125-ptt"
sudo udevadm control --reload
sudo udevadm trigger --subsystem-match=hidraw --action=change
ls -l /dev/uri125-ptt
lrwxrwxrwx 1 root root 7 Nov 4 13:35 /dev/uri125-ptt -> hidraw1
The Dire Wolf package installs a second udev rule that gives the audio group read and write access to CM108 HID nodes. The pttmux account is a member of that group.
ls -l /dev/hidraw1
crw-rw---- 1 root audio 240, 1 Nov 4 13:35 /dev/hidraw1
The arbiter program
The script uses only the Python standard library. It is saved as /usr/local/bin/pttmux and made executable.
sudo nano /usr/local/bin/pttmux
sudo chmod 755 /usr/local/bin/pttmux
#!/usr/bin/env python3
"""pttmux - merge PTT requests from several modems into one PTT line.
Several modems (Dire Wolf 1200/9600, XARA) share a single radio. Each modem is
pointed at its own FIFO, which looks like a "CM108 hidraw device" to it. A modem
keys the radio by writing the usual 5-byte CM108 HID output report
[0, 0, data, mask, 0] data bit (gpio-1) = 1 -> PTT on
to its FIFO. This daemon tracks one state per client and drives the real PTT
line so that PTT is asserted while ANY client wants to transmit and released
only when ALL have released. Without this, whichever modem finishes first would
drop PTT in the middle of another modem's transmission.
The real PTT line is chosen with --output:
cm108 GPIO of a CM108/CM119 interface, through --device /dev/hidrawN (default)
rts RTS of the serial port --device; DTR is held low
dtr DTR of the serial port --device; RTS is held low
--invert reverses the polarity of a serial line (keyed = line low).
Safety: PTT is forced off at start-up and shutdown, and a per-client watchdog
releases a client that has held PTT longer than --max-on seconds.
Overlapping requests (two modems transmitting at once) are logged; audio from the
modems is mixed by ALSA dmix, so an overlap is a collision on the air, exactly as
on any shared packet channel.
"""
import argparse
import fcntl
import grp
import os
import select
import signal
import stat
import struct
import sys
import termios
import time
def log(msg):
print(msg, flush=True) # journald adds the timestamp
class Cm108Output:
"""CM108/CM119 GPIO, driven with a HID output report."""
def __init__(self, device, bit):
self.device, self.bit = device, bit
self.fd = os.open(device, os.O_WRONLY)
def describe(self):
return f"CM108 GPIO bit {self.bit:#04x} on {self.device}"
def set(self, on):
os.write(self.fd, bytes([0, 0, self.bit if on else 0, self.bit, 0]))
def close(self):
os.close(self.fd)
class SerialOutput:
"""RTS or DTR of a serial port; the other line is held low."""
def __init__(self, device, line, invert):
self.device, self.line, self.invert = device, line, invert
self.fd = os.open(device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
try:
if not os.isatty(self.fd):
raise OSError(f"{device} is not a serial port")
# Opening a port raises RTS and DTR; both are cleared at once.
self._modem(termios.TIOCMBIC, termios.TIOCM_RTS | termios.TIOCM_DTR)
# Keep the lines as they are when the port closes (the default would
# drop them, which keys an inverted interface).
try:
attrs = termios.tcgetattr(self.fd)
attrs[2] &= ~termios.HUPCL
termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
except termios.error:
if invert:
raise
self.set(False)
except BaseException:
os.close(self.fd)
raise
def describe(self):
name = "RTS" if self.line == termios.TIOCM_RTS else "DTR"
pol = "inverted" if self.invert else "normal"
return f"{name} ({pol} polarity, other line held low) on {self.device}"
def _modem(self, request, bits):
fcntl.ioctl(self.fd, request, struct.pack("I", bits))
def _lines(self):
return struct.unpack("I", fcntl.ioctl(self.fd, termios.TIOCMGET, struct.pack("I", 0)))[0]
def set(self, on):
high = on != self.invert
self._modem(termios.TIOCMBIS if high else termios.TIOCMBIC, self.line)
if bool(self._lines() & self.line) != high:
raise OSError(f"{self.device}: modem line did not follow")
def close(self):
os.close(self.fd)
class Mux:
def __init__(self, a):
self.args = a
self.bit = 1 << (a.gpio - 1)
self.dry = a.dry_run
self.max_on = a.max_on
self.stop = False
self.out = None
self.hw_on = None # unknown until first write
self.last_err = ("", 0.0)
self.fd = {} # client -> fifo fd
self.name = {} # fifo fd -> client
self.on_since = {} # client -> monotonic time PTT went on, or None
self.buf = {}
gid = grp.getgrnam(a.group).gr_gid if a.group else -1
os.makedirs(a.dir, exist_ok=True)
for c in a.client:
p = os.path.join(a.dir, c)
# Reuse an existing FIFO: a modem (xara) keeps its end open for its whole
# life, so replacing the inode on a pttmux restart would orphan it.
try:
if not stat.S_ISFIFO(os.stat(p).st_mode):
os.unlink(p)
raise FileNotFoundError
except FileNotFoundError:
os.mkfifo(p, 0o660)
os.chmod(p, 0o660)
if gid != -1:
os.chown(p, -1, gid)
# O_RDWR: we are always a reader (so a modem's open() never blocks)
# and a writer (so we never see EOF when modems come and go).
fd = os.open(p, os.O_RDWR | os.O_NONBLOCK)
self.fd[c] = fd
self.name[fd] = c
self.on_since[c] = None
self.buf[c] = b""
log(f"client {c}: {p}")
# ---- real PTT line ----------------------------------------------------
def open_output(self):
a = self.args
if a.output == "cm108":
out = Cm108Output(a.device, self.bit)
else:
line = termios.TIOCM_RTS if a.output == "rts" else termios.TIOCM_DTR
out = SerialOutput(a.device, line, a.invert)
log("output: " + out.describe())
return out
def drop_output(self):
if self.out is not None:
try:
self.out.close()
except OSError:
pass
self.out = None
def hw(self, on):
if self.dry:
self.hw_on = on
log(f"[dry-run] PTT line would be {'ON' if on else 'OFF'}")
return
try:
if self.out is None:
self.out = self.open_output()
self.out.set(on)
self.hw_on = on
except OSError as e:
msg, when = self.last_err
now = time.monotonic()
if str(e) != msg or now - when >= 30: # one line per 30 s, not one per retry
log(f"ERROR on {self.args.device}: {e}")
self.last_err = (str(e), now)
self.drop_output()
def want(self):
return any(t is not None for t in self.on_since.values())
def reconcile(self):
w = self.want()
if w != self.hw_on:
self.hw(w)
if self.hw_on == w:
log("PTT " + ("ON" if w else "OFF"))
# ---- client reports ---------------------------------------------------
def report(self, c, rep):
data, mask = rep[2], rep[3]
if not mask & self.bit:
return # not about our GPIO
on = bool(data & self.bit)
was = self.on_since[c] is not None
if on and not was:
others = [o for o, t in self.on_since.items() if t is not None and o != c]
self.on_since[c] = time.monotonic()
log(f"{c} keyed" + (f" ** OVERLAP with {','.join(others)} **" if others else ""))
elif not on and was:
held = time.monotonic() - self.on_since[c]
self.on_since[c] = None
log(f"{c} released after {held:.1f}s")
def feed(self, c, data):
b = self.buf[c] + data
while len(b) >= 5:
rep = b[:5]
if rep[0] != 0 or rep[4] != 0: # not a CM108 report: resync
b = b[1:]
continue
b = b[5:]
self.report(c, rep)
self.buf[c] = b
def watchdog(self):
now = time.monotonic()
for c, t in self.on_since.items():
if t is not None and now - t > self.max_on:
log(f"WATCHDOG: {c} held PTT > {self.max_on}s, forcing release")
self.on_since[c] = None
# ---- main loop --------------------------------------------------------
def run(self):
p = select.poll()
for fd in self.name:
p.register(fd, select.POLLIN)
self.hw(False) # known-safe start
# A reused FIFO may hold stale reports (e.g. an old PTT-ON written just before we
# were restarted). Never replay those: discard everything, fail safe = PTT off.
for fd, c in self.name.items():
drained = 0
while True:
try:
d = os.read(fd, 4096)
except BlockingIOError:
break
if not d:
break
drained += len(d)
if drained:
log(f"discarded {drained} stale byte(s) from {c}")
log("ready; PTT off")
while not self.stop:
try:
events = p.poll(500)
except InterruptedError:
continue
for fd, _ in events:
try:
data = os.read(fd, 4096)
except BlockingIOError:
continue
if data:
self.feed(self.name[fd], data)
self.watchdog()
self.reconcile()
def shutdown(self):
for c in self.on_since:
self.on_since[c] = None
self.hw(False)
self.drop_output()
log("PTT forced OFF; exiting")
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("--output", choices=("cm108", "rts", "dtr"), default="cm108",
help="real PTT line: CM108 GPIO (default) or serial RTS/DTR")
ap.add_argument("--device", default="/dev/uri125-ptt",
help="hidraw node (cm108) or serial port (rts, dtr)")
ap.add_argument("--gpio", type=int, default=3, choices=range(1, 9), metavar="1-8",
help="GPIO number the modems key in their CM108 reports (default 3)")
ap.add_argument("--invert", action="store_true", help="serial output: keyed = line low")
ap.add_argument("--dir", default="/run/pttmux", help="directory for client FIFOs")
ap.add_argument("--group", default="audio", help="group given access to the FIFOs")
ap.add_argument("--client", action="append", required=True, help="client name (repeatable)")
ap.add_argument("--max-on", type=float, default=120.0, help="per-client watchdog, seconds")
ap.add_argument("--dry-run", action="store_true", help="never touch the PTT line; log only")
a = ap.parse_args()
if a.invert and a.output == "cm108":
ap.error("--invert applies to the serial outputs (rts, dtr)")
m = Mux(a)
def bye(*_):
m.stop = True
signal.signal(signal.SIGTERM, bye)
signal.signal(signal.SIGINT, bye)
try:
m.run()
finally:
m.shutdown()
if __name__ == "__main__":
sys.exit(main())
The service
The service runs the arbiter as the pttmux account with the three FIFO names dw1200, dw9600, and xara. The RuntimeDirectory setting creates /run/pttmux at start. The default --output cm108 writes to the HID node named by --device. The --gpio 3 option names the GPIO number that the modems key in their pipes and that the CM108 output drives, which is 3 on the URI 125 and on most CM108 interfaces.
sudo nano /etc/systemd/system/pttmux.service
[Unit]
Description=PTT multiplexer (shared by Dire Wolf and XARA)
After=sound.target
[Service]
Type=simple
User=pttmux
Group=pttmux
SupplementaryGroups=audio plugdev
RuntimeDirectory=pttmux
RuntimeDirectoryMode=0755
RuntimeDirectoryPreserve=restart
ExecStart=/usr/local/bin/pttmux --device /dev/uri125-ptt --gpio 3 --dir /run/pttmux --client dw1200 --client dw9600 --client xara
Restart=always
RestartSec=2
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictAddressFamilies=AF_UNIX
CapabilityBoundingSet=
DevicePolicy=closed
DeviceAllow=char-hidraw rw
UMask=0007
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now pttmux
Created symlink '/etc/systemd/system/multi-user.target.wants/pttmux.service' → '/etc/systemd/system/pttmux.service'.
The service state, the three pipes, and the start-up log confirm the arbiter.
systemctl is-active pttmux
ls -l /run/pttmux/
sudo journalctl -u pttmux -n 6 --no-pager -o cat
active
total 0
prw-rw---- 1 pttmux audio 0 Nov 4 13:35 dw1200
prw-rw---- 1 pttmux audio 0 Nov 4 13:35 dw9600
prw-rw---- 1 pttmux audio 0 Nov 4 13:35 xara
Started pttmux.service - PTT multiplexer (shared by Dire Wolf and XARA).
client dw1200: /run/pttmux/dw1200
client dw9600: /run/pttmux/dw9600
client xara: /run/pttmux/xara
output: CM108 GPIO bit 0x04 on /dev/uri125-ptt
ready; PTT off
Keying the radio by hand
A short manual key-down confirms that GPIO 3 is the right pin for the interface and that the radio transmits. The test is run into a dummy load. Writing the report bytes to a pipe acts exactly like a modem keying PTT.
sudo sh -c 'printf "\000\000\004\004\000" > /run/pttmux/xara'
sleep 2
sudo sh -c 'printf "\000\000\000\004\000" > /run/pttmux/xara'
The first command sends data 04 with mask 04 (GPIO 3 on), and the second sends data 00 with the same mask (GPIO 3 off). The radio keys for two seconds, and the arbiter logs the event.
sudo journalctl -u pttmux -n 4 --no-pager -o cat
xara keyed
PTT ON
xara released after 2.0s
PTT OFF
An interface that keys on a different GPIO number takes that number in the --gpio option of the service and in the Dire Wolf and XARA PTT settings that follow. For GPIO number n, the data and mask bytes of the manual test are both 2 raised to the power n minus 1 (4 for GPIO 3, 8 for GPIO 4).
Other PTT Interfaces: Serial Port and VOX
The CM108 GPIO is one of three common ways for a sound interface to key a radio. Interfaces such as the DigiRig key the radio with a control line of a USB serial port. Interfaces such as the Tigertronics SignaLink USB key the radio from the audio itself (VOX). Each method fits the same station design with a small change.
| Interface type | Example | How the radio is keyed | PTT arbiter | Dire Wolf PTT line | XARA ptt setting |
|---|---|---|---|---|---|
| CM108 GPIO | Allscan URI 125 | GPIO pin of the sound chip | --output cm108 | PTT CM108 3 /run/pttmux/NAME | cm108:/run/pttmux/xara:3 |
| Serial port | DigiRig | RTS (or DTR) of the interface’s serial port | --output rts (or dtr) | the same pipe line as CM108 | the same pipe setting as CM108 |
| VOX | SignaLink USB | audio from the computer trips a voice-operated circuit | not used | no PTT line | vox |
Serial Port PTT (DigiRig and Similar)
The DigiRig Mobile pairs a CM108-class sound codec with a CP2102 USB serial port. The product page describes its PTT as an open-collector switch controlled by the RTS signal of the COM port. The radio is keyed by raising RTS, and the sound card carries only audio.
The modems keep keying their pipes exactly as in the CM108 build, and the Dire Wolf and XARA PTT settings stay unchanged. The arbiter performs the last step: it turns the combined PTT state into RTS (or DTR) on the serial port, using the same line sequence as XARA’s own serial PTT. RTS or DTR is the keyed line, the other line is held low, and both lines are cleared within milliseconds of the port opening, because Linux raises them at open. When the arbiter exits, the lines stay in their released state.
The serial port appears under /dev/serial/by-id/ with a name that stays the same between boots and across USB ports.
ls -l /dev/serial/by-id/
lrwxrwxrwx 1 root root 13 Nov 4 13:35 usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_Controller_SERIAL-if00-port0 -> ../../ttyUSB0
The example shows the naming of a CP210x device. The serial number part of the name differs for every device. The full path, /dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_Controller_SERIAL-if00-port0 in this example, is the SERIAL_PORT value used below.
Serial devices belong to the dialout group, so the arbiter account joins that group.
sudo usermod -aG dialout pttmux
The sound card of the interface is named in /etc/asound.conf. A CM108-class codec captures in mono, so the file from the earlier section applies with the card name changed. The section on sound card changes at the end of this section covers the card name and the channel count.
Three settings in /etc/systemd/system/pttmux.service change. SupplementaryGroups adds the dialout group, ExecStart selects the serial output and names the port, and DeviceAllow permits the serial device classes in place of the HID class.
SupplementaryGroups=audio dialout
ExecStart=/usr/local/bin/pttmux --output rts --device /dev/serial/by-id/SERIAL_PORT --dir /run/pttmux --client dw1200 --client dw9600 --client xara
DeviceAllow=char-ttyUSB rw
DeviceAllow=char-ttyACM rw
The original SupplementaryGroups=audio plugdev, ExecStart, and DeviceAllow=char-hidraw rw lines are replaced by the lines above. The udev rule for the CM108 HID node is unnecessary for a serial interface.
The options of the serial output are:
--output rtskeys with RTS and holds DTR low.--output dtrkeys with DTR and holds RTS low.--invertreverses the polarity for interfaces that key when the line is low. The idle state is then a high line, and the arbiter keeps it high when it exits.--devicenames the serial port.--gpiokeeps its default of 3, because it names the GPIO number that the modems key in their pipes.
sudo systemctl daemon-reload
sudo systemctl restart pttmux
sudo journalctl -u pttmux -n 6 --no-pager -o cat
Started pttmux.service - PTT multiplexer (shared by Dire Wolf and XARA).
client dw1200: /run/pttmux/dw1200
client dw9600: /run/pttmux/dw9600
client xara: /run/pttmux/xara
output: RTS (normal polarity, other line held low) on /dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_Controller_SERIAL-if00-port0
ready; PTT off
The output: line names the line, the polarity, and the port. The manual key test from the arbiter section then confirms the interface. The radio keys for two seconds into a dummy load, and the log shows the same four lines as before.
sudo sh -c 'printf "\000\000\004\004\000" > /run/pttmux/xara'
sleep 2
sudo sh -c 'printf "\000\000\000\004\000" > /run/pttmux/xara'
A station that runs a single modem can key the serial port directly. Dire Wolf takes the port and the line in its PTT setting, and a leading minus sign inverts the polarity.
PTT /dev/serial/by-id/SERIAL_PORT RTS
XARA takes the same information in its ptt setting, with dtr: available for DTR interfaces.
ptt = rts:/dev/serial/by-id/SERIAL_PORT
VOX PTT (SignaLink and Similar)
A VOX interface keys the radio from the audio. The SignaLink USB keys the radio automatically (“Auto-PTT”) through a relay-based keying circuit when audio arrives from the computer, and Tigertronics specifies an adjustable transmit hang time of 30 ms to 3 seconds. The interface needs no PTT line, so the PTT arbiter has no job in a VOX station.
The shared audio design already provides what a VOX station needs. The dmix plugin mixes the output of all three modems into the one stream that reaches the interface, and any modem that plays audio keys the radio. Overlapping transmissions mix in the stream, just as they do on a shared channel. Digital silence between transmissions leaves the VOX circuit idle.
A VOX station skips the PTT arbiter section, the udev rule for the HID node, and the pttmux account. Three configuration changes replace them.
Dire Wolf. The PTT lines are removed from packet.conf, and the unit file drops the dependency on the arbiter. Dire Wolf keys the radio only on channels that have a PTT line.
# Dire Wolf: two modems on one stereo pair, VOX interface (no PTT lines)
ADEVICE dw_in dw_out
ARATE 48000
ACHANNELS 2
CHANNEL 0
MYCALL N0CALL-10
MODEM 1200
TXDELAY 50
TXTAIL 30
CHANNEL 1
MYCALL N0CALL-10
MODEM 9600
TXDELAY 30
TXTAIL 30
KISSPORT 8001
AGWPORT 0
[Unit]
Description=Dire Wolf soundmodem - 1200 and 9600 baud AX.25
After=sound.target network.target
The [Unit] lines above replace the After= and Requires= lines of direwolf-packet.service, and the rest of the unit stays the same. At start-up Dire Wolf confirms the setup and states its VOX requirement.
Channel 0: 1200 baud, AFSK 1200 & 2200 Hz, A+, 48000 sample rate, Tx AX.25.
Channel 1: 9600 baud, K9NG/G3RUH, +, 48000 sample rate x 3, Tx AX.25.
The ratio of audio samples per sec (48000) to data rate in baud (9600) is 5.0
Increasing the sample rate should improve decoder performance.
Note: PTT not configured for channel 0. (OK if using VOX.)
When using VOX, ensure that it adds very little delay (e.g. 10-20) milliseconds
between the time that transmit audio ends and PTT is deactivated.
For example, if using a SignaLink USB, turn the DLY control all the way
counter clockwise.
Dire Wolf transmits its TXDELAY and TXTAIL periods as flag audio around the data. The VOX circuit therefore keys on the first flags and stays keyed through the tail, and the radio’s key-up time is absorbed by the TXDELAY preamble. The SignaLink’s delay control (DLY in Dire Wolf’s note) is turned fully counter-clockwise, as the note says, so the radio unkeys promptly after the last flag. The TXDELAY values of this build, 500 ms for 1200 baud and 300 ms for 9600 baud, are a starting point, and longer values give a slower radio more time to key up.
XARA. The ptt setting selects VOX, and the lead and tail lines are left out.
# XARA: VARA FM on the shared interface, VOX
call = N0CALL-10
fm = wide
registered = on
audio = alsa
capture = xara_in
playback = xara_out
radio = on
drive-db = -4
ptt = vox
kiss-port = off
addr = 127.0.0.1
port = 8300
The start-up log names the PTT method.
xara 0.1.3: N0CALL-10 FM Wide, cmd 127.0.0.1:8300 data 127.0.0.1:8301, xara_in -> xara_out
xara: RADIO MODE: transmitting into "xara_out", PTT VOX (no PTT line), lead 0 ms, tail 100ms, watchdog 30s
The drop-in file for the XARA service lists only Dire Wolf for ordering.
[Unit]
After=direwolf-packet.service
StartLimitIntervalSec=0
LinBPQ. The LinBPQ configuration is identical to the CM108 build, including the TXDELAY and TXTAIL values on the two packet ports.
The audio levels follow the same procedure as the other interfaces (see the adjustment section). On a SignaLink the TX and RX knobs on the front panel set the levels at the interface, and the ALSA mixer sets the level from the computer.
Sound Card Changes for Other Interfaces
The card name and the capture channel count are the two interface-specific values in /etc/asound.conf. The card name appears in square brackets in the card list, and the stream listing shows the channels and sample rates of both directions.
cat /proc/asound/cards
cat /proc/asound/card0/stream0
0 [Device ]: USB-Audio - USB Audio Device
C-Media Electronics Inc. USB Audio Device at usb-0000:02:1b.0-1, full speed
C-Media Electronics Inc. USB Audio Device at usb-0000:02:1b.0-1, full speed : USB Audio
Playback:
Status: Stop
Interface 1
Altset 1
Format: S16_LE
Channels: 2
Endpoint: 0x01 (1 OUT) (ADAPTIVE)
Rates: 48000, 44100
Bits: 16
Channel map: FL FR
Capture:
Status: Stop
Interface 2
Altset 1
Format: S16_LE
Channels: 1
Endpoint: 0x82 (2 IN) (SYNC)
Rates: 48000, 44100
Bits: 16
Channel map: MONO
The example shows the CM108 interface used throughout this article: stereo playback, mono capture, and 48000 Hz available in both directions. An interface with the same layout takes only the card name. Both pcm "hw:CARD=Device,DEV=0" lines in uri_capture and uri_playback change to the card name shown in brackets for the new interface, for example pcm "hw:CARD=CARDNAME,DEV=0".
An interface that captures in stereo takes three additional edits. The dsnoop slave in uri_capture changes to channels 2, and the two capture routes (dw_in and xara_in) take their audio from the left channel of the stereo stream. The following definitions replace uri_capture, dw_in, and xara_in. The rest of the file stays the same.
pcm.uri_capture {
type dsnoop
ipc_key 584410
ipc_perm 0660
ipc_gid audio
slave {
pcm "hw:CARD=CARDNAME,DEV=0"
channels 2
rate 48000
format S16_LE
period_size 480
buffer_size 4800
}
}
pcm.dw_in {
type plug
slave.pcm {
type route
slave.pcm "uri_capture"
slave.channels 2
ttable.0.0 1
ttable.1.0 1
}
}
pcm.xara_in {
type plug
slave.pcm {
type route
slave.pcm "uri_capture"
slave.channels 2
ttable.0.0 1
}
}
In these routes, ttable.0.0 1 copies the left channel of the stream to the first output channel, and ttable.1.0 1 copies the same left channel to the second output channel of dw_in. A test with a left-channel tone confirms the mapping: the tone appears on both channels of dw_in and on the single channel of xara_in.
arecord -D dw_in -f S16_LE -r 48000 -c 2 -d 3 -t raw | wc -c
576000
Three seconds of 48000 Hz, 16-bit, two-channel audio is 576000 bytes, which shows that the new devices open and deliver samples. The audiotest.py script from the audio section runs unchanged after the edit and reports the RMS level of both channels.
Interfaces whose mixer controls have other names are adjusted with the names that amixer -c CARDNUMBER scontrols lists. Automatic gain control, where the interface has it, is switched off in the same way as on the CM108 interface.
Dire Wolf: 1200 and 9600 Baud Packet
One program, two modems
A single Dire Wolf instance runs both packet speeds. It reads the stereo dw_in device and treats the left channel as radio channel 0 (1200 baud AFSK) and the right channel as radio channel 1 (9600 baud G3RUH). Both channels share one KISS TCP port, and LinBPQ addresses them as KISS channels A and B.
sudo install -d /etc/direwolf
sudo nano /etc/direwolf/packet.conf
# Dire Wolf: two modems on one stereo pair
# channel 0 (left) = 1200 baud AFSK
# channel 1 (right) = 9600 baud G3RUH
ADEVICE dw_in dw_out
ARATE 48000
ACHANNELS 2
CHANNEL 0
MYCALL N0CALL-10
MODEM 1200
TXDELAY 50
TXTAIL 30
PTT CM108 3 /run/pttmux/dw1200
CHANNEL 1
MYCALL N0CALL-10
MODEM 9600
TXDELAY 30
TXTAIL 30
PTT CM108 3 /run/pttmux/dw9600
# LinBPQ attaches here: KISS channel A = radio channel 0, B = radio channel 1
KISSPORT 8001
AGWPORT 0
Each setting has a specific purpose.
ADEVICE dw_in dw_outnames the shared ALSA capture and playback devices from/etc/asound.conf.ARATE 48000andACHANNELS 2match the shared devices. 48000 Hz is the highest sample rate the CM108 offers.MODEM 1200andMODEM 9600select the modem type for each channel.PTT CM108 3 /run/pttmux/dw1200keys GPIO 3 through the arbiter pipe of that channel. Each channel has its own pipe. Serial-port interfaces use the same line, and VOX interfaces omit it, as described in the section on other PTT interfaces.TXDELAYis the length of the flag sequence Dire Wolf transmits between key-up and the first data, in units of 10 ms. The 500 ms (value 50) on 1200 baud and 300 ms (value 30) on 9600 baud give the radio and the receiving stations time to settle before data starts.TXTAILis the length of the flag sequence Dire Wolf keeps transmitting after the data, in units of 10 ms, and PTT stays down for that time. The 300 ms (value 30) exceeds the 200 ms dmix buffer, so the final frame of every transmission has left the sound card before the radio unkeys.KISSPORT 8001opens the KISS TCP port, andAGWPORT 0turns the AGW port off.
LinBPQ sends its own TXDELAY and TXTAIL values over KISS after it connects, with a delay of up to about a minute and a half. The matching values in this file apply from the first second after every Dire Wolf start, so the correct timing is in force during that interval as well.
The service
sudo nano /etc/systemd/system/direwolf-packet.service
[Unit]
Description=Dire Wolf soundmodem - 1200 and 9600 baud AX.25
After=sound.target pttmux.service network.target
Requires=pttmux.service
[Service]
Type=simple
User=direwolf
Group=direwolf
SupplementaryGroups=audio
ExecStart=/usr/bin/direwolf -c /etc/direwolf/packet.conf -t 0
Restart=on-failure
RestartSec=10
Nice=-5
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
CapabilityBoundingSet=
DevicePolicy=closed
DeviceAllow=char-alsa rw
[Install]
WantedBy=multi-user.target
The Requires=pttmux.service line ties Dire Wolf to the arbiter, and -t 0 turns off colored output so the journal stays readable.
sudo systemctl daemon-reload
sudo systemctl enable --now direwolf-packet
sudo journalctl -u direwolf-packet -b --no-pager -o cat | head -20
Started direwolf-packet.service - Dire Wolf soundmodem - 1200 and 9600 baud AX.25.
Dire Wolf Release 1.8.1, November 2025
Includes optional support for: gpsd hamlib cm108-ptt
Reading config file /etc/direwolf/packet.conf
Could not extract card number or name from dw_out
Can't automatically find matching HID for PTT.
Could not extract card number or name from dw_out
Can't automatically find matching HID for PTT.
Audio input device for receive: dw_in (channels 0 & 1)
Audio out device for transmit: dw_out (channels 0 & 1)
Channel 0: 1200 baud, AFSK 1200 & 2200 Hz, A+, 48000 sample rate, Tx AX.25.
Channel 1: 9600 baud, K9NG/G3RUH, +, 48000 sample rate x 3, Tx AX.25.
The ratio of audio samples per sec (48000) to data rate in baud (9600) is 5.0
Increasing the sample rate should improve decoder performance.
Using /run/pttmux/dw1200 GPIO 3 for channel 0 PTT control.
Using /run/pttmux/dw9600 GPIO 3 for channel 1 PTT control.
Disabled AGW network client port.
Ready to accept KISS TCP client application 0 on port 8001 ...
The two “Could not extract card number” lines come from Dire Wolf’s automatic PTT detection, which runs before it applies the explicit pipe paths. The Using ... for channel 0 PTT control lines confirm that the explicit paths took effect. The sample-rate message is informational, because 48000 Hz is the interface maximum.
Reading the receive log
Received frames appear in the journal with a line for the audio level and a line for the decoded frame.
sudo journalctl -u direwolf-packet -f -o cat
W1ABC-6 audio level = 25(7/6) |||||||__
[0.3] W1ABC-6>APBPQ1,WIDE1-1,WIDE2-1:!0000.00N/00000.00W&Example BPQ node
The number after audio level = is the received level, and the pair in parentheses holds the mark and space tone peaks. Received levels between about 25 and 40 decoded cleanly on the reference station. The bar graph visualizes the same level. In [0.3], the first number is the radio channel and the digit after the dot identifies which of the parallel decoders produced the frame. The prefix [0L] marks a frame that the station transmitted on channel 0, and [1L] marks one on channel 1.
The receive level is adjusted with the Mic capture control of the interface (amixer -c 0 sget Mic shows its range).
XARA: VARA FM
Building
XARA is built from source with the Rust toolchain installed earlier. The reference build uses commit 09a9201 (version 0.1.3) of the project.
mkdir -p ~/src && cd ~/src
git clone https://github.com/x0rro/xara.git
cd xara
git checkout 09a9201
Registered VARA users enable the registered speeds with a build feature.
cargo build --release --locked -p xara --features registered
An unregistered build omits the feature.
cargo build --release --locked -p xara
Compiling pkg-config v0.3.34
Compiling adler2 v2.0.1
Compiling num-traits v0.2.19
Compiling miniz_oxide v0.8.9
Compiling alsa-sys v0.3.1
Compiling xara v0.1.3 (/home/USERNAME/src/xara/crates/xara)
...
Compiling xara-ui v0.1.3 (/home/USERNAME/src/xara/crates/xara-ui)
Finished `release` profile [optimized] target(s) in 44.00s
The build takes about a minute on four cores. A registered build accepts the registered option at run time, and the registered option is rejected by a build that omits the feature. The two binaries, xara and xara-monitor, land in target/release.
Installing
The project’s installer copies the binaries, the systemd template unit, the udev rule, and the service account definition into place.
sudo ./dist/install.sh target/release
sudo systemd-sysusers /usr/lib/sysusers.d/xara.conf
sudo udevadm control --reload
id xara
Creating group 'xara' with GID 982.
Creating user 'xara' (xara modem) with UID 982 and GID 982.
uid=982(xara) gid=982(xara) groups=982(xara)
The installer provides a systemd template named xara@.service. An instance named gateway reads its settings from /etc/xara/gateway.conf. The template gives the service account the audio, dialout, and plugdev groups.
Configuration
sudo nano /etc/xara/gateway.conf
sudo chown root:xara /etc/xara/gateway.conf
sudo chmod 640 /etc/xara/gateway.conf
# XARA: VARA FM on the shared radio
call = N0CALL-10
fm = wide
# registered VARA users add this line (requires the registered build)
registered = on
# shared ALSA devices from /etc/asound.conf
audio = alsa
capture = xara_in
playback = xara_out
radio = on
drive-db = -4
# PTT through the arbiter pipe, GPIO 3
ptt = cm108:/run/pttmux/xara:3
ptt-lead-ms = 0
ptt-tail-ms = 100
# LinBPQ uses the VARA command and data ports
kiss-port = off
addr = 127.0.0.1
port = 8300
The settings work as follows.
callis the callsign XARA answers to. Using the RMS gateway callsign (SSID 10) keeps the on-air identity identical in every mode.fm = wideorfm = narrowselects the VARA FM mode. The HF alternative is covered in the last sections.registered = onenables the registered speeds. It is a line for registered VARA users and requires the registered build.captureandplaybackname the shared ALSA devices.radio = onenables transmitting, anddrive-db = -4sets the transmit drive in dB (zero or lower). The level is tuned with the audio adjustment procedure later in this article.ptt = cm108:/run/pttmux/xara:3keys GPIO 3 through the arbiter pipe. XARA also offersrts:DEVICE,dtr:DEVICE,vox, andrigctld:HOST:PORTfor other kinds of interfaces, and the section on other PTT interfaces covers the serial and VOX cases.kiss-port = offturns XARA’s own KISS port off, because LinBPQ uses the VARA ports.port = 8300sets the command port, and the data port is always the next number, 8301.
The service template needs two ordering additions, kept in a drop-in file. The first makes XARA depend on the PTT arbiter and start after Dire Wolf. The second lifts the start limit, so systemd keeps retrying if the sound interface appears late at boot.
sudo mkdir -p /etc/systemd/system/xara@.service.d
sudo nano /etc/systemd/system/xara@.service.d/override.conf
[Unit]
Requires=pttmux.service
After=pttmux.service direwolf-packet.service
StartLimitIntervalSec=0
sudo systemctl daemon-reload
sudo systemctl enable --now xara@gateway
sudo journalctl -u xara@gateway -b --no-pager -o cat | head -10
Started xara@gateway.service - xara VARA-compatible modem (gateway).
xara: config /etc/xara/gateway.conf
xara: alsa playback "xara_out": S16_LE, 1 channel(s), 48000 Hz, period 480, buffer 7680 frames
xara: alsa capture "xara_in": S16_LE, 1 channel(s), 48000 Hz, period 480, buffer 4800 frames
xara: settings in /var/lib/xara/gateway/settings
xara: audio: TX path 40 ms = capture 0 + backlog 0 + lead 0 + keying 0 + queue 40 + interp 0.2
xara 0.1.3: N0CALL-10 FM Wide, cmd 127.0.0.1:8300 data 127.0.0.1:8301, xara_in -> xara_out
xara: warning: /run/pttmux/xara is not a hidraw device; CM108 PTT may not work
xara: RADIO MODE: transmitting into "xara_out", PTT CM108 GPIO3 on /run/pttmux/xara, lead 0 ms, tail 100ms, watchdog 30s
0.000 expect [0] x1
The N0CALL-10 FM Wide banner shows the mode and ports, and the RADIO MODE line confirms the PTT path. The hidraw warning is informational, because the pipe stands in for the hidraw device by design.
Checking registration
XARA answers the VARA command protocol on port 8300. A registered build started with the registered option reports REGISTERED after a MYCALL command. The check is run before LinBPQ is connected, because XARA serves its command port to one program at a time.
python3 - <<'EOF'
import socket, time
s = socket.create_connection(("127.0.0.1", 8300), timeout=3)
s.settimeout(1.0)
s.sendall(b"MYCALL N0CALL-10\r")
t0, buf = time.time(), b""
while time.time() - t0 < 8:
try:
data = s.recv(4096)
if not data:
break
buf += data
except socket.timeout:
pass
print(buf.decode().replace("\r", "\n").strip())
EOF
BUSY ON
OK
BUSY OFF
REGISTERED N0CALL-10
ENCRYPTION DISABLED
The REGISTERED N0CALL-10 line confirms the registered mode. The BUSY ON and BUSY OFF lines show XARA’s busy-channel detector reacting to activity on the audio input.
LinBPQ: The Winlink Gateway and Packet Node
Installing
LinBPQ is a single 64-bit binary plus a set of web pages. Both are downloaded from the author’s site and placed in /opt/linbpq, which belongs to the linbpq account created earlier.
sudo install -d -o linbpq -g linbpq -m 0750 /opt/linbpq /opt/linbpq/HTML
cd ~
curl -fLO https://www.cantab.net/users/john.wiseman/Downloads/Beta/linbpq64
curl -fLO https://www.cantab.net/users/john.wiseman/Downloads/Beta/HTMLPages.zip
sha256sum linbpq64 HTMLPages.zip
2f4f9326d84ea4cdae6ae8594baa071af83a2767b293cccc56d362061eb0dd08 linbpq64
773f997fe3255098aed7518d53d94f62a1a7354e7485dbf25bcfb36982224e2b HTMLPages.zip
The checksums above belong to the downloads used for the reference build (LinBPQ 6.0.26.1). The author publishes updated binaries over time, so the values change with each release. Recording the values of the installed copy gives a reference for later comparison.
sudo install -o linbpq -g linbpq -m 0750 linbpq64 /opt/linbpq/linbpq
sudo unzip -o -q HTMLPages.zip -d /opt/linbpq/HTML
sudo chown -R linbpq:linbpq /opt/linbpq
The binary runs on a default Ubuntu Server installation, because the libraries it needs (zlib, D-Bus, and systemd) are part of the base system. When the standard output is not a terminal, LinBPQ disables its console and runs in the foreground, which is the mode systemd uses.
The configuration file
LinBPQ reads bpq32.cfg from its working directory. The file holds the Winlink gateway password, so it is created readable by the linbpq account only.
sudo nano /opt/linbpq/bpq32.cfg
sudo chown linbpq:linbpq /opt/linbpq/bpq32.cfg
sudo chmod 600 /opt/linbpq/bpq32.cfg
The file is assembled from the blocks below, in this order: node identity, the APRS block (covered in its own section), the four ports, and the application line. The first block sets the identity of the node.
; N0CALL-10 Winlink RMS gateway and N0CALL-7 packet node
; Port 1 Telnet, web administration, and Winlink CMS access
; Port 2 1200 baud packet (Dire Wolf channel 0 = KISS channel A)
; Port 3 9600 baud packet (Dire Wolf channel 1 = KISS channel B)
; Port 4 VARA FM (XARA command port 8300, data port 8301)
SIMPLE
NODECALL=N0CALL-7
NODEALIAS=ALIAS
LOCATOR=AA00aa
MAPCOMMENT=BPQ32 NODE BY N0CALL, CITY STATE 145.050
PASSWORD=SYSOP_PASSWORD
IDINTERVAL=0
BTINTERVAL=0
IDMSG:
145.050 MHz N0CALL-7 BPQ NODE. RMS N0CALL-10
***
INFOMSG:
Welcome to the N0CALL packet/VARA FM node, CITY STATE. AA00aa.
Winlink RMS N0CALL-10 is available on 145.050 MHz over 1200 and 9600 baud
packet and VARA FM Wide.
***
CTEXT:
Welcome to the N0CALL-7 BPQ Node.
Type ? for help.
ALIAS:N0CALL} CONNECT BYE INFO NODES ROUTES PORTS RMS USERS MHEARD
***
FULL_CTEXT=1
MAXLINKS=100
MAXNODES=300
MAXCIRCUITS=150
IDLETIME=1200
ENABLE_LINKED=A
SIMPLEapplies a set of sensible defaults for the node.NODECALLis the callsign of the node.NODEALIASis a short name of up to six characters.LOCATORis the grid square. It also enables position reporting to the BPQ32 node map, andMAPCOMMENTsupplies the text shown there.PASSWORDsets the node sysop password.IDINTERVAL=0andBTINTERVAL=0turn off the periodic ID and beacon transmissions of the node itself. The APRS beacon is separate and has its own interval.IDMSG:,INFOMSG:, andCTEXT:hold the text blocks users see. Each block ends with***.MAXLINKS,MAXNODES,MAXCIRCUITS, andIDLETIMEset the capacity and idle timeout of the node.ENABLE_LINKED=Arestricts the*** LINKEDcommand to application programs.
The first port carries Telnet, the web interface, and the connection to the Winlink CMS.
PORT
PORTNUM=1
ID=Telnet
DRIVER=TELNET
QUALITY=0
CONFIG
LOGGING=1
CMS=1
CMSCALL=N0CALL
CMSPASS=GATEWAY_PASSWORD
CloseOnDisconnect=1
HTTPPORT=8080
TCPPORT=8010
FBBPORT=8011
MAXSESSIONS=15
USER=SYSOP_USER,SYSOP_PASSWORD,N0CALL,"",SYSOP
ENDPORT
CMS=1turns the port into the gateway connection to the Winlink CMS.CMSCALLis the callsign registered with Winlink, normally the base callsign.CMSPASSis the Winlink gateway password. Together they form the secure login to the CMS.HTTPPORT,TCPPORT, andFBBPORTare the web interface (8080), the Telnet port (8010), and a monitoring port for BPQTermTCP (8011).USERdefines a login. The fields are the user name, the password, the callsign, an optional application, andSYSOPto grant sysop rights.QUALITY=0keeps routing traffic off this port.
The two packet ports connect to the same Dire Wolf KISS port. LinBPQ chains ports that share an address and TCP port onto one connection and routes the frames by KISS channel.
PORT
PORTNUM=2
ID=145.050 MHz 1200 bps
TYPE=ASYNC
PROTOCOL=KISS
IPADDR=127.0.0.1
TCPPORT=8001
CHANNEL=A
PACLEN=80
TXDELAY=500
TXTAIL=300
SLOTTIME=100
PERSIST=63
FULLDUP=0
WL2KREPORT PUBLIC, api.winlink.org, 80, N0CALL-10, AA00aa, 00-23, 145050000, PKT1200, 50, 30, 11, 0
ENDPORT
PORT
PORTNUM=3
ID=145.050 MHz 9600 bps
TYPE=ASYNC
PROTOCOL=KISS
IPADDR=127.0.0.1
TCPPORT=8001
CHANNEL=B
PACLEN=80
TXDELAY=300
TXTAIL=300
SLOTTIME=100
PERSIST=63
FULLDUP=0
WL2KREPORT PUBLIC, api.winlink.org, 80, N0CALL-10, AA00aa, 00-23, 145050000, PKT9600, 50, 30, 11, 0
ENDPORT
TYPE=ASYNC,PROTOCOL=KISS,IPADDR, andTCPPORTdescribe a KISS-over-TCP link to Dire Wolf.CHANNEL=Aselects Dire Wolf radio channel 0 (1200 baud), andCHANNEL=Bselects radio channel 1 (9600 baud).PACLEN=80sets the maximum packet length.TXDELAYandTXTAILare in milliseconds here, and they match the values in the Dire Wolf configuration (which uses units of 10 ms). The 300 ms tail exceeds the 200 ms audio buffer so that the last frame of a transmission plays out completely.SLOTTIMEandPERSISTare the CSMA parameters, andFULLDUP=0selects half duplex.WL2KREPORTpublishes the channel to the Winlink channel list. Its fields are covered below.
The VARA FM port connects to XARA.
PORT
PORTNUM=4
ID=145.050 MHz VARA FM Wide
DRIVER=VARA
CONFIG
ADDR 127.0.0.1 8300
FM9600
WL2KREPORT PUBLIC, api.winlink.org, 80, N0CALL-10, AA00aa, 00-23, 145050000, VARAFM96, 50, 30, 11, 0
ENDPORT
APPLICATION 1,RMS,C 1 CMS,N0CALL-10,GBRMS
DRIVER=VARAloads the VARA driver.ADDR 127.0.0.1 8300names the XARA command port, and the driver uses the next port number, 8301, for data.FM9600marks the port as VARA FM wide for the Winlink channel list.FM1200marks VARA FM narrow.APPLICATION 1,RMS,C 1 CMS,N0CALL-10,GBRMScreates the RMS application. When a station connects toN0CALL-10(or the aliasGBRMS) on any port, LinBPQ runsC 1 CMS, which connects through port 1 to the Winlink CMS. The1in the command is the number of the Telnet port.
The Winlink channel report
Each WL2KREPORT line adds one channel to the Winlink gateway list. LinBPQ reports shortly after it starts and then every two hours. The fields appear in this order.
| Field | Example | Meaning |
|---|---|---|
| Service | PUBLIC | the service code; PUBLIC is the default |
| Host, Port | api.winlink.org, 80 | the Winlink reporting server |
| Callsign | N0CALL-10 | the gateway callsign with SSID |
| Locator | AA00aa | the grid square |
| Hours | 00-23 | the hours of operation; 00-23 means continuous |
| Frequency | 145050000 | the center frequency in Hz |
| Mode | PKT1200 | the mode code, listed below |
| Power | 50 | transmitter power in watts |
| Antenna height | 30 | in feet |
| Antenna gain | 11 | in dB |
| Antenna direction | 0 | in degrees; 0 means omnidirectional |
The mode codes used in this build are PKT1200, PKT9600, and VARAFM96 (VARA FM wide). The complete mode table, including the other VARA modes, appears in the section on changing bands and modes.
The service
sudo nano /etc/systemd/system/linbpq.service
[Unit]
Description=LinBPQ packet node and Winlink RMS gateway
After=network-online.target direwolf-packet.service xara@gateway.service
Wants=network-online.target direwolf-packet.service xara@gateway.service
[Service]
Type=simple
User=linbpq
Group=linbpq
WorkingDirectory=/opt/linbpq
ExecStart=/opt/linbpq/linbpq
Restart=on-failure
RestartSec=15
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/opt/linbpq
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
CapabilityBoundingSet=
UMask=0077
[Install]
WantedBy=multi-user.target
The After and Wants lines start LinBPQ after both modems. LinBPQ reconnects by itself whenever a modem restarts.
sudo systemctl daemon-reload
sudo systemctl enable --now linbpq
sudo journalctl -u linbpq -b --no-pager -o cat | grep -E 'Initialising|Connected to VARA'
Initialising Port 01 Telnet Server
Initialising Port 02 TCPKISS IP 127.0.0.1 Port 8001 Chan A
Initialising Port 03 TCPKISS IP 127.0.0.1 Port 8001 Chan B
Initialising Port 04 VARA Host 127.0.0.1 8300
Connected to VARA TNC Port 4
Dire Wolf logs the matching connection.
sudo journalctl -u direwolf-packet -b --no-pager -o cat | grep -E 'Attached|KISS protocol set TX'
Attached to KISS TCP client application 0 on port 8001 ...
KISS protocol set TXDELAY = 50 (*10mS units = 500 mS), chan 0
KISS protocol set TXtail = 30 (*10mS units = 300 mS), chan 0
KISS protocol set TXDELAY = 30 (*10mS units = 300 mS), chan 1
KISS protocol set TXtail = 30 (*10mS units = 300 mS), chan 1
The KISS protocol set lines show LinBPQ delivering its timing values to Dire Wolf. They appear after a delay.
Testing the connection to the Winlink CMS
The LinBPQ web interface listens on port 8080. An SSH tunnel from the administrator’s computer brings it to a local address.
ssh -L 8080:127.0.0.1:8080 USERNAME@GATEWAY
The page http://127.0.0.1:8080 then opens in a browser on that computer. According to the LinBPQ documentation, connections from the local machine log in automatically, and the “SYSOP Signin” link accepts the USER credentials from the configuration file. On the “Terminal” page, the command RMS opens a connection to the CMS, and the command B closes it.
The same test runs from a shell on the gateway with a Telnet client.
sudo apt install -y telnet
telnet 127.0.0.1 8010
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
user:SYSOP_USER
password:SYSOP_PASSWORD
Connected to N0CALL-7's Telnet Server
RMS
Trying ec2-52-21-40-82.compute-1.amazonaws.com
*** N0CALL Connected to CMS
[WL2K-5.0-B2FWIHJM$]
;PQ: 86860125
CMS via N0CALL >
B
Disconnecting...
The lines *** N0CALL Connected to CMS and [WL2K-5.0-B2FWIHJM$] confirm that the gateway callsign and password are accepted by Winlink. The ;PQ: line is the secure-login challenge.
APRS Digipeater and iGate
APRS runs inside LinBPQ. The APRSDIGI block of bpq32.cfg configures it, and no separate program is involved. The optional BPQAPRS map and messaging client is a separate application, and the RUN BPQAPRS.exe line found in Windows configurations belongs to that client. The block is placed above the first PORT line.
APRSDIGI
APRSCALL N0CALL-5
APRSPATH 2=APRS,WIDE1-1,WIDE2-1
DIGICALLS=N0CALL
STATUSMSG=145.050 MHz N0CALL-7 BPQ/APRS NODE. RMS N0CALL-10, APRS 5
SYMBOL=&
SYMSET=\
BeaconInterval=60
TraceCalls=WIDE,TRACE
FloodCalls=USA
MAXTRACEHOPS=2
MAXFLOODHOPS=2
LAT=DDMM.mmN
LON=DDDMM.mmW
ISHOST=noam.aprs2.net
ISPORT=14580
ISPASSCODE=APRS_PASSCODE
ISFILTER=m/50
***
APRSCALLis the callsign of the APRS station (the iGate).APRSPATH 2=APRS,WIDE1-1,WIDE2-1selects LinBPQ port 2, the 1200 baud port, as the APRS RF port and sets the destination and digipeater path of its beacons. Only ports that have anAPRSPATHline take part in APRS.DIGICALLS,TraceCalls, andFloodCallsdefine which path entries the digipeater answers.MAXTRACEHOPSandMAXFLOODHOPScap the hop count it accepts.STATUSMSGis the text of the status beacon.SYMBOL=&withSYMSET=\selects the gateway icon.BeaconInterval=60sends the position beacon every 60 minutes.LATandLONgive the position in APRS format: degrees and decimal minutes, for example4015.50Nand07500.25W.ISHOST,ISPORT, andISPASSCODEdefine the connection to the APRS-IS network. Port 14580 is the filtered feed, and the passcode comes from the callsign.ISFILTER=m/50asks the server for stations within 50 km. LinBPQ sends this filter at login when the BPQAPRS client is not running, and the value is adjusted to the coverage wanted.
LinBPQ reads the APRS block when it starts.
sudo systemctl restart linbpq
sudo journalctl -u linbpq -b --no-pager -o cat | grep APRS
Using Shared Memory /BPQAPRSSharedMem
Map APRS Shared Memory Allocated at 0x43000000
APRS Digi/Gateway Enabled
The connection to APRS-IS appears in the socket list.
sudo ss -tnp | grep 14580
ESTAB 0 0 192.168.1.50:57784 192.104.234.219:14580 users:(("linbpq",pid=13436,fd=10))
The first beacon goes out on the radio shortly after the start. A neighboring digipeater that repeats it returns a copy with its own callsign added to the path, which confirms the RF path in both directions.
sudo journalctl -u direwolf-packet -b --no-pager -o cat | grep -E 'N0CALL-5'
[0L] N0CALL-5>APBPQ1,WIDE1-1,WIDE2-1:!DDMM.mmN\DDDMM.mmW&145.050 MHz N0CALL-7 BPQ/APRS NODE. RMS N0CALL-10, APRS 5
[0.3] N0CALL-5>APBPQ1,W1ABC-6,WIDE1*,WIDE2-1:!DDMM.mmN\DDDMM.mmW&145.050 MHz N0CALL-7 BPQ/APRS NODE. RMS N0CALL-10, APRS 5
The station also appears on https://aprs.fi/info/a/N0CALL-5 after its first beacon reaches APRS-IS.
Firewall
Dire Wolf, LinBPQ, and XARA open TCP ports for each other. Dire Wolf and LinBPQ listen on all addresses, so the firewall limits inbound traffic to SSH, and the web interface is reached through the SSH tunnel shown above.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp comment ssh
sudo ufw --force enable
sudo ufw status
Firewall is active and enabled on system startup
Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere # ssh
22/tcp (v6) ALLOW Anywhere (v6) # ssh
The ports in use are:
| Port | Program | Purpose | Reachable from |
|---|---|---|---|
| 22 | sshd | administration | the network |
| 8001 | Dire Wolf | KISS for LinBPQ | the gateway itself |
| 8010, 8011, 8080 | LinBPQ | Telnet, monitoring, web interface | the gateway itself, or an SSH tunnel |
| 8300, 8301 | XARA | VARA command and data | the gateway itself (bound to 127.0.0.1) |
The firewall leaves outbound connections open, which covers the Winlink servers and APRS-IS.
Starting Everything at Boot
Every piece of the station is a systemd service, and each one is enabled so that the whole chain starts by itself.
sudo systemctl enable pttmux direwolf-packet xara@gateway linbpq ufw
The services start in a fixed order, which systemd builds from the Requires, Wants, and After lines written earlier.
| Service | Starts after | Relationship |
|---|---|---|
| pttmux | sound.target | creates the PTT pipes in /run/pttmux |
| direwolf-packet | pttmux | requires pttmux |
| xara@gateway | pttmux, direwolf-packet | requires pttmux; the start limit is lifted, so systemd keeps retrying |
| linbpq | network-online, direwolf-packet, xara@gateway | wants both modems and reconnects whenever either one restarts |
| ufw | early boot | applies the firewall rules |
The remaining pieces persist by themselves. The ALSA configuration in /etc/asound.conf and the udev rule load at boot, the mixer state saved with alsactl store is restored by the ALSA restore service when the sound card appears, and the XARA, Dire Wolf, and LinBPQ configuration files stay in place.
A virtual machine returns after a host restart when its “Start at boot” option is enabled in the hypervisor, and the USB interface follows the machine when it is attached by vendor and device ID.
Verifying a reboot
A full reboot confirms the whole chain.
sudo systemctl reboot
After the machine returns, the services, the boot time, and any failed units are checked.
systemctl is-active pttmux direwolf-packet xara@gateway linbpq ufw
systemd-analyze
systemctl --failed --no-legend
active
active
active
active
active
Startup finished in 1.042s (kernel) + 1.725s (initrd) + 3.131s (userspace) = 5.899s
The empty output of systemctl --failed means that no unit failed. The interface, the PTT name, and the saved mixer level are checked next.
lsusb | grep -i c-media
ls -l /dev/uri125-ptt
amixer -c 0 sget Speaker | grep Front
Bus 002 Device 002: ID 0d8c:0012 C-Media Electronics, Inc. USB Audio Device
lrwxrwxrwx 1 root root 7 Nov 4 13:35 /dev/uri125-ptt -> hidraw1
Front Left: Playback 21 [57%] [-16.00dB] [on]
Front Right: Playback 21 [57%] [-16.00dB] [on]
The modem and gateway logs show the connections made by themselves during start-up.
sudo journalctl -u xara@gateway -b --no-pager -o cat | grep -E 'Mycall|Listen|REGISTERED'
sudo journalctl -u linbpq -b --no-pager -o cat | grep -E 'Connected to VARA|APRS Digi'
3.930 > Mycall(Ok(["N0CALL-7", "N0CALL-10"]))
3.930 > Listen(true)
8.930 < REGISTERED N0CALL-7 N0CALL-10
Connected to VARA TNC Port 4
APRS Digi/Gateway Enabled
The Mycall line shows LinBPQ handing both of its callsigns to XARA, and the REGISTERED line shows XARA reporting the registered mode for both. On the reference station all five services were active five seconds into boot, and the first APRS beacon followed about half a minute after the LinBPQ service started.
Testing the Gateway
Receive
Received packets from other stations on the channel appear in the Dire Wolf log as soon as the station is running. A decoded APRS beacon from a neighboring station, as shown earlier, proves the receive chain from the antenna to the decoder.
Transmit
Test transmissions go through the same path as real sessions: the modem, the shared mixer, the PTT arbiter, the interface, and the radio. A small script sends identified UI frames through Dire Wolf’s KISS port. The source address of every frame is the station callsign, so every transmission carries identification.
nano ~/kisstest.py
#!/usr/bin/env python3
"""Send identified AX.25 UI test frames to Dire Wolf through its KISS TCP port.
usage: kisstest.py CALLSIGN-SSID CHANNEL [BURSTS] [FRAMES_PER_BURST]
CHANNEL 0 = 1200 baud, 1 = 9600 baud
BURSTS number of transmissions, 6 seconds apart (default 3)
FRAMES_PER_BURST frames queued together in each transmission (default 3)
Every frame carries CALLSIGN-SSID as its source address, so each transmission
is identified.
"""
import socket
import sys
import time
FEND, FESC, TFEND, TFESC = 0xC0, 0xDB, 0xDC, 0xDD
def address(call, ssid, last):
field = bytes((ord(c) << 1) for c in call.ljust(6))
return field + bytes([0x60 | (ssid << 1) | (1 if last else 0)])
def ui_frame(source, text):
call, _, ssid = source.partition("-")
return (address("TEST", 0, False)
+ address(call, int(ssid or 0), True)
+ bytes([0x03, 0xF0]) # UI control, no layer 3
+ text.encode())
def kiss(channel, frame):
out = bytearray([FEND, channel << 4])
for b in frame:
if b == FEND:
out += bytes([FESC, TFEND])
elif b == FESC:
out += bytes([FESC, TFESC])
else:
out.append(b)
out.append(FEND)
return bytes(out)
def main():
if len(sys.argv) < 3:
sys.exit(__doc__)
source, channel = sys.argv[1].upper(), int(sys.argv[2])
bursts = int(sys.argv[3]) if len(sys.argv) > 3 else 3
per_burst = int(sys.argv[4]) if len(sys.argv) > 4 else 3
pad = ("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 4)[:100]
mode = {0: "1200 baud", 1: "9600 baud"}.get(channel, f"channel {channel}")
sock = socket.create_connection(("127.0.0.1", 8001), timeout=5)
for n in range(1, bursts + 1):
data = b"".join(
kiss(channel, ui_frame(source, f"{source} {mode} test burst {n}/{bursts} frame {k}/{per_burst} {pad}"))
for k in range(1, per_burst + 1))
print(time.strftime("%H:%M:%S UTC", time.gmtime()), f"burst {n}/{bursts}: {per_burst} frame(s), {mode}", flush=True)
sock.sendall(data)
time.sleep(6)
sock.close()
if __name__ == "__main__":
main()
The arguments are the callsign, the channel (0 for 1200 baud, 1 for 9600 baud), the number of bursts, and the number of frames per burst. The second receiver tuned to the station frequency carries the audio. A burst of two or three frames lasts long enough to hear clearly on a voice receiver, and 9600 baud sounds like a short rush of noise.
python3 ~/kisstest.py N0CALL-10 0 3 2
python3 ~/kisstest.py N0CALL-10 1 3 3
14:12:01 UTC burst 1/3: 2 frame(s), 1200 baud
14:12:07 UTC burst 2/3: 2 frame(s), 1200 baud
14:12:13 UTC burst 3/3: 2 frame(s), 1200 baud
14:12:31 UTC burst 1/3: 3 frame(s), 9600 baud
14:12:37 UTC burst 2/3: 3 frame(s), 9600 baud
14:12:43 UTC burst 3/3: 3 frame(s), 9600 baud
The PTT arbiter log shows how long the radio was keyed for each burst. The durations vary with the frame length, the TXDELAY, and the TXTAIL.
sudo journalctl -u pttmux --since "-3min" --no-pager -o cat | grep -E 'keyed|released'
dw1200 keyed
dw1200 released after 3.1s
dw1200 keyed
dw1200 released after 3.1s
dw1200 keyed
dw1200 released after 3.1s
dw9600 keyed
dw9600 released after 1.1s
dw9600 keyed
dw9600 released after 1.1s
dw9600 keyed
dw9600 released after 1.1s
The same log marks any moment when two modems key at once with an OVERLAP note.
The VARA FM path
LinBPQ can place a VARA connection request itself, which exercises LinBPQ, XARA, the mixer, and the radio together. A VARA port is attached first with ATTACH and then called. The commands are typed in the web interface terminal or in a Telnet session to port 8010.
ATTACH 4
C N0CALL-9
The XARA log shows the connect request and the PTT cycles. Closing the session ends the attempt, and XARA returns to listening.
sudo journalctl -u xara@gateway --since "-1min" --no-pager -o cat | grep -E 'Connect|PTT|Listen'
131.000 > Connect { mycall: "N0CALL", remote: "N0CALL-9" }
131.010 < PTT ON
132.230 < PTT OFF
134.910 < PTT ON
136.130 < PTT OFF
140.340 > Disconnect
142.700 > Listen(true)
A client session
A real client confirms everything at once. Winlink Express, WoAD, and similar programs connect to the RMS gateway callsign, in any of the modes, as they would to any other RMS station. The Dire Wolf log records every frame. The abridged trace below comes from a packet session in which the client delivered one message.
sudo journalctl -u direwolf-packet -f -o cat | grep -v 'audio level'
[0.2] W1ABC>N0CALL-10:(SABM cmd, p=1)
[0L] N0CALL-10>W1ABC:(UA res, f=1)
[0L] N0CALL-10>W1ABC:(I cmd, n(s)=0, n(r)=0, p=1, pid=0xf0)Trying ec2-98-82-103-158.compute-1.amazonaws.com<0x0d>
[0.2] W1ABC>N0CALL-10:(RR res, n(r)=1, f=1)
[0L] N0CALL-10>W1ABC:(I cmd, n(s)=1, n(r)=0, p=0, pid=0xf0)*** N0CALL Connected to CMS<0x0d>
[0L] N0CALL-10>W1ABC:(I cmd, n(s)=2, n(r)=0, p=0, pid=0xf0)[WL2K-5.0-B2FWIHJM$]<0x0d>
[0L] N0CALL-10>W1ABC:(I cmd, n(s)=3, n(r)=0, p=0, pid=0xf0);PQ: 69988343<0x0d>
[0L] N0CALL-10>W1ABC:(I cmd, n(s)=4, n(r)=0, p=1, pid=0xf0)CMS via N0CALL ><0x0d>
[0.2] W1ABC>N0CALL-10:(I cmd, n(s)=0, n(r)=5, p=0, pid=0xf0);FW: W1ABC<0x0d>[WoAD-1.5.22-B2FHM$]<0x0d>;PR: 35146639<0x0d>; WL2K DE W1ABC (AA00aa)<0x0d>
[0.2] W1ABC>N0CALL-10:(I cmd, n(s)=1, n(r)=5, p=0, pid=0xf0)FC EM C2AILA3HBI2P 299 256 0<0x0d>F> B3<0x0d>
[0L] N0CALL-10>W1ABC:(I cmd, n(s)=5, n(r)=2, p=1, pid=0xf0)FS Y<0x0d>
[0.2] W1ABC>N0CALL-10:(I cmd, n(s)=2, n(r)=6, p=0, pid=0xf0)... message data ...
[0L] N0CALL-10>W1ABC:(I cmd, n(s)=6, n(r)=4, p=1, pid=0xf0)FF<0x0d>
[0.2] W1ABC>N0CALL-10:(I cmd, n(s)=4, n(r)=7, p=0, pid=0xf0)FQ<0x0d>
[0.2] W1ABC>N0CALL-10:(DISC cmd, p=1)
[0L] N0CALL-10>W1ABC:(UA res, f=1)
The trace reads as follows.
SABMandUAopen the AX.25 connection toN0CALL-10.- The
Trying ...and*** N0CALL Connected to CMSlines show LinBPQ opening the secure connection to the Winlink CMS.[WL2K-...]and;PQ:are the CMS greeting and login challenge. - The client answers with its own greeting (
;FW:,[WoAD-...],;PR:). FC EM ...is the client proposing one message, andFS Yis the CMS accepting it. The binary message data follows.FFfrom the CMS means no mail waits for the client,FQfrom the client ends the exchange, andDISCwithUAcloses the link.
The message appearing in the recipient’s Winlink mailbox confirms the delivery from the other side.
The Winlink channel list
LinBPQ reports the three channels shortly after it starts, and then every two hours. It does not log the result. The Winlink gateway status pages list the channels of the callsign, with the frequency, the mode, the grid square, and the power, height, and gain values from the WL2KREPORT lines.
Adjusting Transmit Audio
The transmit level decides how strongly the radio is modulated. Too little level weakens the signal, and too much level distorts it. Three settings control the level.
- The ALSA
Speakercontrol sets the audio that reaches the radio for all three modes. Steps of 3 dB are applied withamixer -c 0 sset Speaker 3dB+or3dB-, andsudo alsactl storesaves the result. - The XARA
drive-dbvalue trims VARA FM relative to that level. - The data port input level and the trim control of the interface set the sensitivity on the radio side.
The adjustment proceeds mode by mode. The second receiver carries the test bursts from kisstest.py and the VARA connect attempts, and a successful client session in each mode confirms the result. The reference station settled on a Speaker setting of -16 dB and a XARA drive-db of -4, and successful sessions with a client radio confirmed those values. A service monitor or a deviation meter gives a calibrated adjustment when one is available.
XARA’s drive-db takes effect after a service restart.
sudo systemctl restart xara@gateway
Changing Bands and Modes
2 meters and 70 cm
The same build works on either VHF/UHF band. The frequency in the three WL2KREPORT lines, the radio, and the antenna change. The software configuration stays the same.
VARA FM narrow
VARA FM offers a narrow and a wide mode. Three settings keep XARA, LinBPQ, and the Winlink channel list consistent.
| Mode | XARA setting | LinBPQ VARA port line | WL2KREPORT mode |
|---|---|---|---|
| VARA FM wide | fm = wide | FM9600 | VARAFM96 |
| VARA FM narrow | fm = narrow | FM1200 | VARAFM (VARAFM12 is also accepted) |
After the change, XARA and LinBPQ restart.
sudo systemctl restart xara@gateway linbpq
HF
XARA’s HF mode works with the same programs and the same LinBPQ driver. The mode is set in /etc/xara/gateway.conf by replacing the fm line with an hf line holding the bandwidth in Hz: 500, 2300, or 2750.
| Mode | XARA setting | LinBPQ VARA port line | WL2KREPORT mode |
|---|---|---|---|
| VARA HF 500 | hf = 500 | BW500 | VARA500 |
| VARA HF 2300 | hf = 2300 | BW2300 | VARA |
| VARA HF 2750 | hf = 2750 | BW2750 | VARA2750 |
The HF station needs a few other changes.
- Packet modems are a VHF/UHF feature, so an HF-only gateway runs XARA and LinBPQ. The two KISS ports are removed from
bpq32.cfg, thedirewolf-packetservice is dropped from theWantsandAfterlines oflinbpq.serviceand from the XARA drop-in, and the VARA port moves up in the port numbering. TheC 1 CMScommand in theAPPLICATIONline keeps pointing to the Telnet port, which stays port 1. - HF radios key through CAT control or a serial line more often than through a CM108 GPIO. XARA accepts
rts:DEVICE,dtr:DEVICE, andrigctld:HOST:PORT, and the section on other PTT interfaces covers the serial case. Hamlib’srigctldprovides CAT control, and the stable/dev/serial/by-id/device names keep the serial port assignment fixed between boots. - With XARA as the only modem, PTT can key a CM108 interface directly with
ptt = cm108:/dev/uri125-ptt:3, and the PTT arbiter service becomes optional. - The Winlink channel frequency is the center frequency of the signal in Hz. For upper sideband, the center is the dial frequency plus the audio center of the signal, usually 1500 Hz.
- The HF audio levels follow the same procedure as the FM levels, with a lower
drive-dbas the usual starting point.
Hamlib’s rigctld installs from the distribution package.
sudo apt install -y libhamlib-utils
rigctl -l | grep -i 'IC-7300'
rigctld -m 3073 -r /dev/serial/by-id/usb-EXAMPLE-if00 -s 19200
The rigctl -l listing shows the model number of the radio, which is the number passed to -m. A rigctld systemd service keeps the daemon running at boot.
An HF configuration for XARA follows.
call = N0CALL-10
hf = 2300
registered = on
audio = alsa
capture = xara_in
playback = xara_out
radio = on
drive-db = -10
ptt = rigctld:127.0.0.1:4532
kiss-port = off
addr = 127.0.0.1
port = 8300
The matching LinBPQ port replaces the VARA FM port.
PORT
PORTNUM=2
ID=HF VARA 2300
DRIVER=VARA
CONFIG
ADDR 127.0.0.1 8300
BW2300
WL2KREPORT PUBLIC, api.winlink.org, 80, N0CALL-10, AA00aa, 00-23, CENTER_FREQUENCY_HZ, VARA, 50, 30, 11, 0
ENDPORT
XARA also ships xara-monitor, a passive, receive-only VARA HF monitor. It reads the same capture device and is handy for watching band activity before a gateway goes on the air.
Troubleshooting
Most of the symptoms below appeared while this station was built. The last five rows list standard checks that the build did not require.
| Symptom | Cause | Fix |
|---|---|---|
unable to initialize sum ring buffer when a program opens uri_playback | the ipc_key values of dsnoop and dmix are too close together | Set the keys at least 10 apart (584410 and 584420 in this build), clear stale shared-memory segments with ipcs -m and sudo ipcrm -m ID, and restart the services. |
XARA exits with the device buffers at most 50 ms, but --tx-buffer-ms 40 needs 160 ms | the dmix buffer is too small | Set buffer_size 9600 in uri_playback, then restart Dire Wolf and XARA so the shared mixer is rebuilt. |
A client receives the connect acknowledgement (UA) but the session stalls, and only the first frame of each transmission arrives | PTT releases before the audio buffer finishes playing, which truncates the last frame | Set TXTAIL to at least 300 ms in both packet.conf (value 30) and bpq32.cfg (value 300). |
| Short transmissions are inaudible on the monitor receiver, or the start of a burst is lost | the radio and the receivers need longer to settle | Raise TXDELAY: 500 ms for 1200 baud and 300 ms for 9600 baud worked on the reference station. |
| Sessions stall for the first minute or two after Dire Wolf restarts | Dire Wolf runs on its own defaults until LinBPQ delivers the timing values | Keep TXDELAY and TXTAIL in packet.conf as shown, so the correct values apply from the first second. |
| New dmix or dsnoop settings seem ignored | the old shared instance stays alive while any program holds the device | Stop Dire Wolf, XARA, and LinBPQ, clear the segments with ipcrm, and start the services again. |
| A connection to XARA’s port 8300 is refused while LinBPQ runs | XARA serves its command port to one program at a time, and LinBPQ holds it | Read the XARA log instead, or stop linbpq for the duration of a manual test. |
The Dire Wolf log shows is not a supported device type for the PTT pipes | Dire Wolf checks for a real USB HID device and finds a pipe | These lines are informational, and the transmissions work. |
Permission denied on /dev/snd/* or on a PTT pipe | the service account is missing from the audio group | Add the account with sudo usermod -aG audio ACCOUNT and restart its service. |
| A serial-PTT radio keys when it should be idle, and releases when it should be keyed | the interface keys on the opposite line level | Add --invert to the pttmux command line, restart the service, and repeat the manual key test. |
Permission denied on the serial port in the pttmux log | the pttmux account is missing from the dialout group | Run sudo usermod -aG dialout pttmux, confirm that SupplementaryGroups in the unit lists dialout, and restart the service. |
| VOX station: the radio stays keyed after the last frame, or the start of a frame is lost | the interface’s delay is longer than needed, or the preamble is shorter than the radio’s key-up time | Turn the interface delay (DLY on a SignaLink) fully counter-clockwise, and raise TXDELAY for the affected channel. |
| No frames decode | the receive audio is missing or too low | Check the radio data port speed setting, run the audiotest.py check for a non-zero RMS value, and adjust the Mic capture control. |
Maintenance
Service logs are read with journalctl.
sudo journalctl -u linbpq -f
sudo journalctl -u direwolf-packet -f
sudo journalctl -u xara@gateway -f
sudo journalctl -u pttmux -f
LinBPQ also writes its own logs under /opt/linbpq/logs.
A new LinBPQ release replaces the binary. The configuration file stays as it is.
sudo systemctl stop linbpq
sudo install -o linbpq -g linbpq -m 0750 ~/linbpq64 /opt/linbpq/linbpq
sudo systemctl start linbpq
A new XARA version is built and installed the same way as the first one.
cd ~/src/xara
git pull
cargo build --release --locked -p xara --features registered
sudo ./dist/install.sh target/release
sudo systemctl restart xara@gateway
Dire Wolf updates arrive with the regular package updates (sudo apt upgrade).
One archive holds every file of the station, which makes a restore or a second build quick. The archive contains the gateway password, so it is stored somewhere private.
sudo tar czf ~/gateway-config-$(date +%F).tar.gz \
/etc/asound.conf /etc/direwolf /etc/xara \
/etc/udev/rules.d/61-uri125.rules \
/etc/systemd/system/pttmux.service \
/etc/systemd/system/direwolf-packet.service \
/etc/systemd/system/linbpq.service \
/etc/systemd/system/xara@.service.d \
/usr/local/bin/pttmux /opt/linbpq/bpq32.cfg /var/lib/alsa/asound.state
Finished
The station is complete. One radio and one USB interface now serve a Winlink RMS gateway in three modes at once, 1200 baud packet, 9600 baud packet, and VARA FM, with an APRS digipeater and iGate on the same node. ALSA’s dsnoop and dmix share the audio, the PTT arbiter keeps the transmitter keyed until the last modem is finished, and every service returns by itself after a reboot.
The same layout carries over to other bands. The frequency changes for 70 cm, and the XARA mode and the LinBPQ port line change for HF.
References
- XARA: https://github.com/x0rro/xara
- Dire Wolf: https://github.com/wb2osz/direwolf
- LinBPQ installation guide: https://www.cantab.net/users/john.wiseman/Documents/InstallingLINBPQ.html
- LinBPQ Winlink RMS gateway guide: https://www.cantab.net/users/john.wiseman/Documents/LinBPQ_RMSGateway.html
- BPQ32/LinBPQ configuration file reference: https://www.cantab.net/users/john.wiseman/Documents/BPQCFGFile.html
- Winlink channel reporting (WL2KREPORT): https://www.cantab.net/users/john.wiseman/Documents/WL2KReporting.html
- VARA ports in BPQ32/LinBPQ: https://www.cantab.net/users/john.wiseman/Documents/VARA.html
- APRS digipeater and iGate: https://www.cantab.net/users/john.wiseman/Documents/APRSDigiGate.html
- Winlink: https://winlink.org
- APRS-IS: http://www.aprs-is.net/