Or: How I finally sent Winlink mail from my Mac
Hey, what’s this Winlink thing?
That innocent question cost me rather more time than I expected.
I’m an amateur radio operator, VE3ZDN, and one of the things I’ve been gradually assembling is a useful emergency/digital communications toolkit. I already use modes such as WSPR, JS8Call and fldigi, and I wanted to add Winlink to the mix.
Winlink is essentially a worldwide radio email system. A station can connect over RF to an RMS — a Radio Message Server — and exchange email even when ordinary Internet access isn’t available. That makes it particularly interesting for emergency communications.
At least, that’s the theory.
I wanted to do it from my M1 MacBook Air, using my Yaesu FTdx10, preferably without dedicating a Windows computer to the job.
What followed was several days of serial ports, audio devices, CAT commands, ARDOP frames, propagation experiments, bugs, false starts, and more than one moment when I came perilously close to deciding that Winlink simply wasn’t worth the trouble.
And then it worked.
Twice.
And now I have a one-click Mac launcher that brings up the entire station, controls the FTdx10, starts the ARDOP modem, obtains my current Mac location and feeds it into Pat through GPSd, and opens the interfaces I need.
This post documents the whole adventure.
The station
My relevant hardware and software are:
- Apple Silicon MacBook Air, which I call Timnit
- macOS 26.6.2
- Yaesu FTdx10
- 20 m inverted-V dipole at 5 m
- USB connection directly between Mac and radio
- Pat, the open-source Winlink client
- ardopcf, the ARDOP modem
- Hamlib 4.7.2
rigctldCoreLocationCLIgpsdsocat
My callsign is VE3ZDN.
The FTdx10 turns out to be particularly well suited to this because its USB interface actually exposes two virtual serial ports.
Yaesu describes them this way:
- Enhanced COM Port: CAT communication — frequency and mode control
- Standard COM Port: transmit control — PTT, CW keying and digital operation
That seemingly minor fact turned out to be the key to the whole installation.
Attempt number one: MacWinlink
My first instinct was the obvious one: install MacWinlink.
That led me into Wine, WoW64, helper applications and beta releases. I was working with a beta MacWinlink release and its helper application, and at one point discovered that the Wine prefix was missing its syswow64 environment.
I filed a bug report with the developer, John, and he was already working on another beta.
I still hope MacWinlink develops into a great native-feeling solution. But for my purposes — particularly because Winlink is supposed to be part of an emergency communications toolkit — I needed something I could understand and troubleshoot all the way down.
So I moved sideways.
Enter Pat
Pat is an open-source Winlink client written in Go. It runs nicely on macOS and exposes a browser-based interface.
That immediately appealed to me.
The basic idea is straightforward:
Pat
↓
ARDOP modem
↓
radio
↓
RMS Winlink station
Except that the computer also needs to:
- tune the radio to the RMS frequency;
- key the transmitter;
- send and receive modem audio.
Those are three distinct jobs.
My initial configuration used flrig as part of the CAT-control chain. It worked something like:
Pat
↓
rigctld
↓
flrig
↓
FTDX10
Meanwhile, ardopcf handled the modem audio.
This nearly worked.
And “nearly works” is one of the more dangerous phrases in technical troubleshooting.
The first big breakthrough: flrig wasn’t necessary
I eventually came across Carl Howe, WG1V’s excellent post about running Winlink and Pat on an M1 Mac.
Something in it made me stop.
Why was I using flrig at all?
Hamlib’s rigctld can talk directly to a supported transceiver. My installed Hamlib version confirmed that the FTDX10 has a stable backend:
1042 Yaesu FTDX-10 Stable
My Mac exposed two serial devices:
/dev/cu.usbserial-00F490390
/dev/cu.usbserial-00F490391
So I tried this:
rigctl -m 1042 \
-r /dev/cu.usbserial-00F490390 \
-s 38400
At the Rig command: prompt, I entered:
f
The answer:
Frequency: 14009914
Bingo.
Hamlib was talking directly to the FTDX10. The test and the Hamlib model identification are captured in my troubleshooting log.
No flrig.
Next I started rigctld:
rigctld \
-m 1042 \
-r /dev/cu.usbserial-00F490390 \
-s 38400 \
-T 127.0.0.1 \
-t 4532
Hamlib opened the FTdx10 successfully on model 1042 and the serial device above.
Pat could now talk to:
127.0.0.1:4532
and tune the radio directly.
That eliminated an entire layer of software.
The new architecture was:
Pat
↓
rigctld
↓
FTDX10
Much better.
But it still wouldn’t transmit
Pat would tune the Yaesu correctly.
I would select an RMS station and see:
QSY ardop: 14102.3
Connecting to WG0A-10 (ardop)...
Unable to establish connection to remote: Connect timeout
But the FTdx10 never went into transmit.
At first I suspected CAT PTT.
We tested Hamlib manually:
rigctl -m 2 -r 127.0.0.1:4532
Then:
T 1
The Yaesu transmitted.
Then:
T 0
It returned to receive.
So Hamlib could key the radio perfectly.
Pat simply wasn’t doing it.
The Pat configuration said:
"rig": "FTdx10",
"ptt_ctrl": true
but verbose rigctld logging showed frequency commands and no PTT request at all.
That sent me looking for another approach.
The second serial port
This is where the FTdx10 design suddenly made enormous sense.
The Yaesu manual says that the USB bridge presents two ports, specifically so that CAT communication and TX control can be performed independently.
We had already established that:
/dev/cu.usbserial-00F490390
was the Enhanced CAT port.
Therefore:
/dev/cu.usbserial-00F490391
was the Standard TX-control port.
Rather than asking Pat to key the transmitter through Hamlib, I could let ARDOP control PTT itself using RTS.
The FTdx10 provides exactly that option. Its RTTY/data settings allow the USB virtual COM RTS or DTR lines to control transmit.
My radio was configured for RTS.
So ardopcf could be started with:
-p /dev/cu.usbserial-00F490391
and it reported:
Port /dev/cu.usbserial-00F490391 opened
Using RTS on port /dev/cu.usbserial-00F490391 for PTT
Great.
Except the radio still didn’t transmit.
“Not TXEnabled”
The ARDOP log contained the clue:
Mod4FSKDataAndPlay() called when not TXEnabled. Ignoring.
Over and over.
The PTT configuration wasn’t actually the problem.
I opened the ARDOP WebGUI, and suddenly the problem became obvious.
PTT was enabled.
But:
RX Audio Device: NONE
TX Audio Device: NONE
ARDOP wasn’t refusing to transmit because it couldn’t key the radio.
It was refusing because it had no transmit audio device.
Some days, debugging feels less like engineering and more like being mocked by a very literal genie.
Explicitly configuring the USB audio
The FTdx10 appears to macOS as:
USB AUDIO CODEC
and that name contains two spaces between AUDIO and CODEC.
That detail matters.
I started ARDOP with the audio devices explicitly specified:
build/macos/ardopcf \
-p /dev/cu.usbserial-00F490391 \
--hostcommands "MYCALL VE3ZDN;DRIVELEVEL 2" \
8515 \
"USB AUDIO CODEC" \
"USB AUDIO CODEC"
The ARDOP WebGUI now showed:
RXAudio: ENABLED
TXAudio: ENABLED
PTTControl: ENABLED
Now I pressed Send2Tone.
The FTDX10 keyed.
There may have been cheering.
Pat gets out of the PTT business
Once ARDOP had reliable RTS control, Pat no longer needed to handle PTT.
So my Pat configuration became:
"ardop": {
"addr": "127.0.0.1:8515",
"rig": "FTdx10",
"ptt_ctrl": false
}
while the Hamlib rig remained:
"hamlib_rigs": {
"FTdx10": {
"network": "tcp",
"address": "127.0.0.1:4532",
"VFO": ""
}
}
That produced a wonderfully clean separation of responsibilities:
┌── rigctld ── Enhanced USB serial ── CAT |
Pat ────────────────┤
│
└── ardopcf ── Standard USB serial ── RTS/PTT
│
└── USB AUDIO CODEC ── audio
That remains my working configuration.
And then… propagation
By this point ARDOP was transmitting real connection frames:
Sending Frame Type ConReq500M
but RMS stations still weren’t answering.
That was frustrating because I had spent so long assuming every failed connection meant the software stack was broken.
Eventually the evidence began pointing somewhere else:
the ionosphere.
I looked at the morning WSPR reports.
Stations around southern Ontario were making strong 20-metre paths of 2,000–3,000 km and farther, while many of the nearest RMS stations were only a few hundred kilometres away.
That suggested a skip zone.
Instead of repeatedly trying the closest gateways, I started selecting RMS stations around 2,000–3,000 km away.
And then:
2026/08/29 08:26:16 Connecting to K5RAV (ardop)...
2026/08/29 08:26:35 Connected to K5RAV (ardop)
Wait–what‽ YES!
K5RAV was roughly 2,478 km away in Harlingen, TX.
A little later, I connected successfully to N0DAJ, roughly 2,978 km away in Wickenburg, AZ.
After days of wondering whether Winlink actually worked from this station, I had two successful ARDOP RMS connections.
The difference was propagation, not software.
That lesson alone was worth remembering:
Once you have confirmed that the radio is actually transmitting valid ARDOP frames, a failed connection is no longer necessarily a configuration problem.
Sometimes the ionosphere is just being the ionosphere.
Making it one click
Once everything worked, the obvious next question was:
Can I make all of this start automatically?
I already had an AppleScript application:
/Applications/Winlink ARDOP.app
Its job was simply to display three buttons:
Cancel
Stop
Start
and call:
~/bin/start-winlink-ardop
or:
~/bin/stop-winlink-ardop
That was perfect. I didn’t need to rebuild the application.
I only needed to replace the shell scripts behind it.
The launcher now:
- checks that the required software exists;
- checks both FTdx10 serial devices;
- starts
rigctld; - verifies that CAT can read the radio frequency;
- starts
ardopcf; - assigns the Standard serial port to RTS PTT;
- opens the FTdx10 USB audio device;
- sets my callsign;
- sets ARDOP TX DriveLevel to 2;
- starts Pat;
- opens the ARDOP and Pat browser interfaces.
And then I decided that wasn’t quite ridiculous enough.
So I added GPS.
Wait — my Mac knows where it is
Pat can automatically send position reports through Winlink.
I received this acknowledgement:
Subject: Position Report Acknowledgement
The following position report for VE3ZDN has been entered.
Latitude: 43-24.41N
Longitude: 080-24.44W
Comment: EN93tj
But the report contained an old stored position.
My Mac knows where it is through Apple’s Core Location framework.
Could Pat use that?
Not directly.
Pat understands GPSd.
So naturally, the final architecture became:
macOS Core Location
↓
CoreLocationCLI
↓
NMEA generator
↓
socat pseudo-terminal
↓
real gpsd
↓
Pat
Because apparently this project needed one more layer.
Core Location to NMEA1
I installed:
brew install corelocationcli
brew install gpsd
brew install socat
CoreLocationCLI --json produced a live location fix from macOS.
I then wrote a small Python program called:
~/bin/corelocation-nmea
It takes the Core Location coordinates and continuously generates standard NMEA RMC and GGA sentences.
Here is the code:
#!/usr/bin/env python3
import json
import subprocess
import time
import datetime
import sys
def checksum(sentence):
c = 0
for ch in sentence:
c ^= ord(ch)
return f"{c:02X}"
def dd_to_nmea(value, is_lat):
value = float(value)
hemi = (
("N" if value >= 0 else "S")
if is_lat
else ("E" if value >= 0 else "W")
)
value = abs(value)
deg = int(value)
minutes = (value - deg) * 60
if is_lat:
field = f"{deg:02d}{minutes:07.4f}"
else:
field = f"{deg:03d}{minutes:07.4f}"
return field, hemi
def emit(sentence):
full = f"${sentence}*{checksum(sentence)}"
print(full, flush=True)
while True:
try:
result = subprocess.run(
["CoreLocationCLI", "--json"],
capture_output=True,
text=True,
check=True,
timeout=10
)
data = json.loads(result.stdout)
lat, ns = dd_to_nmea(data["latitude"], True)
lon, ew = dd_to_nmea(data["longitude"], False)
now = datetime.datetime.now(datetime.timezone.utc)
hhmmss = now.strftime("%H%M%S")
ddmmyy = now.strftime("%d%m%y")
rmc = (
f"GPRMC,{hhmmss}.00,A,"
f"{lat},{ns},{lon},{ew},"
f"0.0,0.0,{ddmmyy},,,A"
)
emit(rmc)
gga = (
f"GPGGA,{hhmmss}.00,"
f"{lat},{ns},{lon},{ew},"
f"1,08,1.0,0.0,M,0.0,M,,"
)
emit(gga)
except Exception as e:
print(
f"# CoreLocation error: {e}",
file=sys.stderr,
flush=True
)
time.sleep(2)
socat creates a pair of pseudo-serial devices. The Python program writes NMEA to one end, while real gpsd reads the other.
Eventually:
gpspipe -w -n 5
returned:
{"class":"VERSION","release":"3.27.5",...}
followed by a genuine GPSd TPV report containing my current latitude and longitude.
Pat then logged:
Locator changed from EN93tj to EN93TJ
Success.
Pat was consuming the Mac’s current Core Location fix through a real GPSd server.
The finished launcher
My AppleScript application itself is deliberately boring:
set choice to button returned of (display dialog ¬
"Winlink ARDOP station control" buttons {"Cancel", "Stop", "Start"} ¬
default button "Start" with title "Winlink ARDOP")
if choice is "Start" then
display notification "Starting Winlink ARDOP station…" ¬
with title "Winlink ARDOP"
do shell script "/bin/zsh -lc '$HOME/bin/start-winlink-ardop' 2>&1"
display dialog "Winlink ARDOP startup completed." ¬
buttons {"OK"} default button "OK" ¬
with title "Winlink ARDOP"
else if choice is "Stop" then
display notification "Stopping Winlink ARDOP station…" ¬
with title "Winlink ARDOP"
do shell script "/bin/zsh -lc '$HOME/bin/stop-winlink-ardop' 2>&1"
display dialog "Winlink ARDOP station shut down." ¬
buttons {"OK"} default button "OK" ¬
with title "Winlink ARDOP"
end if
The interesting part is in the startup script.
The important radio-specific portion is:
CAT_PORT="/dev/cu.usbserial-00F490390"
PTT_PORT="/dev/cu.usbserial-00F490391"
RIGCTLD_PORT=4532
ARDOP_PORT=8515
PAT_PORT=8080
CAT starts as:
rigctld \
-m 1042 \
-r "$CAT_PORT" \
-s 38400 \
-T 127.0.0.1 \
-t "$RIGCTLD_PORT"
ARDOP starts as:
build/macos/ardopcf \
-p "$PTT_PORT" \
--hostcommands "MYCALL VE3ZDN;DRIVELEVEL 2" \
8515 \
"USB AUDIO CODEC" \
"USB AUDIO CODEC"
and Pat simply starts with:
pat http
The final launcher also starts the Core Location → NMEA → GPSd stack before Pat.
I’ll provide the complete scripts with this post so anyone attempting the same setup can use them as a starting point rather than reconstructing them from snippets.
Important: your serial device names will be different
Do not blindly copy:
/dev/cu.usbserial-00F490390
/dev/cu.usbserial-00F490391
Those names belong to my radio/interface.
Find yours with:
ls /dev/cu.* /dev/tty.* | grep -Ei 'usb|serial'
Then identify which is the FTdx10 Enhanced port.
For me, this test did it:
rigctl \
-m 1042 \
-r /dev/cu.usbserial-00F490390 \
-s 38400
and then:
f
If it returns your current radio frequency, you’ve found the CAT port.
The other FTDX10 USB serial device should then be the Standard TX-control port.
The FTdx10 settings that mattered
The radio itself also needs to be configured appropriately.
In particular:
- CAT baud rate must match Hamlib — mine is 38400
- DATA operation must be configured appropriately
- USB audio must be selected for the digital mode
- RPTT SELECT must be RTS
The Yaesu documentation explicitly identifies RTS/DTR as the USB virtual-COM transmit-control mechanism for digital operation.
My working ARDOP TX DriveLevel is:
2
That number is specific to my Mac/radio audio levels; don’t assume it is correct for yours.
Use enough drive for reliable transmission without overdriving the FTdx10 or producing unwanted ALC activity.
What I learned
Several things turned out to matter far more than I expected.
First: simplify the stack.
Every additional program creates another port, configuration and failure mode.
Removing flrig from the Winlink path was one of the biggest improvements I made. Hamlib already knows how to control the FTDX10 directly.
Second: CAT, PTT and audio are separate things.
It is entirely possible for:
- CAT frequency control to work;
- the radio not to key;
- ARDOP to think it has PTT configured;
- and ARDOP still refuse to transmit because TX audio isn’t enabled.
Ask which layer has actually failed.
Third: the FTdx10’s two USB serial ports are a feature, not an annoyance.
Using the Enhanced port for CAT and the Standard port for RTS PTT gives each application ownership of one interface. Yaesu explicitly designed the ports for those respective functions.
Fourth: logs beat guessing.
A few ARDOP messages became particularly valuable:
Mod4FSKDataAndPlay() called when not TXEnabled
means something very different from:
Sending Frame Type ConReq2000M
And once I began seeing:
DataACK
BREAK
4FSK...
I knew I was dealing with a real two-way ARQ session rather than a transmitter shouting futilely into the void.
Fifth: propagation matters.
A failed Winlink connection is not automatically a broken Winlink installation.
The first station I finally reached was K5RAV, around 2,500 km away — after numerous failures to stations much closer to me.
The second was N0DAJ, around 3,000 km away.
The morning WSPR data had been telling me exactly that sort of path was open.
I just needed to listen.
Was it worth it?
A few days ago, I was very close to giving up on Winlink.
For an emergency communications system, I need something I can understand well enough to troubleshoot. “Install this mysterious helper and hope all the pieces cooperate” is not good enough for me.
Now I have something different.
I know exactly what each component does:
Core Location
↓
CoreLocationCLI
↓
NMEA
↓
gpsd
↓
Pat
│
├── rigctld ── CAT ── FTDX10 Enhanced COM
│
└── ardopcf ── RTS ── FTDX10 Standard COM
│
└──── audio ── USB AUDIO CODEC
I can test each layer independently.
I can read the logs.
And I can launch the whole station with one click.
Most importantly, I have actually connected over RF to multiple Winlink RMS stations.
So yes.
Winlink works on my M1 Mac.
No Windows.
No flrig in the Winlink control path.
No external CAT interface.
Just the FTdx10’s USB connection, Pat, Hamlib, ARDOP, a bit of Unix plumbing, and an unreasonable amount of stubbornness.
And when the screen finally said:
Connected to K5RAV (ardop)
I may have been just a little bit excited.
73,
Doug Nix, VE3ZDN
Kitchener, Ontario
EN93tj
1 National Marine Electronics Association
