KI9NG
June 2026  •  qmx remote ops echocat ft8 docker amateur radio

Operating the QMX+ From My Phone: Headless ECHOCAT in a Box

I wanted to work FT8 and SSB on my QRP Labs QMX+ from a parking lot, a hotel room, or the couch -- with nothing in my hand but a phone. No laptop, no soundcard dongle, no native app to sideload. Just a browser pointed at my own shack. This is the story of getting there, which turned out to be a longer walk through container audio than I expected.

The tool that makes it possible is POTACAT by Casey K3SBP, and specifically its remote feature, ECHOCAT. POTACAT is an Electron app that talks CAT to your radio and streams its audio; ECHOCAT exposes that as a single-page web app over HTTPS and WebSocket. The clever part is that the FT8 engine and the audio bridge run next to the radio -- the phone is just a thin client over WebRTC, so the half-second of audio lag that makes networked WSJT-X miserable on a phone simply isn't in the path. You get a spot list, a PTT button, and an FT8 screen with an optional waterfall (toggled with the WF button) of the decode passband, all in mobile Safari or Chrome.

The Shape of It

The radio lives on an OptiPlex in my basement. That box already runs a WireGuard tunnel into 44Net (AMPRNet), which is how my phone reaches home from the cellular network. So the plan was: run POTACAT headless on the OptiPlex, bind ECHOCAT inside the tunnel, and connect from the phone over the same tunnel.

I run everything on that machine in Docker, and I wanted this to be no exception. The arrangement is two containers. One is the WireGuard tunnel itself, which owns the network namespace and the split routing that keeps my LAN traffic local while sending the rest through 44Net. The second is POTACAT, running headless under Xvfb, sharing the tunnel container's namespace with network_mode: service:wg44. Because POTACAT lives inside the tunnel's network, ECHOCAT comes up reachable both on the 44Net address and on the LAN at the same time. The QMX's USB CAT port and its USB sound card get passed straight into the container.

That part went up cleanly in an afternoon. Then I keyed up.

The Audio Rabbit Hole

The first transmit was a buzz. Not my voice, not FT8 tones -- a flat, ugly buzz. Receive was perfect, transmit was garbage, which is a strange place to be.

The cause is a detail about how ECHOCAT moves audio. Both directions run through the embedded Chromium engine: your phone's microphone audio is played out through Chromium's audio layer to the rig, and the rig's receive audio is captured by Chromium and sent back to the phone. Chromium wants a real sound server underneath it. On a normal desktop -- Windows, macOS, a Raspberry Pi running its standard OS -- one is always there. My minimal Ubuntu container had nothing but raw ALSA, and Chromium playing to bare ALSA produces exactly that buzz. It isn't a POTACAT bug; it's a thing nobody hits because every normal target already has PulseAudio or PipeWire sitting there.

So I added PulseAudio to the container, in system mode, and pointed it at the QMX as an output sink. The buzz cleared in theory -- but I'd made it a sink only, and the moment PulseAudio became Chromium's audio backend, receive went silent. Of course it did: Chromium now wanted to capture from a PulseAudio source, and I'd given it none. The QMX is a full-duplex USB audio device, so the fix was to expose it as both a sink and a source in the same PulseAudio config:

load-module module-alsa-sink   device=hw:CARD=Transceiver,DEV=0 sink_name=qmx    tsched=0
load-module module-alsa-source device=hw:CARD=Transceiver,DEV=0 source_name=qmx_in tsched=0
set-default-sink qmx
set-default-source qmx_in

The tsched=0 matters -- timer-based scheduling tends to crackle on USB audio. With a clean sink and source both defaulted to the QMX, Chromium had both halves of the conversation and the buzz was gone.

The Microphone That Wouldn't

With audio sorted on the server side, I loaded the page on my phone and pressed PTT. It threw an error: it couldn't get the microphone. The browser was refusing.

This is a web platform rule, not a POTACAT one. Browsers only hand out the microphone -- getUserMedia -- in a "secure context," which in practice means a page served over HTTPS with a certificate the browser actually trusts. ECHOCAT generates a self-signed certificate out of the box, and a self-signed cert on a bare IP address is not a secure context. Receive audio had worked the whole time because playing audio doesn't require a secure context; only capturing the mic does. That's why I could hear the radio but not talk to it.

The clean fix was a real certificate. I own ki9ng.com on Cloudflare, so I issued a proper Let's Encrypt certificate for echocat.ki9ng.com using a DNS-01 challenge -- no need to expose anything publicly, since the challenge is satisfied entirely through DNS. A grey-cloud A record points that hostname at the 44Net address, ECHOCAT serves the real cert, and acme.sh renews it on a cron. Now the phone connects to https://echocat.ki9ng.com:7300, sees a genuine padlock, decides the page is a secure context, and grants the microphone. The one rule to remember: connect by the hostname, never the raw IP, or the cert won't match and you're back to a blocked mic.

The Silent Transmitter

Last bug, and the sneakiest. FT8 was decoding beautifully, my callsign was set, I hit auto-CQ on 40 meters at four in the morning when 40 should be wall-to-wall -- and got zero spots. The radio was clearly keying. Nobody was hearing me.

The trail led back to the audio server. Earlier I'd reloaded the container with a plain docker restart to pick up a settings change. Docker keeps the container's writable layer across a restart, and PulseAudio in system mode leaves a PID file behind in /run. On the restart it found that stale PID file, decided a daemon was already running, and refused to start. With no PulseAudio, Chromium quietly fell back to bare ALSA -- the original buzz -- so every FT8 transmission went out as noise nobody could decode. Receive kept working on bare-ALSA capture, which is exactly what hid the problem from me.

The fix is for the container to clean up after itself. Its startup script now wipes the stale PulseAudio runtime before launching the daemon, so it always comes up on a clean slate:

pkill -9 pulseaudio 2>/dev/null || true
rm -rf /run/pulse 2>/dev/null || true

There's a nice bonus here. The certificate renewal restarts the container to reload the new cert -- the same docker restart that bit me. Making the container self-clean on every start means the nightly renewal can never silently kill my transmit audio. After the fix, a quick test tone showed the sink running all the way through to the rig, and the next CQ on 40 got picked up across the country.

Where It Landed

What I have now is a 5-watt radio in the basement that I can fully operate from a phone anywhere I have signal. I open a browser, get a trusted padlock, and there's the band activity, a PTT button, and an FT8 screen that decodes and calls CQ on its own -- with a waterfall I can toggle on to see the decode passband. Receive and transmit audio are clean. The whole thing is four files and a couple of containers, all version-controlled, and it rebuilds from scratch with one command.

A few things I'd tell anyone trying this. The microphone needs a real, trusted certificate -- plan for that from the start rather than fighting self-signed warnings. Container audio for anything Chromium-based needs PulseAudio or PipeWire present; bare ALSA will bite you. And if you run an audio daemon in system mode in a container, make the container clean its own runtime on boot, because a restart is not a fresh start.

As with everything I build, I didn't write the code. I knew what I wanted and I worked the problems as they surfaced -- the buzz, the dead microphone, the silent transmitter -- and Claude did the implementation over a long session, driving my server directly through a set of tools while I described goals and read back what the radio was doing. The shack is mine and the ideas are mine. The YAML and the shell are Claude's.

73
Bill KI9NG  |  EN61  |  DMR 3200395
← back to field notes