Four ways to the same result:
Download the files from the latest release.
| Pi | Raspberry Pi 4 or 5 (3B+ works for CAT and a listener or two), 64-bit OS only |
| SD card | 16 GB or more, good quality |
| Network | wired Ethernet preferred, or Wi-Fi; internet access during the install |
| Radio | USB cable from the radio's USB jack to the Pi (CAT and audio travel over the same cable on the FT-991A) |
| PC | to write the SD card (Windows, macOS or Linux) with Raspberry Pi Imager (version 2.x for route A) |
The image is Raspberry Pi OS Lite (64-bit, Debian Trixie) with Radio Remote already installed. It contains no password: you set the user, password, hostname and Wi-Fi while writing the card.
https://github.com/dev-jmatias/YaesuFT991A-Remote/releases/latest/download/os-list.jsonradio), your user name and password, Wi-Fi if you
do not use a cable, the time zone, and keep SSH enabled. Write the card.https://<hostname>.local and continue with section 5 (first run).Root login stays disabled. The user you create has sudo, like on every Raspberry Pi OS.
If Imager has no Content Repository option (older versions), download image_…-radio-remote.img.xz and first-boot-settings.ps1 from the
release, write the image with Choose OS > Use custom (do not unzip it), take the card out and put it back in the PC so the small
bootfs volume shows up (before the card has ever booted), then run in PowerShell 7:
pwsh -ExecutionPolicy Bypass -File first-boot-settings.ps1. It asks for hostname, user, password and Wi-Fi. More: image/README.md.
bash
ssh <user>@<hostname>.local
sudo apt update && sudo apt full-upgrade -y && sudo rebootradio-remote-installer-….zip from the release and unzip it on your PC. It is a folder (radio-remote-installer) with
install-everything.sh, the radio-remote program, a wheels folder with the Python libraries for the Pi (nothing is compiled or
fetched from PyPI) and the manual.radio-remote-installer):
powershell
scp -r radio-remote-installer <user>@<hostname>.local:~bash
cd ~/radio-remote-installer
sudo bash install-everything.sh
Useful options (all optional): --model ft991a (the default; others are in other radios),
--hostname radio.local, --with-tailscale (installs Tailscale), --no-caddy, --dry-run (shows every step and changes nothing).What it does, in order: installs python3-venv alsa-utils libopus0 curl caddy and friends (needs internet), creates the service user
radio-remote, installs the program under /opt/radio-remote, installs the Python libraries from the bundled wheels folder, writes
/etc/radio-remote/config.toml (transmitting disabled, listening on the Pi only), installs the udev rule and the service, sets up
HTTPS with Caddy (its own local certificate), and waits until the app answers. It never opens a firewall port, never enables
transmitting and never talks to the radio.
# on the Pi: unpack radio-remote-vX.Y.Z.tar.gz from the release (or clone the repository)
cd radio-remote
sudo bash install.sh --dry-run # prints every action, changes nothing
sudo bash install.sh --model ft991a # real install (downloads the Python libraries; several minutes on a Pi)
Options: --model NAME, --hostname NAME, --no-caddy, --with-hamlib, --with-tailscale, --wheels DIR, --no-rade, --rade-file FILE, --force-arch.
install.sh also installs the RADE library (the neural FreeDV mode) by itself; a failure there only prints a hint and the install carries on.
Use bash install.sh (not ./install.sh) when the files came from a Windows PC: the executable flag is lost there. A git checkout does not
contain the built manual; run python scripts/build_docs.py first if you want it on the Pi.
Radio Remote also runs on a normal 64-bit PC (an old laptop or a mini PC next to the radio) with Debian 12 ("bookworm") or Debian 13 ("trixie"). It uses the same installer as route C. This route has been prepared from the Pi installation and checked in test mode on Debian; a real install on a PC is new, so tell us what you meet.
What you need: a 64-bit PC (x86_64), a minimal / server Debian install (no desktop: a desktop's PipeWire or PulseAudio can grab the radio's USB sound card and
block the audio), a wired network connection, internet access during the install, and the USB cable to the radio.
# 1. get the program: download radio-remote-vX.Y.Z.tar.gz from the release page (NOT the installer pack zip: its Python libraries are for the Pi)
tar xzf radio-remote-vX.Y.Z.tar.gz && cd radio-remote
# 2. install (the Python libraries are downloaded from PyPI; a few minutes)
sudo bash install.sh --model ft991a --hostname radio.local
Then continue with First run below. Things that differ from a Pi:
avahi-daemon (it announces the PC) and libnss-mdns (so the PC can look up .local names itself), so https://<hostname>.local works from phones and PCs that understand .local names.
Important: the name announced on the network is the PC's own hostname. --hostname only sets the name the web server answers to, so the two must match: install with the default name, or with --hostname <the PC's hostname>.local,
or rename the PC (sudo hostnamectl set-hostname NAME, then replace the old name by NAME in /etc/hosts, then sudo systemctl restart avahi-daemon). Using the PC's IP address in the browser does not work, because the web server answers only to its name.audio and dialout groups by the installer.install.sh itself: it downloads the library for your CPU from the release page and checks it (use --no-rade to skip it, --rade-file FILE for a local copy).
If that step could not run (no internet, or your release has no file for your CPU), install it later from the web page (Admin > Config > RADE > Install RADE), as long as the release you run offers a library for your CPU
(x86_64 and aarch64 are built). It needs nothing else installed. The library is built on Debian 12, so it works on Debian 12 and on Debian 13. Without internet, or if your release has
no x86_64 file: download radio-remote-rade-linux-x86_64.tar.xz on another computer and run sudo /opt/radio-remote/current/scripts/install_rade.sh --file <file>, or build it yourself on
the PC with sudo apt install git cmake build-essential autoconf automake libtool curl and bash scripts/build_rade.sh (about 5 minutes; it downloads the sources and model weights)
and install the resulting librade-rr.so with install_rade.sh --file.sudo /opt/radio-remote/current/scripts/self_update.sh.ufw or nftables, allow TCP port 443 from your network (and 22 for SSH).systemctl is-active caddy radio-remote avahi-daemon (all active), then
curl -k -sI --resolve NAME:443:127.0.0.1 https://NAME | head -1 (HTTP/2 200; NAME is the name from --hostname, e.g. radio.local).hostname must be NAME without .local; avahi-resolve-host-name NAME should print the PC's address (sudo apt install avahi-utils if it is missing).ping NAME in PowerShell. If it answers from a different address than the PC's (the router keeps old records of names from earlier devices, for example an earlier Raspberry Pi
called ft991a), Windows asks the router first and never finds the PC. Fix: use a name nobody has used, or tell that PC the address by hand: add PC-ADDRESS NAME to
C:\Windows\System32\drivers\etc\hosts (Notepad as administrator), then ipconfig /flushdns. For good, give the PC a fixed address on the router (a DHCP reservation) and delete the old names from the router's list.sudo python3 /opt/radio-remote/current/scripts/rr_admin.py render-caddy --host NEWNAME --out /tmp/Caddyfile.new, then sudo caddy validate --config /tmp/Caddyfile.new --adapter caddyfile,
sudo install -m 0644 /tmp/Caddyfile.new /etc/caddy/Caddyfile, sudo systemctl reload caddy..local names at all; try a PC first.https://<hostname>.local. Your browser warns about the certificate until you trust the Pi's local certificate once per
device (security and remote access); listening works regardless, the microphone needs HTTPS.auto finds an FT-991A), Save, restart the service when asked./etc/radio-remote/config.toml, set allow_ptt = true under [safety], then
sudo systemctl restart radio-remote.)Tailscale gives you a private way to the Pi from anywhere, with no router port opened.
--with-tailscale, or follow tailscale.com/download) and sign in once:
sudo tailscale up.sudo /opt/radio-remote/current/scripts/tailscale_setup.sh, then open the https://<pi>.<tailnet>.ts.net address it prints from any
device that has Tailscale on. Details and troubleshooting: tailscale.sudo /opt/radio-remote/current/scripts/self_update.sh (downloads and verifies the new release, makes a backup, installs it).
Without internet: copy the release's radio-remote-vX.Y.Z.tar.gz to the Pi, unpack it and run sudo ./update.sh in the folder.
The old release is kept and the update rolls back by itself if the new one does not start. Details: operations.sudo bash /opt/radio-remote/current/scripts/uninstall.sh (keeps configuration and backups) or --purge (deletes them too).sudo apt install python3 python3-venv alsa-utils libopus0
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp config/radio-remote.example.toml config/radio-remote.toml # edit model/port
PYTHONPATH=backend .venv/bin/python -m radio_remote --config config/radio-remote.toml
Then see packaging/ for the systemd unit and udev rule to adapt.
The error message says what failed. The most common causes: no internet (apt cannot download), a 32-bit OS, or Python older than 3.11.
Run sudo /opt/radio-remote/current/scripts/doctor.sh and see troubleshooting.
How to operate the radio from a phone, tablet or computer. For installing the system see the installation guide; for administering it (users, configuration, backups) see operations.
Open the address the installer printed (for example https://radio.local, or your Tailscale address) and sign in.
The very first visit asks you to create the administrator account. The administrator then creates a user for everyone else
under Admin > Users (roles below).
| Role | Can |
|---|---|
| viewer | watch the radio and listen; cannot control it or transmit |
| operator | take control, tune, change settings, use the microphone and transmit (if transmitting is enabled) |
| admin | everything, plus users, configuration, diagnostics, power on/off |
Tip: add the page to your phone's home screen ("Add to Home Screen" / "Install app") to get a full-screen app. The microphone only works over HTTPS (a secure address). Listening works either way.
Many people can watch at once, but only one controls the radio. The top bar shows your state:
Phones and tablets/computers share the same layout idea: a radio panel on top, four tabs at the bottom: Radio, Filters, Audio & Levels and Digital (FreeDV, FT8/FT4 and SSTV, chosen with a switch: a slim row at the top of the tab on a phone, and in the bar beside the radio's name on a tablet or computer).
Top bar: Full (full screen), Help (this manual), Admin (administrators), Account (your password), Sign out, and for administrators Power off. Radio bar: a green dot = the radio answers, the radio name, and the control status. The Help button opens this manual from the Pi itself (no internet needed).
14.195, or Hz, e.g. 14195000), Mode buttons.PTT stays at the bottom on every tab so you can adjust a level while transmitting.
Squelch (FT-991A, FTDX101D and MP): a Squelch slider appears under the mode buttons in FM, AM and C4FM and on the AIR band (and is always in the Audio & Levels tab); on the FTDX101 there is one for MAIN and one for SUB. 0 is open (no squelch); raise it until the noise just closes. It is the radio's own squelch level (SQ), the same as its SQL knob. On the FTDX101 it is written from the manual and simulator-tested; the FT-991A one was tested on a real radio.
AIR band (FT-991A): the band buttons include AIR (the radio's AIR key, BS14): the radio goes to the airband in AM by itself and the page's tuning step switches to 25 kHz, the airband channel
spacing, and goes back to the previous step when you leave the band.
Memories (beside Tune; FT-991A and FTDX101D) lists the channels stored in the radio (001-099): number, name, frequency, mode. Type in the box to filter. Tap a channel to recall it. Back to VFO returns to the VFO. Re-read reads the list again after you changed memories on the radio. Export saves the list as a CSV file (a backup, or to open in a spreadsheet). The list is read in the background a few seconds after the radio connects, so it opens at once; it is read again whenever the radio reconnects.
Adding, editing and deleting a channel (FT-991A, administrators): Add stores a new channel and Edit (beside each row) changes one: name (up to 12 characters), frequency, mode, shift direction (simplex, plus, minus), tone mode (off, CTCSS encode, CTCSS encode + decode, DCS) and the tone frequency (the 50 CTCSS tones) or DCS code. Opening Edit on a channel with a tone reads that channel's own tone from the radio first (the radio is recalled to that channel for a moment and put back), so changing a name does not change its tone. Delete channel empties it. Saving or deleting asks for confirmation and takes 20 to 30 seconds: the program writes the channel and then reads all channels back, and tells you if the radio did not store it exactly as sent. To store a tone the radio's VFO-A is borrowed for a moment (it is set to the channel's frequency, mode, shift and tone, the channel is written, and VFO-A, its own tone settings and the V/M state are put back as they were). If a write or a delete ever changed a channel other than the one you chose, editing switches itself off (until the service is restarted) and the message names the channels, so put them back from your Export file. Export first if the list matters to you. One limit remains: the repeater offset (for example 600 kHz) is a radio menu setting, not stored per channel, so set it in the radio's menus; the page only chooses the direction.
Transmitting is disabled until the owner enables it: an administrator opens Admin > Config > Transmitting (PTT), chooses
Enable transmitting… and types their password again (or sets allow_ptt = true in the configuration file on the Pi). The button then
says Hold to transmit. An administrator can switch it off again at any time, which also stops a transmission in progress.
Rules that always apply:
+PTT=P when the button goes down and +PTT=R when it goes up. Pair the device with the computer first, then on the Audio tab press
Bind PTT, then Add Bluetooth serial device (it appears while binding), pick the device's port in Chrome's list (named after the device, for example B01PTT-F5D0) and press its PTT button
within 30 seconds. Once the port has been picked, a plain Bind PTT hears it too. The button then gives true hold to talk, the page reopens the port by itself when it is loaded or the device is switched
on again, and a device that disappears while the button is down releases the radio. Needs Chrome or Edge 117 or later (not Safari or Firefox) on
Windows or Linux, and only one program can have the port open. On a Mac Chrome cannot open such a port: use the small Radio Remote PTT helper instead: download radio-remote-ptt-mac.zip from the latest release, unzip it, open it once (right-click > Open, allow Bluetooth) and tick Start at login in its menu-bar icon. On Android install radio-remote-ptt-android.apk from the same release, choose the paired device and press Start. Pair the device with the computer or phone first, then press Bind PTT on the Audio tab and the device's button: the page finds the helper by itself (and shows a download link if it cannot). The helper listens on this computer or phone only, and the page reconnects to it by itself. iPhone and iPad cannot do this. If the port is read but the messages are not +PTT=P / +PTT=R, the page shows what
it received: send that line to the developer and it can often be added.The Audio tab has a parametric equalizer for what you hear (Receive) and for what you send (Transmit), like the one in Thetis: 5, 10 or 18 bands, each with its own frequency, gain (-12 to +12 dB) and Q (width, 0.3 to 10). Drag a node on the graph to set its frequency (left/right) and gain (up/down); the mouse wheel over the graph changes the selected band's Q (on a phone use the Q box); double-click a node to zero it; the boxes under the graph give exact values for the selected band. The curve shows the combined response. Bands changes the number of bands (it starts again flat), Preset (Voice, Bass cut, Treble cut, Bass boost, Treble boost, and FreeDV speech: less bass, more presence at 1 to 4 kHz, which makes decoded RADE speech clearer) sets the gains at your band frequencies, Reset zeroes all gains. It is one setting for the whole radio, shared by everyone connected, and it is kept across restarts. Both sides have a Low-cut and a High-cut (24 dB per octave, off until you tick them; defaults 150 Hz and 2800 Hz for Transmit, 200 Hz and 6000 Hz for Receive): tick the box and type a frequency, or drag the dashed L and H markers on the graph. They keep unwanted rumble and hiss out of the signal and out of the radio's own filter edges. The selected band's Freq, Gain and Q are on one line under the graph. Two buttons on the Radio tab, RX EQ and TX EQ (after Memories), switch each equalizer (and the cuts) on and off so you can compare. It adds about 10 ms of delay while on; very narrow bands below about 150 Hz come out a little wider and weaker than drawn. With FreeDV the equalizer works on the speech only: the modem tones are never equalized.
Each side has a Noise gate under the graph (off until ticked). It turns the audio down by Range dB (default 30) when the level stays under Thresh. (default -50 dB), waits Hold ms (default 150) before it starts to close and closes over Rel. ms (default 250), and opens again at once when the level comes back, so word endings are not chopped. Receive takes the hiss out of the pauses between words (it also works on decoded FreeDV speech); Transmit keeps the microphone's room noise out of the signal and out of the leveler, which would otherwise raise it. A readout shows how many dB the gate is taking off while it is closed. Set Thresh. just above the noise in the pauses and below your quiet words, then listen for chopped word beginnings and lower it if you hear them.
My sets… (beside Reset) saves the current bands (frequency, gain and Q) and the low-cut and high-cut under a name; Use puts a saved set on the Receive or the Transmit equalizer, and Delete removes it. Your sets also appear in the Preset list under My sets. Up to 20 sets are kept; they are shared by Receive and Transmit and by everyone connected, and kept across restarts.
Under the Transmit graph, two more stages for your voice, after the noise gate (they are shared and kept like the equalizer, and they work on the speech only: a FreeDV signal is made from the leveled speech, and a recording sent with Send is not touched):
Both are off until you tick them. Start with the leveler's defaults and the limiter at about -3 dB.
A Recorder block under Mode on the Radio tab has four buttons of one size:
https://<pi-name>.local. Your device must trust the Pi's certificate once (see security and remote access).cw.md).menu.md). Items that could cut the link or change the keying are read only.js8.md).rtty.md).Admin has these tabs: Users (create, roles, trusted, reset password), Clients (connected devices; disconnect one), Config (radio model, serial port, audio devices and gains, meter calibration, SWR warning limit, time limits, the transmit switch and Settings backup: download the settings to a file and restore them later; Updates: whether a newer release exists and how to install it; some changes need a restart), Diagnostics (system, radio link, audio and the recent log) and Audit (who did what). Power off switches the radio to standby; when the radio is off, the offline banner shows Power on radio. Details: operations.
| Symptom | Try |
|---|---|
| Radio offline | cable, radio on, Admin > Diagnostics; see troubleshooting |
| No sound | tap the speaker icon; Audio tab status; the radio's USB OUT LEVEL menu |
| Microphone silent | tap the microphone icon and allow it in the browser; HTTPS needed; the microphone button is on (radio menu 106 = REAR); check To radio while keyed |
| Cannot transmit | transmitting enabled? you have control? not a viewer account? |
| Page looks old after an update | press Ctrl+F5, or the Reload now bar |
install.sh)| Path | What | Owner |
|---|---|---|
/opt/radio-remote/releases/<timestamp>/ |
program releases (read-only to the service) | root |
/opt/radio-remote/current |
symlink to the active release | root |
/opt/radio-remote/venv/ |
Python environment | root |
/etc/radio-remote/config.toml |
settings | radio-remote, mode 0600 |
/var/lib/radio-remote/radio-remote.db |
users, sessions, audit log | radio-remote |
/var/backups/radio-remote/ |
backups (hold password hashes: mode 0600) | root |
/etc/systemd/system/radio-remote*.service/.timer |
units | root |
/etc/udev/rules.d/99-radio-remote.rules |
ModemManager exclusion for the radio's USB bridge | root |
Edit in the web UI (Admin > Config) or in the file. The file is the only place for the settings the UI refuses to
change on purpose: server.*, storage.*. The transmit permission (safety.allow_ptt) has its own switch in the same page
(Transmitting (PTT)): enabling asks for the administrator's password, applies at once, is written to the file and is audited.
Other changes need sudo systemctl restart radio-remote (the log level and audio gains apply live). The UI's
"Restart service now" button does the same.
System card (Admin > Config). Two buttons that are always there: Restart the service… (confirmation, then the page reloads by itself when the service is back) and
Reboot the system… (needs your password again; refused while the radio transmits; it only appears when the root helper radio-remote-power.path is installed, which install.sh and
update.sh do). Like Update now, the page itself cannot reboot anything: it only drops /var/lib/radio-remote/power-request, and a root helper (scripts/power_from_request.sh,
started by systemd) reboots the machine after about 3 seconds, and does nothing else. Both actions are written to the audit log.
| Key | Meaning | Default |
|---|---|---|
server.host / port |
where the app listens. Keep 127.0.0.1; Caddy/Tailscale face the network |
127.0.0.1 / 8080 |
server.allowed_origins |
extra browser origins allowed for WebSockets (needed only if a proxy rewrites Host) |
[] |
radio.model |
mock or ft991a |
mock |
radio.serial_port / baud |
CAT port (auto probes) and speed |
auto / 38400 |
radio.hamlib_model |
reserved (Hamlib backend is not implemented) | 0 |
safety.allow_ptt |
must be true before a real radio can be keyed. Set it with the Transmitting (PTT) switch in Admin > Config (password required) or in the file |
false |
safety.tx_timeout_s |
server-enforced maximum continuous transmission | 120 |
safety.tx_warn |
one steady beep (about 1.5 s) on the transmitting device shortly before the time limit; the timer under PTT also turns red | true |
safety.tx_warn_s |
how many seconds before the limit the beep sounds (1-60) | 10 |
safety.ptt_heartbeat_timeout_s |
un-key if the PTT holder is silent this long | 1.0 |
safety.control_request_timeout_s |
an unanswered request for control passes after this long (trusted users skip the wait) | 10 |
audio.enabled / backend |
remote audio on/off; auto = alsa for a real radio, test tone for the mock |
true / auto |
audio.input_device / output_device |
ALSA devices (plughw:CARD=...,DEV=0) |
auto-detect |
audio.rx_gain_db / tx_gain_db |
-30..30 dB; a limiter always follows TX gain | 0 |
audio.opus_bitrate / max_peers |
codec rate; simultaneous listeners | 32000 / 3 |
ui.tuning_steps_hz |
step choices in the UI | 10..10000 |
ui.meter_alc_full / meter_comp_full |
raw ALC / COMP value at which the radio's own meter is full (provisional calibration) | 160 / 255 |
ui.swr_warn |
SWR warning: while transmitting at or above this ratio the SWR bar turns red and a message appears; 0 = off, otherwise 1.2..10 |
3.0 |
ui.swr_raw_at_3 |
only for radios that report a raw SWR value (FT-991A): the raw reading (10..255) at which the radio's own meter shows 3:1; the ratio is estimated on a straight line from 1:1 = 0. Provisional: the CAT manual gives no calibration | 100 |
logging.level |
DEBUG/INFO/WARNING/ERROR | INFO |
updates.check |
once a day ask GitHub whether a newer release exists and tell the administrators (nothing is installed automatically); also a switch in Admin > Config > Updates | true |
rigctl.enabled / port / allow / set |
the logbook link (logbook): off by default; port 4532; allow = private or a list of addresses/networks; set = false makes it read-only. Also a card in Admin > Config; applies at once |
false / 4532 / private / true |
updates.repo |
the GitHub repository to ask (owner/name); file only |
dev-jmatias/YaesuFT991A-Remote |
storage.data_dir |
database location | /var/lib/radio-remote |
sudo systemctl status radio-remote # is it running?
sudo systemctl restart radio-remote # restart (un-keys the transmitter first)
sudo systemctl stop radio-remote # stop
sudo systemctl disable --now radio-remote # stop and do not start at boot
journalctl -u radio-remote -f # follow the log
journalctl -u radio-remote --since "1 hour ago" --no-pager
systemctl list-timers radio-remote-backup.timer
sudo systemctl status caddy # the HTTPS front end
The service restarts itself after a crash (Restart=always) and is restarted by systemd if it hangs (watchdog, 20 s).
A switched-off or unplugged radio does not restart the service: it reconnects by itself. Stopping or restarting the
service releases PTT before the process exits.
In the UI, Admin > Diagnostics shows version, CPU/RAM/temperature, radio and CAT counters, audio state, clients, the
control holder and the recent log. sudo scripts/doctor.sh prints similar information plus USB/serial/audio devices.
Update notice. Once a day the server asks GitHub whether a newer release exists (one anonymous, read-only request to
api.github.com; switch it off in Admin > Config > Updates or with [updates] check = false). When there is one,
administrators see a bar at the top of the page ("Radio Remote v1.2.0 is available") with a link to the release notes. Nothing is
downloaded or installed by the notice itself.
Update from the page. In Admin > Config > Updates, when a newer version is known, press Update now…, type your password and confirm. The page only
drops a request file; a small root helper that systemd starts for it (radio-remote-update.path / .service, installed by install.sh and update.sh) runs
self_update.sh (download, SHA-256 check, backup, install, health check, automatic rollback). The service restarts, so Radio Remote is unavailable for about a
minute (a transmission in progress is stopped); the Updates card shows the log and the page reloads by itself when it is done. The result is also in
/var/lib/radio-remote/update.log, and the request and the outcome are in the audit log. A Pi that was installed before the helper existed gets it with its next
update from the command line.
Update from the Pi (needs internet on the Pi):
sudo /opt/radio-remote/current/scripts/self_update.sh --check # is there a newer release? (no root needed)
sudo /opt/radio-remote/current/scripts/self_update.sh # download, verify the checksum, install
It downloads the release's update package (radio-remote-vX.Y.Z.tar.gz) and its SHA256SUMS, refuses the package if the checksum
differs or it holds anything unexpected, then runs the package's own update.sh. --force reinstalls the newest release even if it is
not newer.
Without internet on the Pi, or from the source code:
# on a computer with internet: download radio-remote-vX.Y.Z.tar.gz from the Releases page, copy it to the Pi, then
tar xzf radio-remote-vX.Y.Z.tar.gz && cd radio-remote
sudo ./update.sh --dry-run # optional preview
sudo ./update.sh
(From a git checkout: git pull, then sudo ./update.sh; build the manual first with python scripts/build_docs.py if you want it on the Pi.)
update.sh: backs up config and users, installs the new version beside the old one (including Python packages,
before anything is switched), switches, restarts, and checks health. If the new version is not healthy it switches back
to the previous release automatically and keeps the failed one for inspection. The three newest releases are kept.
Config and the user database are never modified by an update.
Manual rollback: sudo ln -sfn /opt/radio-remote/releases/<older> /opt/radio-remote/current && sudo systemctl restart radio-remote.
(If the newer release changed Python dependencies, the older code normally still runs; if not, re-run
/opt/radio-remote/venv/bin/pip install -r /opt/radio-remote/releases/<older>/requirements.txt.)
A timer runs a backup daily (14 kept). Manual:
sudo scripts/backup.sh # -> /var/backups/radio-remote/radio-remote-<time>.tar.gz
sudo scripts/restore.sh /var/backups/radio-remote/radio-remote-20261001-030000.tar.gz
What is backed up: /etc/radio-remote/config.toml and /var/lib/radio-remote/radio-remote.db (a consistent
snapshot even while running). That is everything worth keeping; the program itself is reproducible from git.
Restore stops the service, keeps the replaced files as *.before-restore, restores, restarts and checks health.
Copy backups off the Pi occasionally: they are on the same SD card.
Settings only, from the web page. Admin > Config > Settings backup has Download settings (a small .toml file with the
radio, audio, meter and limit settings: no accounts, no passwords) and Restore from file…. A restore takes only the settings the web page
may change; the listener address, the storage folder and the transmit permission (server.*, storage.*, safety.allow_ptt) are never taken
from a file, so an old backup or a file from another Pi cannot lock you out or switch transmitting on. The replaced config is kept as
config.toml.bak, the change is audited (config_backup, config_restored) and a restart applies it. Use the daily archive above (or
restore.sh) when you also need the user accounts.
On the Pi: sudo -u radio-remote /opt/radio-remote/venv/bin/python -m radio_remote.cli --config /etc/radio-remote/config.toml reset-password <name>
(with PYTHONPATH=/opt/radio-remote/current/backend). It prompts for the new password and signs that user out everywhere.
The manual is installed with the program and served at https://<pi>/docs/ (the Help button in the app). It is built from docs/*.md
by python scripts/build_docs.py --pdf (HTML + a PDF) and travels inside every release as docs-html/.
FT-991A: complete and tested on a real radio. FTDX10, FTDX101D, FTDX101MP and FT-710 have experimental profiles (written from their CAT manuals, tested only against a simulator): other radios. A built-in simulator ("mock") lets you try everything without a radio. What each radio supports: capability matrix.
Connect the radio's rear USB jack straight to the Pi (a short, good-quality cable; avoid unpowered hubs). Switch the radio on.
lsusb # expect a "Silicon Labs CP210x" entry (CAT) and a "USB Audio CODEC"-style entry
ls /dev/serial/by-id/ # the FT-991A's USB bridge presents TWO serial ports (CAT and a second one)
arecord -l ; aplay -l # the radio's sound card, e.g. "card 1: CODEC [USB Audio CODEC]"
The app finds the CAT port itself: it probes only Silicon Labs USB serial ports, sending the read-only ID; and
accepting the one that answers ID0670; (FT-991A). You can also set radio.serial_port explicitly (Admin > Config). The service user must be in
group dialout (the installer does this). Only one program may use the port: close any other CAT software. If the radio is switched off
or unplugged the page shows it as offline and reconnects by itself.
You set these once on the radio. The only menus the program changes by itself are the microphone source ones (below), when you press the microphone button or the last operator leaves. Menu numbers are from the official CAT manual (1711-D).
Basics (every use)
| Menu | Setting | Value |
|---|---|---|
| 031 CAT RATE | 4800 / 9600 / 19200 / 38400 | 38400 (the default of the app; if you change it set radio.baud to match) |
| 036 TX TOT | OFF, 1-30 min | set a value (e.g. 3 min): the radio's own backstop if the Pi ever fails |
| 047, 071, 076, 108 (PTT select) | DAKY / RTS / DTR | DAKY, so nothing can key the radio by toggling the RTS/DTR serial lines. The app opens the port with DTR/RTS low regardless |
| 033 CAT RTS | ENABLE / DISABLE | DISABLE. With ENABLE the radio only talks while the RTS line is high, and the app never raises RTS (it could key the radio); the radio then stays silent and the app says "radio offline" (seen after a factory reset) |
| 032 CAT TOT | leave at the default; raise it only if you see CAT timeouts |
Microphone source: managed by the app (0 = MIC, 1 = REAR)
| Menu | Mode | Remote in use (microphone button on) | Nobody remote (at the radio) |
|---|---|---|---|
| 106 SSB MIC SELECT | SSB | REAR | MIC |
| 045 AM MIC SELECT | AM | REAR | MIC |
| 074 FM MIC SELECT | FM, C4FM | REAR | MIC |
| 070 DATA IN SELECT | DATA modes | REAR | stays REAR (it only matters in the DATA modes) |
Where the audio comes in and goes out: USB (bench-found on a real FT-991A after a reset: the app keyed the radio but no RF came out, because the port menus were on DATA)
| Menu | Mode | Value |
|---|---|---|
| 109 SSB PORT SELECT | SSB | USB (1) |
| 048 AM PORT SELECT | AM | USB (1) |
| 077 FM PORT SELECT | FM, C4FM | USB (1) |
| 072 DATA PORT SELECT | DATA modes | USB (1; see the FreeDV table) |
If PTT from the app keys the radio but an external power meter shows nothing (in SSB, no audio means no RF), check these first.
Receive level sent to the Pi (0-100, default 50): 107 SSB OUT LEVEL in USB/LSB, 073 DATA OUT LEVEL in the DATA modes (and 046 AM, 075 FM, 054 CW, 099 RTTY). If the browser audio is too quiet,
raise it (90 was right on one tested radio, 50 on another) before using audio.rx_gain_db.
FreeDV in the DATA modes (the FreeDV guide's setup; switch on the FreeDV tab's DATA option after setting them)
| Menu | Setting | Value |
|---|---|---|
| 059 CW FREQ DISPLAY | DIRECT FREQ | |
| 062 DATA MODE | PSK / OTHER | OTHER |
| 064 OTHER DISP (SSB), 065 OTHER SHIFT (SSB) | +1500 Hz both | |
| 066 DATA LCUT FREQ, 068 DATA HCUT FREQ | OFF both | |
| 070 DATA IN SELECT | MIC / REAR | REAR |
| 071 DATA PTT SELECT | DAKY | |
| 072 DATA PORT SELECT | DATA / USB | USB (the manual numbers it 1 = DATA, 2 = USB, but a real radio took 0 = DATA, 1 = USB: check the display) |
Not set for FreeDV, but worth knowing: 110 SSB TX BPF narrows the SSB transmit audio (it matters only if you use plain USB/LSB for FreeDV, which is why the DATA modes are recommended), and the speech processor must be off.
Other menus that decide where transmit audio comes from and where receive audio goes (109 SSB PORT SELECT and the AM/FM equivalents) are listed in the manual but without a ready recipe: if your audio does not flow, follow the steps in audio and change one menu at a time.
Power off is an administrator button in the page (PS0;). Power on radio appears in the offline banner when the radio is in standby
(the server sends dummy data, waits 1.4 s, then PS1;, as the manual requires). Both work over USB on the FT-991A, because its USB serial port
stays available in standby while the DC supply is on.
[radio]
model = "ft991a"
serial_port = "auto" # or "/dev/ttyUSB0"; stable names are under /dev/serial/by-id/
baud = 38400
[audio]
backend = "auto" # real radio -> alsa
input_device = "" # radio -> Pi, e.g. "plughw:CARD=CODEC,DEV=0"; empty = auto when exactly one USB audio CODEC exists
output_device = "" # Pi -> radio
A device name is an ALSA name such as plughw:CARD=CODEC,DEV=0, not a bare number (a number like 0 is refused). Leave both empty unless
auto-detection fails.
RX: radio USB codec -> arecord -> gain -> shared track -> MediaRelay -> Opus (per listener) -> browser
TX: browser mic -> Opus -> decode -> mono 48 kHz -> [SERVER GATE] -> gain + limiter -> aplay -> radio USB codec
Internal format: mono, s16, 48 kHz, 20 ms frames. Opus runs at 32 kbps (aiortc defaults to 96 kbps stereo); configurable with
audio.opus_bitrate. The audio runs in the same process as the rest of the server (a separate process was considered; the measured CPU cost on a Pi 4
makes it unnecessary).
http://): the installer sets that up, and your device must trust the Pi's certificate once
(security) or use the Tailscale name (tailscale).audio.max_peers, default 3). Any operator may attach a microphone, but the server passes audio only from
the connection that owns PTT.guard.keyed and guard.owner == conn_id). The browser also mutes its mic track outside PTT, but that is a
convenience, not the protection./api/audio/offer rejects a conn that belongs to someone else.On a real FT-991A with a Raspberry Pi 4:
tools/audio_spike.py on a Pi 4 (aarch64, Python 3.13): Opus encode 3-4 % of one core per stream, decode below 1 %, one
loopback listener about 21 % and three about 56 % of one core with both ends in one process (the real server-side cost is lower).
A Pi 4 has ample headroom for the default audio.max_peers = 3; a Pi 3B+ is expected to be 2-3 times slower per core: use 1-2.Automated tests (no radio) cover real WebRTC sessions in-process: RX audio and level meter, mic audio reaching the sink only while the gate is open, wrong connection, viewer role, peer limit, device loss and recovery.
Not measured yet: the latency of either transport (WebSocket audio, receive and transmit, and the automatic fallback with UDP blocked were verified on a real FT-991A with a Raspberry Pi 4), figure (budget: capture 20-80 ms + Opus 20 ms + network + the browser's jitter buffer).
WebRTC sends the audio as UDP packets. Some networks block that (hotel, office or school Wi-Fi, some mobile carriers, strict firewalls). The page then works but the audio does not connect. For that case the same Opus audio can travel over the page's own HTTPS/WebSocket connection (TCP).
AudioDecoder/AudioEncoder: current Chrome, Edge, Firefox and Safari 17 or newer) and, for the microphone, AudioWorklet and HTTPS.
Otherwise the page says so and WebRTC stays in use.audio.max_peers counts both kinds of listener.sudo apt install alsa-utils libopus0 # the installer does this
sudo usermod -aG audio radio-remote # service user needs the audio group (the installer does this)
arecord -l ; aplay -l # note the CARD=<id> of the USB Audio CODEC
Use Raspberry Pi OS Lite (no PipeWire/PulseAudio grabbing the device). Set audio.input_device /
audio.output_device to plughw:CARD=<id>,DEV=0 (a full ALSA name, never a bare number) only if auto-detection finds zero or several codecs.
The admin-only GET /api/audio/devices shows what the server sees.
arecord -l / aplay -l: confirm the codec appears when the radio is on, and what its card ID is.arecord -D plughw:CARD=<id>,DEV=0 -f S16_LE -r 48000 -c 1 -d 5 /tmp/rx.wav with a signal on the radio: play it back. If that is silent the problem is
on the radio side (menus, USB level), not in the app.audio.rx_gain_db.audio.tx_gain_db and the radio's data-out level until the ALC is just touching.Three places, in this order: 1. Browser Volume slider (Audio tab) at 100 %. 2. The radio's own USB output level menus (0-100, default 50): 107 SSB OUT LEVEL, 073 DATA OUT LEVEL, 046 AM OUT LEVEL, 075 FM OUT LEVEL, 054 CW OUT LEVEL, 099 RTTY OUT LEVEL (CAT manual p.9). This is the cleanest fix because it improves the signal-to-noise ratio instead of just amplifying. (On the tested FT-991A, menu 107 = 90 fixed a quiet receive level.) 3. Admin > Config > Audio > rx_gain_db: boost in dB (-30..30, live, no restart). +6 dB doubles the amplitude, +12 dB is four times. A boost above 0 dB runs through the same soft limiter as the transmit path, so loud signals do not clip hard.
The radio's AF gain knob is believed not to affect the USB audio (not verified separately); the app therefore does not offer an AF gain slider.
FreeDV sends speech as modem tones in an ordinary SSB channel. Radio Remote does the FreeDV work on the Pi, so you only need the browser:
Modes: RADE V1 (the neural mode most stations use today) and RADE V2 (see below). Both come from one optional library, the same one that has always held RADE. The older codec2 modes (1600, 700D, 700E) were removed from the program; a configuration file that still names one of them is read as RADE V1.
Status: the modem round trip (voice to tones to voice, noise rejected) is covered by automated tests. How well it works over the air with your radio, antenna and band has to be found out by trying it: start with receive, then transmit into a dummy load.
Switching FreeDV off passes the radio's audio through as before (it does not change the radio's mode back).
A FreeDV modem only locks when the signal is close to where it expects it, and a radio dial cannot be set that finely by hand. Measured with ideal signals: RADE V1 locks only within about ±50 Hz, so the Pi does the fine tuning in software (RADE V2 locked across the whole ±300 Hz tried, so for V2 the search is rarely needed):
FT-991A: use plain USB/LSB, like the other stations (recommended, and the default). Below 10 MHz FreeDV uses LSB, above it USB, with the same dial frequencies as the channel list and the FreeDV Reporter. Every station you meet is on its radio's ordinary SSB mode, and a DATA mode can put your signal somewhere else (see below). In USB/LSB the radio's filter is 2400 Hz, which carries RADE V1 (audio 750 to 2250 Hz) and V2 (about 1000 to 1900 Hz), and the radio's own SSB transmit filter (menu 110, for example 400-2600 Hz) trims the edges of the signal. What the radio must not do is process the audio:
| Menu / control | Value |
|---|---|
| Speech processor | OFF (also RF/AF compression, noise reduction, notch and contour on receive) |
| Parametric microphone equalizer (menus for the mic EQ) | OFF |
| 110 SSB TX BPF | at least 400-2600 Hz (100-2900 Hz is the widest of the usual choices) |
| 106 SSB MIC SELECT, 109 SSB PORT SELECT | REAR, USB (the page's microphone button sets 106) |
| 107 SSB OUT LEVEL | the receive level to the Pi: the tones clearly present, never clipping |
Set the transmit level with the Level test (see below): the radio's ALC must stay under its mark and the power should be close to what you set; a compressed RADE signal cannot be decoded.
Alternative: the radio's DATA mode (the way the FreeDV guide for the FT-991A does it). The DATA modes bypass the radio's microphone chain, but they have traps of their own: the receive filter starts at 500 Hz (see the filter width below), the transmit band is not trimmed by menu 110, and the OTHER DISP / OTHER SHIFT menus (064, 065) change the relation between the displayed frequency and the carrier, so at the same displayed frequency your signal may sit up to 1500 Hz away from where a USB/LSB station expects it (V1 only locks within about 50 Hz: nothing would decode). Use it only if you have checked all three. FreeDV's own guide sets these menus once on the radio:
| Menu | Name | Value |
|---|---|---|
| 031 | CAT RATE | 38400 |
| 059 | CW FREQ DISPLAY | DIRECT FREQ |
| 062 | DATA MODE | OTHERS |
| 064, 065 | OTHER DISP (SSB), OTHER SHIFT (SSB) | 1500 Hz |
| 066, 068 | DATA LCUT FREQ, DATA HCUT FREQ | OFF |
| 070 | DATA IN SELECT | REAR (the page's microphone button sets it) |
| 071 | DATA PTT SELECT | DAKY |
| 072 | DATA PORT SELECT | USB |
Then switch on Use the radio's DATA-USB / DATA-LSB mode in the FreeDV tab (administrators): FreeDV then puts the radio in DATA-USB (DATA-LSB below 10 MHz) instead of USB (LSB). It is off by default because it needs the menus above. The receive level that goes to the Pi is then menu 073 DATA OUT LEVEL (in USB/LSB it is 107 SSB OUT LEVEL).
The radio's filter width: RADE V1 needs about 1500 Hz of the audio, and the DATA modes start too narrow (BENCH-FOUND). RADE V1 occupies about 1500 Hz: the audio from about 750 to 2250 Hz (V2 about 900 Hz, around 1000 to 1900 Hz). An FT-991A in a DATA mode starts with a 500 Hz receive filter (width code 10 in the radio's table): a recording of the noise in DATA-L spanned exactly 1225 to 1750 Hz, and the reference RADE decoder found nothing in it, because only a third of the carriers get through. The DATA filter is centred on 1500 Hz, so 1700 Hz is the least that carries V1 and about 2000 Hz is comfortable; in USB/LSB the passband starts near 300 Hz, so about 2100 Hz is needed and the default 2400 Hz is fine. More than that only lets in more noise and neighbouring stations (it does not help RADE). The width is kept per mode (USB and DATA-L have their own), and the DATA modes have no width key on the radio's front panel, so the Filters tab shows a Width list for DATA-U and DATA-L as well, and the FreeDV tab shows a warning with a Widen button when the filter is too narrow while FreeDV is on. With Open the radio's filter automatically ticked (the default; administrators can change it), the app opens a too-narrow filter by itself a moment after FreeDV is switched on or the mode changes, to the narrowest value from 2000 Hz (DATA) or 2400 Hz (USB/LSB) up. Check it with a recording of the noise (REC on the Radio tab, then Download raw radio audio): it should span roughly 750 to 2250 Hz or more.
Always: speech processor off, no equaliser tricks (the radio must pass the modem tones unchanged).
RADE V1 sends speech as an OFDM signal that is about 2.1 kHz wide and decodes at lower signal-to-noise ratios than 700D/700E (it still locks at about 0 dB on a fading path). It is the mode most FreeDV activity uses. It comes from a separate library of about 24 MB.
A new installation already has it, you do not need to do anything: the ready-made image and the installer pack contain the RADE library, and install.sh (Raspberry Pi or
Debian PC) downloads and installs it as part of the install (--no-rade skips it). Ordinary updates do not carry it, so they stay small, and a library that is already installed is kept.
If it is missing, for example because the install had no internet: an administrator presses Install RADE in Admin > Config > RADE (or in the FreeDV tab, which shows the same
button; once RADE is installed it says Reinstall RADE). The Pi downloads the library from the project's release page, checks its SHA-256, puts it in /var/lib/radio-remote/lib and starts
using it at once, with no restart. It works on 64-bit Linux: a Raspberry Pi with the 64-bit OS, or Debian 12/13 on a 64-bit PC (see installation, route D). The library is built on
Debian 12 and runs on Debian 12 and newer. From the command line:
sudo /opt/radio-remote/current/scripts/install_rade.sh # downloads the matching release file, checks its SHA-256, installs it, restarts the service
sudo /opt/radio-remote/current/scripts/install_rade.sh --file F.tar.xz # from a file you copied to the Pi (no internet)
sudo /opt/radio-remote/current/scripts/install_rade.sh --remove
Then choose RADE in the mode list of the FreeDV tab (or in a channel). Until it is installed the tab says so and offers only the codec2 modes.
rade_c C port (BSD licence, with the Opus sources it uses); the release includes the licence files.
It runs in the same program as the rest of the server (about 3% of one fast PC core to decode, less to encode; expect roughly a quarter to a
third of one Pi 4 core while receiving: not yet measured on your Pi).At the end of every RADE V1 transmission the Pi sends your callsign inside the signal (in the end-of-over frame), the way the FreeDV program does, so other stations' programs can show who was calling.
/. Left empty, the Callsign of the Reporter settings is used
(its first 8 characters). The configuration key is [freedv] callsign.Choose RADE V2 in the mode list. It is a newer version of the same idea with a narrower signal (about 1.1 to 1.9 kHz), its own level control on receive, and it follows a mistuned signal by itself. A V1 station cannot decode a V2 station and the other way round, so use the version the other station uses; most stations are on V1 today.
RADEV2 (as other open-source clients report it).First check that you and the other station use the same RADE version. V1 and V2 are different waveforms and do not decode each other at all (the reference decoder gave 0 % lock on a V2 recording with V1 and the other way round). Many stations on the air use V2 now (about 900 Hz wide, around 1000 to 1900 Hz of the audio) while others use V1 (about 1500 Hz, 750 to 2250 Hz). Ask the station, look at the mode on the FreeDV Reporter list, and set the same one on the FreeDV tab; a station that "cannot understand anything" is very often on the other version.
Check that the transmitted signal stays in its band. The V1 modem's output is made of OFDM carriers with sharp edges; without a band-pass filter about 7 % of its power falls outside 700 to 2300 Hz (skirts down to the audio floor and up past 3 kHz), so on someone's waterfall it looks wider than it should and the radio's ALC and filters see it. RADE V2 has its filter on by default; for V1 the library has it but leaves it off. Libraries installed from 1.1.30 on have it switched on (1.6 % outside the band, a lower peak-to-average ratio): if the FreeDV tab says the installed RADE library is older, press Reinstall RADE.
RADE needs a continuous stream of tones, with no holes in it. The Pi therefore keeps a small cushion (about 0.2 s) of modem tones queued before it starts sending them to the radio, so an uneven network (Wi-Fi, a remote connection) cannot put gaps into the signal; when you release PTT the Pi first sends the last modem frame (silence completes it) and lets the queued tones reach the radio, so the end of the over is not cut off; the radio is unkeyed about half a second after you let go. After every over the service log says whether the tones went out whole:
journalctl -u radio-remote --since "10 min ago" | grep "FreeDV transmit"
no holes is good. the tones had N hole(s) means the audio reached the server unevenly or the PC is too slow: use a wired connection, close other programs, and send the line to the developers.
Admin > Diagnostics (and /api/diagnostics) also shows the slowest processing step, which should stay well under 20 ms on the receive side. A PC that regularly needs longer than that for one 20 ms frame will stutter.
qso.freedv.org is the live list of FreeDV stations: callsign, grid square, frequency, mode, whether the station is transmitting right now, and a short message. Radio Remote can join it. It is off by default.
IO91wm) to be listed, plus an optional short message. Choose whether to announce this station, to show who is on the air, or both. Apply.[reporter] (enabled, announce, watch, callsign, grid_square, message).The list on the FreeDV tab comes from the configuration (freedv.channels). The defaults are the usual FreeDV calling frequencies (160 m
1.997, 80 m 3.625 and 3.643, 40 m 7.177 and 7.197, 20 m 14.236 and 14.240, 17 m 18.118, 15 m 21.313, 12 m 24.933, 10 m 28.330 and 28.720 MHz, all in kHz
as dial frequencies). Check the current activity frequencies on the FreeDV website before relying on them.
Administrators can change the list in the tab (Edit the channel list): name, frequency in MHz, mode.
Settings (Admin > Config or the tab): freedv.mode (the mode used when FreeDV is switched on without choosing one), freedv.tx_level_db (-40..+3), freedv.channels.
RADE's transmit signal has an almost constant amplitude (the paper reports a peak-to-average ratio under 1 dB before the radio). It is meant to be sent at close to the radio's full rated power, and "more is not better" only applies beyond that: an overdriven signal is distorted and decodes badly, but an underdriven one is simply weak at the other station. A radio whose ALC barely moves (one or two bars at every setting) is underdriven.
A measured example (FT-991A in DATA-USB, set to 50 W, an external power meter, the test tone): -6 dB gave 32 W and the ALC barely moved; 0 dB gave 41 W with the ALC just below its limit; +3 dB gave 42 W with the ALC at its limit. So 0 dB was the right level for that radio: the last 3 dB bought 1 W and started the overdrive. The default for a new installation is -3 dB, a safe starting point; every radio and cable is different, so run the level test. Read the external meter if you have one: the app's PO bar saturates at the set power.
The level can go up to +3 dB. If the ALC is still not moving at +3 dB, raise the radio's own input level for the USB audio instead (on an FT-991A the sound-card playback volume on the computer, or the radio's DATA/USB input gain menus) rather than turning up the level beyond that.
Small PCs (an Intel Atom mini PC, for example): RADE V1 receive needs about 40% of one core and the decoder has single steps of 60 to 100 ms. Such a PC also changes its CPU clock all the time (down to 480 MHz when it looks idle), and RADE's short bursts of work then run slowly. Keep the clock up with the "performance" governor, permanently:
sudo tee /etc/systemd/system/radio-remote-cpu-performance.service >/dev/null <<'EOF'
[Unit]
Description=Radio Remote: CPU governor performance
After=sysinit.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c "for g in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do echo performance > $g; done"
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload && sudo systemctl enable --now radio-remote-cpu-performance.service
(undo: sudo systemctl disable --now radio-remote-cpu-performance.service). On the Atom tested the clock still swung between 1.0 and 1.9 GHz (its power limit), but the worst decoder step fell from 107 to 68 ms.
RADE V2 is lighter for the decoder on such a PC (about 30% of a core, worst step 19 ms). Admin > Diagnostics shows the receive timing: the time per 20 ms frame in total and the time inside the decoder; the total must stay under 20 ms on average.
python3 scripts/freedv_probe.py (on the Pi: /opt/radio-remote/venv/bin/python /opt/radio-remote/current/scripts/freedv_probe.py) runs every installed mode as a
transmit-to-receive loopback and prints the share of one CPU core each needs and its slowest single step. On a PC RADE needs about 1% to transmit and 5% to receive; on a small Intel Atom PC receive was measured at about 40% of one core.
| Symptom | Likely cause |
|---|---|
| The FreeDV tab offers no mode | the RADE library is not installed (Admin > Config > RADE > Install RADE) |
| Status stays at "no signal locked yet" | wrong frequency or sideband, signal too weak, RX level too low or clipping, or it is another mode (RADE V1 and V2 signals do not decode as each other) |
| "Tap the speaker icon" | the Pi only decodes while someone is listening |
| Locks, but the speech is garbled | an overloaded receive level (lower menu 107) or a very weak signal |
| Others cannot decode you | overdriven or too-weak transmit level, speech processor on, or the radio not in USB/LSB |
The SSTV screen (the Digital tab, then SSTV in its switch; on the all-in-one desktop layout the SSTV button in the bar under the title) receives pictures from the radio's audio and transmits a picture of your own. The Pi does the decoding and the encoding; the page only shows the picture as it arrives and prepares the one to send.
SSTV sends a still picture as a sound: the pitch of the tone is the brightness of the picture (1500 Hz black, 2300 Hz white, 1200 Hz the sync at the start of each line). A transmission starts with a short calibration header that names the mode, so the receiver knows by itself what is coming. The software is written from the published mode descriptions; nothing from other SSTV programs is used.
Robot 36 and Robot 72, Martin M1 and M2, Scottie S1, S2 and DX, PD50, PD90, PD120, PD160, PD180 and PD240, for receiving and for sending. Checked against two independent SSTV programs in both directions (their pictures decode here, and ours decode there). The time a mode takes is shown in the mode list; the usual ones are Robot 36 (37 s), Martin M1 (115 s), Scottie S1 (111 s) and PD120 (127 s).
The receiver follows the transmitter's timing line by line, so a sound card that runs a little fast or slow does no harm, and a picture right behind another one is picked up too. Signals down to about
12 dB signal-to-noise ratio (in 3 kHz) give a picture (noisy at that level); weaker ones are not recognised. The radio's filter must pass 1100 to 2300 Hz: if it is narrower, the Pi opens it (the same automatic widening as FreeDV,
freedv.auto_width).
Under the frequency display there is a Frequency box (type 437.550 for MHz, 437550 for kHz or 437550000 for Hz, then Set or Enter), - and + buttons with a step (100 Hz, 500 Hz, 1, 2.5, 5 or 10 kHz) and a Mode list
(USB, LSB, FM, FM-N, AM, AM-N and the data modes the radio has). The buttons use the same tuner as the Radio tab: taps in a row add up while the radio catches up, and the arrow keys of the keyboard tune by the same step.
A Squelch slider appears in the FM modes. SSTV is received in any of these modes: the presets use a data mode on the HF bands (it also skips the microphone processing when you send) and plain FM for the ISS,
but nothing forces that: if you change the mode on the radio, SSTV keeps decoding.
The ISS signal is Doppler shifted by up to about 10 kHz during a pass: it arrives high at the start (about 437.560), at 437.550 at the closest point and low at the end (about 437.540). The program does not calculate the shift: you follow it: choose the 70cm ISS preset (FM), set the step to 2.5 or 5 kHz and tap - as the pass goes on, watching the picture. Before the pass: use the wide FM mode, open the squelch (slider at open), and check that the radio sends FM audio over USB (menu 077 FM PORT SELECT = USB, see Radio connection). The Robot 36 / PD120 pictures of such events are received only: do not transmit.
Sending goes through the same protection as every transmission: the transmit permission in the administration page, the control of the radio (only the page that has control can send), the heartbeat (the Pi
stops at once if the page disconnects) and the transmit time limit. A mode that takes longer than the time limit is refused before anything is keyed, and the page says so (an administrator can raise
safety.tx_timeout_s in Admin > Config: the default of 120 s allows Robot, Martin and Scottie S1/S2 and PD50/PD90, but not PD120 and the longer ones). The radio's ALC must stay at zero: the tones are sent at the TX level under SSTV settings (default -6 dB), and the
radio's speech processor must be off. SSTV and FreeDV cannot share the radio: opening the SSTV screen switches FreeDV off.
This device's sound is off while the screen is open (the decoder works on the Pi's own audio and the tones are not nice to listen to); SSTV settings > Sound here can make it Low instead. It comes back when you leave the screen if it was on.
data/sstv on the Pi holds the pictures (the last 60 by default, sstv.keep).The CW switch on the Digital tab decodes Morse from the radio's audio into text. It also sends what you type, through the radio's own keyer.
Leaving the screen switches decoding off and puts back the mode and frequency the radio had.
Under the received text is the Send box (it appears when the radio can send CW this way, as the FT-991A can). Type text and press Send (or Enter). The buttons CQ, My call, 599 and 73 send the usual short messages with your callsign (set in the FT settings of the FT8/FT4 screen). Halt TX stops at once and drops what is still waiting.
Before sending:
safety.allow_ptt), as for any transmission. The usual transmit time limit applies to every piece.How it works, and its limits: the FT-991A's CAT cannot send free text to its keyer, it can only play its stored keyer memories. So your text is cut into pieces (at most 24 characters, at word boundaries), each piece is written into keyer memory 5 and played, and the next piece follows when the estimated sending time is over. The text that was in that memory is read first and put back at the end. The keyer speed (the box on the screen, in WPM) is the radio's own keyer speed. This is not live typing: text is sent when you press Send. Only letters, digits and . , ? / = + - ' can be sent; other characters are dropped (the screen tells you which). The sending time is worked out from the speed, so if you turn the speed knob during a long text the pieces may overlap or have gaps: stop and send again.
An administrator can change which memory is used (cw.keyer_slot, 1 to 5) and the piece length (cw.chunk_chars, 10 to 50) in Admin > Config. This sending method has not yet been checked on a real radio: try it first into a dummy load. The same commands are used for the FTDX10, FTDX101D/MP and FT-710 profiles (their keyer commands are the same in the manuals that were read); none of them has been tried on a real radio yet.
Under the text is Log a contact: callsign, the reports (599 by default), name and a comment; double-click a callsign in the received text to fill the Call box. The date, UTC time, band and frequency come from the radio. The contact goes into the same log as FT8/FT4 and RTTY, which can be exported as one ADIF file (mode CW) and uploaded to QRZ and eQSL with the buttons under Log.
The decoder follows any speed from about 5 to 50 WPM by itself, copes with some noise and with sending that is not perfectly even, and prints nothing when there is only noise. Like every automatic decoder it makes mistakes on weak, fading or badly timed signals (a ? marks a letter it could not read), and it cannot read two stations at once. Your ears are still better, so use Sound here when it matters.
Written from the Morse timing rules; no code from any other program is used.
The JS8 switch on the Digital tab is keyboard-to-keyboard messaging with JS8 (JS8Call's weak-signal mode, an FT8-style signal that carries free text): call CQ, send a heartbeat, talk to stations you hear, ask for signal reports, all from the browser. It receives and transmits.
JS8's message coding (a word dictionary and error-correcting tables) cannot be written into this MIT-licensed program, so the JS8 modem is JS8Call-improved, a separate free program (GPL-3.0, from https://github.com/JS8Call-improved/JS8Call-improved). It is not part of Radio Remote: an administrator downloads it from its own release page with a button. It then runs on the Pi without a screen, in its own service, and Radio Remote only talks to it through its documented network interface (on the Pi itself, localhost) and connects its audio to the radio:
Supported: a Raspberry Pi 4 or newer, or a 64-bit Linux PC. The Pi uses about a quarter of one core while JS8 is on.
JS8Call only transmits what you ask for: when JS8Call reports that it wants to key the radio, Radio Remote keys it only if you pressed Send a moment ago from a page that is still connected. Anything else (for example JS8Call answering by itself) is stopped at once and reported. As for every transmission you need control of the radio, transmitting must be allowed (safety.allow_ptt), and the transmit time limit, the heartbeat and the release when your page goes away all apply. The radio must be in USB, LSB or a data mode. FreeDV, SSTV, CW and RTTY cannot be used at the same time.
TX level, dB (JS8 settings, administrators) sets the level of JS8Call's audio sent to the radio; its output is at full scale, so the default is -14 dB. Keep the radio's ALC at zero.
The RTTY switch on the Digital tab decodes radio teletype into text and sends what you type. RTTY is two audio tones (mark and space) a fixed shift apart, sending 5-bit Baudot characters at a fixed baud rate. The usual amateur setting is 45.45 baud and 170 Hz shift, which is what the screen starts with. It is sent and heard as audio in a USB (or data) mode, not with the radio's own RTTY modes.
Settings on the screen: Baud (45.45, 50, 75, 100) and Shift (170, 200, 425, 850 Hz) must match the other station. Reverse swaps mark and space (needed in lower sideband, or when the other station sends the other way round). USOS (unshift on space) is what most stations use: after a space the letters shift is assumed.
Type in the Send box and press Send (or Enter). The buttons CQ, My call, RST and 73 send the usual short texts with your callsign (set in the FT settings of the FT8/FT4 screen). Halt TX stops at once and drops what is waiting.
Under the text is Log a contact: callsign, the reports (599 by default), name and a comment. The date, UTC time, band and frequency come from the radio. The contact goes into the same log as FT8/FT4 and CW, which can be exported as one ADIF file and uploaded to QRZ and eQSL with the buttons under Log (set the accounts up on the FT8/FT4 screen). The same contact logged twice within a few minutes is kept once.
It reads clean and moderately noisy signals at the usual speeds, follows a transmitter that is a little fast or slow, and prints nothing when there is only noise. Like any automatic decoder it makes mistakes on weak or fading signals, and it cannot read two stations at once. The first characters after tuning to a signal can be lost while the tones are being found.
Written from the published mode (ITA2 Baudot, 5 data bits, 1.5 stop bits); no code from any other program is used.
The FT8/FT4 screen (the Digital tab, then FT8/FT4 in its switch; on the all-in-one desktop layout the FT8/FT4 button in the bar under the title) lets you listen to, call and work FT8 and FT4 stations from the browser. The Pi decodes the radio's audio in the UTC time slots (15 s for FT8, 7.5 s for FT4) and makes the transmit tones; the page shows the stations, follows the standard QSO sequence, keeps a log and can upload it.
The codec is ft8_lib (MIT licence). FT2 is not supported.
Every FT8/FT4 slot starts on the UTC second, so the clock is the most important setting of all. The top bar of the tab shows the UTC time of the Pi and two indicators (hover over them for the explanation):
sudo timedatectl set-ntp true, or install chrony.journalctl -u radio-remote | grep "median DT").python scripts/build_cty.py cty.dat when a new country file is out (country-files.com).FT8 Sent: +06 Rcvd: +01). Type your own in the Comment box (next to Erase) before the contact is logged and it replaces the automatic one in the log, the ADIF file and the QRZ / eQSL uploads; the box empties after each logged contact.Each slot is decoded twice: an early pass about 1.5 s before the slot ends, so the CQ calls appear while you can still answer them in the next slot, and a final pass that adds the late and weak ones (shown as "more decoded"). Double-click a CQ and message 1 goes out in the next slot of the other parity, or still in the current one if you were quick enough (up to about 1.3 s after it started: the signal is then that much late, which receivers accept). Later than that it waits for the next slot of that parity, which for FT8 can be 30 s away; the line under the controls says how many seconds are left.
CQ <callsign> <grid> in the slots you choose (TX even or odd) and repeats it. When a station answers, the page follows the standard sequence by itself:| You call CQ | You answer a CQ | ||
|---|---|---|---|
| they | <you> <them> <grid> |
you | <them> <you> <grid> |
| you | <them> <you> -12 (their SNR) |
they | <you> <them> -07 |
| they | <you> <them> R-08 |
you | <them> <you> R-09 |
| you | <them> <you> RR73 |
they | <you> <them> RR73 |
| they | <you> <them> 73 |
you | <them> <you> 73 |
The Pi repeats the current message in every slot of the chosen parity until the next one replaces it, the contact ends, you press Halt, or it has been sent Repeats times (default 6) without progress. Log QSO logs the current DX station by hand and Erase forgets it.
Transmitting goes through the same protection as the PTT button: the transmit permission in the administration page, the control of the radio (only the operator who has control can transmit), the owner heartbeat (the Pi stops transmitting at once if that page disconnects) and the transmit time limit. The tones are sent at the TX level of the settings (default -6 dB); the radio's ALC must stay at zero.
Every logged contact is kept on the Pi (ftx_log.jsonl in the data folder). Export ADIF downloads all of it as a standard .adi file that any logging program imports (FT8 is its own ADIF mode; FT4 is the submode FT4 of MFSK). Under Log each contact shows what has been uploaded; Upload to QRZ / eQSL sends everything not yet sent to that service, one contact at a time. Tick Upload ... as soon as a contact is logged to do it automatically. There is no LoTW upload (it needs your signing certificate: export the ADIF and sign it with TQSL).
The accounts are entered under FT settings by an administrator and stay in the Pi's configuration file; they are never sent back to the page, and the settings backup never contains them.
| Service | What it needs |
|---|---|
| QRZ.com | the logbook's API key (QRZ logbook settings); the logbook owner needs an XML (or higher) subscription |
| eQSL | your eQSL user name and password |
These uploads were written from the services' published interfaces and tested only against stand-ins, not against the live services: the first real upload may need a small correction, so look at the message the page shows.
Radio Remote can let a logbook program on another computer follow the radio: the logbook shows the frequency and mode, and fills them into a new contact automatically. It can also change the frequency and mode when you click a spot or a bandmap entry.
It speaks Hamlib's "NET rigctl" protocol on TCP port 4532, which Log4OM, N1MM+, CQRLOG, HamRS, Ham Radio Deluxe and many others understand. The program on the other computer does not need a COM port or a USB cable: it connects to the Pi over the network.
| Read frequency, mode, split, whether the radio is transmitting | yes |
| Set frequency and mode | yes (can be switched to read-only) |
| Transmit (PTT) | never: a PTT-on request is refused and logged, even when transmitting is enabled in Admin > Config |
| Switch the radio off | never |
| Change frequency or mode while the radio transmits | refused |
This is a deliberate limit: the link has no password (the protocol has none), so it can only do harmless things. WSJT-X and other programs that need to transmit through the radio are not supported by this link.
Settings in the card:
private (the default) means this Pi and the usual home-network ranges (192.168.x.x, 10.x.x.x, 172.16-31.x.x).
Computers anywhere else are dropped without an answer. To allow one computer or a Tailscale address use a list, for example
192.168.1.40, 100.64.0.0/10.Never forward port 4532 on your router. Anyone who could reach it could retune the radio. Keep it for the home network (or Tailscale).
ft991a.local:4532 or 192.168.1.50:4532.
(Use the name shown in your browser's address bar. If .local names do not work on that computer, use the Pi's IP address.)The wording of the Log4OM menus changes between versions. What matters: Hamlib, model NET rigctl, address host:4532. Other logbooks use the
same three settings, sometimes under the name "rigctld" or "Hamlib NET rigctl".
With Hamlib installed on any computer:
rigctl -m 2 -r ft991a.local:4532 f # prints the frequency
rigctl -m 2 -r ft991a.local:4532 m # mode and passband
rigctl -m 2 -r ft991a.local:4532 F 7074000
| Symptom | Likely cause |
|---|---|
| The card says "Switched off" | tick the box and press Apply |
| The logbook cannot connect | wrong name/address, a firewall on the Pi, or the logbook's computer is outside the allowed addresses (the card counts "refused" connections, and the log says rigctl: refused) |
| It connects but the frequency never changes | the radio is off or disconnected (the link answers "I/O error" then); check the radio tab shows a green dot |
| Setting a frequency does nothing | "Let the logbook change frequency and mode" is off, the radio is transmitting, or the frequency is outside the radio's range |
| Mode shows wrongly | Hamlib has fewer mode names than the radio: DATA-U shows as PKTUSB, DATA-L as PKTLSB, narrow FM/AM as FM/AM |
Every frequency or mode change made through the link is written to the audit log (Admin > Audit) with the computer's address.
Tailscale gives your devices a private network to the Pi without opening any port to the Internet. It is the recommended way to operate away from home. The app's own logins stay on: Tailscale is the outer wall, not a replacement.
Never port-forward the Pi. Never bind the app to 0.0.0.0.
--with-tailscale; or follow tailscale.com/download), then sign in once:
sudo tailscale up (it prints a link to open in a browser). The Pi now appears in your Tailscale account.bash
sudo /opt/radio-remote/current/scripts/tailscale_setup.sh
It finds the Pi's Tailscale name (<pi>.<tailnet>.ts.net), lets the HTTPS front end (Caddy) fetch a real certificate for it
from Tailscale, adds the name to Caddy, checks the new configuration before using it, and tests the address. The first request
can take up to a minute while the certificate is issued. --dry-run shows what it would do; --remove goes back to the LAN
name only. The name is remembered in /etc/radio-remote/caddy-extra-hosts, so updates and reboots keep it.
5. Open https://<pi>.<tailnet>.ts.net from any device that has Tailscale switched on.
Why https://<pi>.local:8443, the 100.x.y.z address or a port number did not work: the HTTPS front end answers only to the
names it is configured for (by default <hostname>.local) on the standard HTTPS port 443. Under any other name it has no site
and gives no answer. Step 4 adds the Tailscale name. The 100.x.y.z address cannot get a certificate: use the .ts.net name.
tailscale serve is not used here: the script above (Caddy) is the tested way, and Caddy forwards X-Forwarded-Proto: https, which the
session cookie's Secure flag depends on.
If the script stops with a message: "not signed in" means run sudo tailscale up; "MagicDNS" or "HTTPS Certificates" means
switch them on in the admin console and run the script again; a timeout at the end means the certificate could not be issued yet:
wait a minute and run it again, and look at sudo journalctl -u caddy -n 30.
WebRTC audio uses UDP. On a direct tailnet path it works like the LAN. On a relayed (DERP) path expect extra delay or
failure. Check with tailscale ping <pi> (it says "direct" or "via DERP"). If WebRTC cannot connect, the page switches to WebSocket audio by itself (Audio tab > Connection; see audio).
viewer accounts.The system can transmit RF on your licence, so security has two jobs: keep strangers out, and make sure a bug, a flaky network or a second user can never leave the transmitter keyed or in the wrong hands.
| Role | Watch state, listen | Take control, change radio, PTT, mic audio | Users, config, diagnostics, power off |
|---|---|---|---|
| viewer | yes | no | no |
| operator | yes | yes (when holding control) | no |
| admin | yes | yes | yes |
safety.control_request_timeout_s
(default 10 s, 3-120): control passes automatically, so an unattended session never blocks anyone.| Concern | Measure | Where |
|---|---|---|
| Passwords | scrypt (N=2^14), min 10 chars, constant-time verify, dummy hash for unknown users | auth.py |
| Brute force | per address+user back-off (4 failures, doubling up to 5 min); address taken from X-Forwarded-For only when the peer is the local proxy |
auth.py, common.py |
| Sessions | random 256-bit id, only a hash is stored, HttpOnly; SameSite=Strict, Secure behind HTTPS, 12 h idle / 7 d absolute, revoked on password change, role change, deletion |
auth.py, admin.py |
| CSRF | SameSite=Strict plus a per-session token header on every state-changing request |
app.py |
| WebSocket | session cookie, Origin must match Host (or allowed_origins), session re-checked on every message, 4 KB message cap, max 24 clients |
app.py |
| Command flood | token bucket 40/s (burst 80) per connection; heartbeats exempt; persistent abuse closes the socket | ratelimit.py |
| Input | every command is a typed intent validated against the radio's capabilities; no raw CAT, no shell, no paths from the browser | app.py, radio/controls.py |
| PTT | allow flag (off by default; an administrator turns it on with a password re-check, audited); single owner; heartbeat; hard time limit; un-key on disconnect / lease change / shutdown; retried until acknowledged | safety.py, lease.py, admin.py |
| Web-editable config | an allow-list. server.* and storage.* can only be changed by editing the file on the Pi; safety.allow_ptt only through its own endpoint (administrator, password re-entered, throttled, audited) |
admin.py |
| Headers | CSP (no inline script/style), X-Frame-Options: DENY, nosniff, Permissions-Policy (mic for self only), HSTS over HTTPS, no-store on API |
app.py |
| Logs | passwords, cookies, CSRF tokens and rr_session values are scrubbed before any handler; the audit table records logins, failures, control changes, PTT, user/config changes, power-off, restarts |
logs.py, auth.py |
| Last-resort recovery | python -m radio_remote.cli reset-password USER on the Pi (needs filesystem access, prompts for the password) |
cli.py |
Not done, deliberately or for now:
SameSite=Strict plus the Origin check cover cross-site hijacking.Internet X (never port-forward this)
LAN / Tailscale ---> Caddy :443 (TLS) ---> app 127.0.0.1:8080 ---> radio (USB)
server.host = "127.0.0.1". The app trusts X-Forwarded-* headers only from a loopback peer; binding it to
0.0.0.0 behind a proxy would let anyone on the LAN spoof their address and "Secure" status.sudo apt install caddy, copy config/Caddyfile.example to /etc/caddy/Caddyfile (Option A), sudo systemctl reload caddy./var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt on the Pi (the path can differ between Caddy installs; the
installer pack and the image also publish it for download). iOS additionally needs it enabled under Settings > General > About >
Certificate Trust Settings.https://raspberrypi.local.sudo tailscale up. Enable MagicDNS and HTTPS certificates in the admin console.sudo /opt/radio-remote/current/scripts/tailscale_setup.sh: it adds the Tailscale name to Caddy (verified on a real Pi). Step by step in
tailscale. tailscale serve is not used: Caddy forwards X-Forwarded-Proto: https, which the session cookie's Secure flag needs.sudo ufw default deny incoming
sudo ufw allow in on tailscale0
sudo ufw allow from 192.168.0.0/16 to any port 443 proto tcp # your LAN range
sudo ufw allow from 192.168.0.0/16 to any port 22 proto tcp
sudo ufw allow from 192.168.0.0/16 to any port 4532 proto tcp # only if you use the logbook link (docs/logbook.md)
sudo ufw enable
WebRTC audio selects ports dynamically; with deny incoming allow UDP from your LAN/tailnet interfaces as well
(not verified against a real firewall yet).
safety.allow_ptt = true in
the config file and restart. It is off by default and every change is in the audit log.python -m radio_remote.cli reset-password <user> on the Pi.data/radio-remote.db (users, sessions, audit).Automated (222 tests, mock radio and simulated CAT): lease transfer / deny / timeout / force / refusal while keyed, heartbeat and disconnect un-keying, role enforcement, rate limiting, user management and last-admin protection, session revocation, config allow-list and validation, audit contents, secret scrubbing, HSTS / cookie flags, recovery CLI. In a real browser: admin sheet (users, clients, config, diagnostics, audit, account), request dialog and hand-over between two users, watching-mode lock-out of dial and PTT.
Not tested: TLS in front of the app (Caddy / Tailscale), real phones/tablets, behaviour behind a real firewall, several simultaneous audio listeners on a Pi.
First step for almost everything: sudo /opt/radio-remote/current/scripts/doctor.sh (or Admin > Diagnostics), then
journalctl -u radio-remote -n 100 --no-pager. Paste the doctor output when asking for help; it contains no passwords.
lsusb: is there a Silicon Labs CP210x entry? No: cable, radio power, try another Pi USB port, avoid unpowered hubs.ls /dev/serial/by-id/: do ports exist? No: kernel did not create them (check dmesg | tail).radio.model must be ft991a, not mock (Admin > Config).sudo fuser -v /dev/ttyUSB*, check ModemManager: systemctl status ModemManager; the installer's udev rule tells it to ignore the radio, re-plug the cable after installing).radio.baud to the radio's menu 031 value.journalctl -u radio-remote | grep -i cat shows reasons ("radio stopped answering", "unexpected radio ID").The FT-991A exposes two serial ports; the app picks the one that answers ID; with 0670. Force one with
radio.serial_port = "/dev/serial/by-id/usb-Silicon_Labs_...-if00-port0" (stable across reboots, unlike ttyUSB0/1).
"unexpected radio ID" means a different radio model answered on that port.
arecord -l / aplay -l must list the radio's codec. If not: cable/power as above; the audio device appears only while
the radio is on. Several codecs present: set audio.input_device and audio.output_device explicitly (the UI says
"several USB audio CODECs found"). Make sure the service user is in group audio (id radio-remote) and that you use
Raspberry Pi OS Lite (a desktop session's PipeWire can hold the device; check with fuser -v /dev/snd/*).
rx_capture, capture_error.arecord -D plughw:CARD=CODEC,DEV=0 -f S16_LE -r 48000 -c 1 -d 5 /tmp/rx.wav && aplay /tmp/rx.wav with a signal on the radio.http:// pages can listen; if audio cannot connect at all, try Audio tab > Connection > WebSocket (TCP); otherwise check browser console for WebRTC errors and that UDP is not blocked between you and the Pi (VPN relays can add delay).allow_ptt is true.audio.tx_gain_db and the radio's out-level until ALC barely moves.allow_ptt = true in /etc/radio-remote/config.toml and restarts.safety.tx_timeout_s.Unplug/replug and radio power-cycle should recover within a few seconds. If the status stays offline: look for
"CAT session ended" in the log; confirm the port name did not change (use by-id); sudo systemctl restart radio-remote.
After a radio power cycle, the radio forgets auto-information; the app re-enables it on reconnect (log: "FT-991A connected").
systemctl status radio-remote, curl -s http://127.0.0.1:8080/api/status on the Pi.systemctl status caddy, journalctl -u caddy -n 30. Does the name resolve (ping radio.local; some Windows/Android setups lack mDNS: use the Pi's IP and --hostname)?server.allowed_origins.sudo /opt/radio-remote/current/scripts/tailscale_setup.sh (tailscale.md).tailscale status on both ends; tailscale ping <pi> shows direct vs relayed (DERP) path.Secure / mic is blocked, the proxy is not forwarding X-Forwarded-Proto: https: use the Caddy variant (tailscale.md).journalctl -u radio-remote -n 100. Common: a typo in config.toml (the error names the key); missing Python packages
after a failed update (sudo /opt/radio-remote/venv/bin/pip install -r /opt/radio-remote/current/requirements.txt);
status=...WATCHDOG means the app hung (please send the log).
sudo scripts/uninstall.sh (keeps data) then sudo ./install.sh; or --purge for a completely clean slate.
The server could not open the radio's sound card for transmit: audio.output_device holds something that is not an ALSA device name
(for example a bare card number 6). Leave Admin > Config > Pi -> radio (playback) empty (auto-detect) or set
plughw:CARD=CODEC,DEV=0 (see aplay -l), save, restart the service. A bare number is now rejected when saving, and the Audio tab shows
"Audio to the radio failed: ..." when the playback device cannot be opened.
These four profiles were written from the Yaesu CAT manuals (FTDX10 2308-F, FTDX101MP/D 2101-I, FT-710 2306-C). The app shows an amber
EXPERIMENTAL PROFILE banner whenever one is selected whose capability file says bench_tested = false (the FTDX10 and FT-710; the FTDX101D was tested on a real radio, and the MP, which has the same commands, shows no banner either).
Connection. The radio has a dual CP210x USB bridge: the Enhanced port (usually /dev/ttyUSB0) is CAT. Set, on the radio, CAT RATE 38400, CAT TIME OUT TIMER to a
long value and CAT RTS = OFF. With CAT RTS on, the radio only answers while the computer holds RTS high, and this program keeps RTS low on purpose (RTS can key a transmitter,
and the Standard port may be set to use it for the CW keyer or PTT), so it would stay silent: the app then says "radio offline". The ID is 0681; the radio's CAT port answers at the rate set in the menu only.
MAIN and SUB receivers. FA is the MAIN receiver and FB / OI the SUB receiver (the radio's VFO-A and VFO-B). The app shows them as MAIN and SUB. Instead of the
VFO copy / swap buttons it has:
VS0; / VS1;: the receiver the radio's own dial and keys operate; follows the radio's MAIN / SUB keys),FR P1 P2, 0 = listening, 1 = muted; at least one stays on) and TX buttons (FT2; MAIN / FT3; SUB; the answer to FT; is 0 / 1). Listening to one receiver and transmitting on the other is split,The rear / USB audio carries one receiver at a time, chosen per mode family by the menus SSB / AM / FM / DATA / RTTY / CW OUT SELECT (EX010108, EX010208, EX010308, EX010410,
EX010510, EX020108; 0 = MAIN, 1 = SUB). When only one receiver is listening the app sets all six to that receiver; when both listen it leaves them alone. Changing receivers on the radio's
own panel does not change them.
Controls (Filters & DSP panel; a MAIN | SUB switch at the top makes the per-receiver ones act on the chosen receiver, set_control carries receiver):
| Tab | Controls |
|---|---|
| Filter | Width, Narrow, IF Shift, Contour (+ frequency) |
| Noise | Manual notch (+ frequency), Auto notch, DNR (+ level), Noise blanker (the NB level, NL, is one setting for both receivers) |
| Receiver | IPO / Preamp, ATT, AGC, RIT, XIT, clarifier (RIT / XIT / clarifier belong to the whole radio) |
| Transmit | Monitor (+ level), Antenna tuner on / off, Mic input, Processor (+ level), AMC output level |
| Audio | Audio out level (SSB / AM / FM / DATA / RTTY / CW OUT LEVEL, written together) and Audio in level (RPORT GAIN of SSB / AM / FM) |
| CW | Keyer speed (KS, 4-60 WPM), CW pitch (KP, 300-1050 Hz in 10 Hz steps), Keyer, Break-in (KR, BI) |
Levels: RF gain and AF gain exist per receiver (RG0 / RG1, AG0 / AG1). AF gain is only the radio's own speaker volume (the app does not use it for remote audio); all 0-255 gains are shown as 0-100 %.
TUNE works (AC002;, keys the transmitter: dummy load or antenna, the app asks first). Memories are listed, recalled (MC) and left with the radio's V/M key (VM;); nothing is ever written.
Where the manual is wrong or unclear on this radio
PR): the manual says 1 = OFF / 2 = ON. The radio uses 0 = OFF, 1 = ON (PR0 the processor, PR1 the parametric mic equalizer) and rejects PR02; with ?;. The processor level is PL.AO, 1-100), shown on the radio as "AMC out", unless the menu OPERATION SETTING → TX AUDIO → PROC LEVEL is set to COMP; then it sets the processor level (PL).
The processor on / off is the MIC / SPEED knob (press).SD (break-in delay) is answered with two digits (SD04), not the four digits the manual shows, so it is not offered.GT answers AUTO as 4, 5 or 6 (AUTO-FAST, -MID, -SLOW); the app shows AUTO.Not tried yet: transmitting on the SUB receiver on the air, the DATA / RTTY / CW audio-source menus with a signal, and the MP.
radio.model (Admin > Config > Radio > Model, restart needed) picks, for that model:
radio/capabilities/<model>.toml): which functions the UI offers (only true entries; "unverified" is hidden);radio/cat/proto.py): the exact commands, answer layouts, mode codes, width tables, level ranges and the
menu (EX) items for that radio. The FT-991A keeps its own, bench-verified protocol object; the four newer radios share one
(HfProto) whose per-model differences are plain data (HfModel).The driver (YaesuCatDriver) never builds a command itself, so an FT-991A command can never reach another radio, and the ID answer
(ID0761; FTDX10, ID0681; FTDX101D, ID0682; FTDX101MP, ID0800; FT-710) is checked on every connect: a different radio is refused.
Same: FA FB (9 digits, 30 kHz-75 MHz), IF/OI layout (25 characters), TX0/1, AG0 RG0 (0-255), MG (0-100), PC (5-100 W, 5-200 W on
the FTDX101MP), SM0, RM selectors (3 COMP, 4 ALC, 5 PO, 6 SWR), NA NB NL NR RL BC BP CO PA ML AC formats, BS band codes 00-10,
AB BA SV, PS (dummy data, then PS1 after 1-2 s), RT XT RU RD RC (not on the FT-710).
Different (and handled by the protocol object):
| Topic | FT-991A | Newer radios |
|---|---|---|
| ID | 0670 | 0761 / 0681 / 0682 / 0800 |
| Mode codes | up to E = C4FM |
up to F; E = PSK, F = DATA-FM-N, no C4FM |
RM answer |
RM4xxx; |
RM4xxx000; (7 characters) |
| Split | FT3;/FT2; |
ST1;/ST0; on the FTDX10 / FT-710. On the FTDX101 FT2; / FT3; choose the MAIN or SUB receiver as the transmitter (the TX buttons) |
| IF shift | IS0+0000 |
IS00+0000, 0-1200 Hz in 20 Hz steps |
| Attenuator | on/off | RA0 0-3 = OFF / 6 / 12 / 18 dB (control att_level) |
| Width | SH0nn with narrow/wide tables |
SH00nn, Table 3 per mode class (differs per radio) |
| AGC | (documented, now offered) | GT0 + 0..4 (OFF/FAST/MID/SLOW/AUTO); the answer reports AUTO as 4/5/6 |
| Mic source menu | EX106 (SSB MIC SELECT, 0 MIC / 1 REAR) |
FTDX10 EX010113, FTDX101 EX010111 (SSB MOD SOURCE), FT-710 EX010114 (MOD SOURCE, 1 = USB) |
| Tune | AC002 start/stop toggle |
FTDX10/101 same; FT-710: AC003 start, AC000 stop |
"unverified" in the capability file)This list is for the radios that have not been tried (FTDX10, FT-710); the FTDX101D (and the MP, which uses the same commands) have these enabled as described above.
PR/PL): the manual says PR 1 = OFF / 2 = ON, which is wrong on the FT-991A and on the FTDX101D (it is 0/1). Verify on the radio first.tuner_tune): it keys the transmitter. Enable it only after a dummy-load test, by setting tuner_tune = true in the model's toml.ST) on the FTDX101: the TX buttons (FT) cover the MAIN / SUB case; the radio's own VFO-A / VFO-B split inside one receiver is not offered.CF command is in its manual) and quick split (no QS).RF), the spectrum scope, CW message keying (KY, it transmits), VOX, the break-in delay: not implemented.ID; and keeps the one that answers; if it picks wrongly, set radio.serial_port explicitly. The
"Power on radio" button also needs the CAT port: set radio.serial_port explicitly for these radios.radio.baud to the same value (38400 is the app's default). If the radio does not answer at all, check its CAT RTS menu (it must be OFF, see the FTDX101D section);
the log then says which ports and rates were tried.EX010111 (SSB OUT LEVEL); FTDX101 EX010109; FT-710 EX010111
(USB OUT LEVEL); on the FTDX101D the app has an Audio out level slider for it. Raise it on the radio instead of boosting audio.rx_gain_db. Transmit audio over USB also needs the radio's
REAR SELECT = USB menu (FTDX10 EX010114, FTDX101 EX010112) and, on the FT-710, MOD SOURCE = USB.python scripts/bench_hf_readonly.py ftdx10 # or ftdx101d / ftdx101mp / ft710
Every line shows the command, the radio's answer and what the decoder made of it. A ?; or a "(decoder: ...)" note is exactly what to fix.radio.model set, check the banner, the frequency, mode, band and the S-meter.Every function confirmed this way should be written into docs/bench-results.md and the capability toml flipped to bench_tested = true.
The FT-991A and the FTDX101D are tested on real radios (the FTDX101MP has the same commands as the D). FTDX10 and FT-710 are experimental: their profiles were written from the radios' CAT manuals and tested only against a simulator, and the page shows an "experimental" banner. See other radios for what to check first if you have one.
How to read the table:
| Mark | Meaning |
|---|---|
| works | tested on the real FT-991A |
| from the manual | implemented from the CAT manual, not yet tried on a real radio of that model |
| from the manual, untested | on the FT-991A too, but not yet tried there |
| not offered yet | exists in the manual but is switched off until it has been tried on the radio (it can transmit, or its table was wrong on another model) |
| - | the radio does not have it, or it is not available over CAT |
| Function | FT-991A | FTDX10 | FTDX101D | FTDX101MP | FT-710 |
|---|---|---|---|---|---|
| Transmit | |||||
| Antenna tuner on/off | works | from the manual | works | works | from the manual |
| Microphone input (menu: MIC / REAR) | works | from the manual | works | works | from the manual |
| Monitor | works | from the manual | works | works | from the manual |
| PTT (hold to transmit) | works | from the manual | works | works | from the manual |
| Speech processor | works | not offered yet | works | works | not offered yet |
| TUNE (start the tuner: transmits a carrier) | works | not offered yet | works | works | not offered yet |
| Meters | |||||
| ALC meter | works | from the manual | works | works | from the manual |
| Compression meter | works | from the manual | works | works | from the manual |
| SWR meter | works | from the manual | works | works | from the manual |
| Receiver | |||||
| AGC (OFF / FAST / MID / SLOW / AUTO) | works | from the manual | works | works | from the manual |
| Attenuator (OFF / 6 / 12 / 18 dB) | - | from the manual | works | works | from the manual |
| Attenuator (on/off) | works | - | - | - | - |
| Audio peak filter (CW) | not offered yet | not offered yet | not offered yet | not offered yet | not offered yet |
| Auto notch | works | from the manual | works | works | from the manual |
| Contour | works | from the manual | works | works | from the manual |
| Filter width | works | from the manual | works | works | from the manual |
| IF shift | works | from the manual | works | works | from the manual |
| IPO / preamp (AMP1, AMP2) | works | from the manual | works | works | from the manual |
| Manual notch | works | from the manual | works | works | from the manual |
| Noise blanker | works | from the manual | works | works | from the manual |
| Noise reduction (DNR) | works | from the manual | works | works | from the manual |
| RIT | works | from the manual | works | works | not offered yet |
| XIT | works | from the manual | works | works | not offered yet |
| VFOs and memories | |||||
| Memory channels: add and edit (MT write) | works | - | - | - | - |
| Memory channels: list and recall | works | not offered yet | works | works | not offered yet |
| Quick split (QS) | from the manual, untested | from the manual | not offered yet | not offered yet | not offered yet |
| Split | works | from the manual | not offered yet | not offered yet | from the manual |
| VFO B (A=B, B=A, swap, set frequency) | works | from the manual | works | works | from the manual |
| Radio | |||||
| 144 MHz band | works | - | - | - | - |
| 430 MHz band | works | - | - | - | - |
| amc_level | - | - | works | works | - |
| Antenna switching over CAT | - | - | - | - | - |
| audio_in_level | - | - | works | works | - |
| audio_out_level | - | - | works | works | - |
| C4FM mode (mode select only) | works | - | - | - | - |
| cw_keyer | - | - | works | works | - |
| cw_text | from the manual, untested | from the manual | works | works | from the manual |
| dual_receiver | - | - | works | works | - |
| menu | works | from the manual | works | works | from the manual |
| Power off over CAT | works | from the manual | works | works | from the manual |
| Power on over CAT (from standby) | works | from the manual | works | works | from the manual |
| range_scan | works | - | - | - | - |
| rear_select | - | - | works | works | - |
| scan | works | - | - | - | - |
| WIRES DG-ID menu value | works | - | - | - | - |
| FT-991A | FTDX10 | FTDX101D | FTDX101MP | FT-710 | |
|---|---|---|---|---|---|
| Modes | 14: LSB, USB, CW-U, FM, AM, RTTY-L, CW-L, DATA-L, RTTY-U, DATA-FM, FM-N, DATA-U, AM-N, C4FM | 14: LSB, USB, CW-U, CW-L, AM, FM, DATA-U, DATA-L, RTTY-L, RTTY-U, PSK, AM-N, FM-N, DATA-FM | 14: LSB, USB, CW-U, CW-L, AM, FM, DATA-U, DATA-L, RTTY-L, RTTY-U, PSK, AM-N, FM-N, DATA-FM | 14: LSB, USB, CW-U, CW-L, AM, FM, DATA-U, DATA-L, RTTY-L, RTTY-U, PSK, AM-N, FM-N, DATA-FM | 14: LSB, USB, CW-U, CW-L, AM, FM, DATA-U, DATA-L, RTTY-L, RTTY-U, PSK, AM-N, FM-N, DATA-FM |
| Bands | 160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, AIR, 2m, 70cm | 160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m | 160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m | 160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m | 160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m |
| Frequency range | 0.03-470 MHz | 0.03-75 MHz | 0.03-75 MHz | 0.03-75 MHz | 0.03-75 MHz |
CAT identity (ID;) |
ID0670; |
ID0761; |
ID0681; |
ID0682; |
ID0800; |
| FT-991A | FTDX10 | FTDX101D | FTDX101MP | FT-710 | |
|---|---|---|---|---|---|
| Level controls | AF Gain 0-255, RF Gain 0-255, Mic Gain 0-100, Squelch 0-100, TX Power 5-100 | AF Gain 0-255, RF Gain 0-255, Mic Gain 0-100, TX Power 5-100 | AF Gain MAIN 0-255, AF Gain SUB 0-255, RF Gain MAIN 0-255, RF Gain SUB 0-255, Squelch MAIN 0-100, Squelch SUB 0-100, Mic Gain 0-100, TX Power 5-100 | AF Gain MAIN 0-255, AF Gain SUB 0-255, RF Gain MAIN 0-255, RF Gain SUB 0-255, Squelch MAIN 0-100, Squelch SUB 0-100, Mic Gain 0-100, TX Power 5-200 | AF Gain 0-255, RF Gain 0-255, Mic Gain 0-100, TX Power 5-100 |
Where each status comes from: the capability files in backend/radio_remote/radio/capabilities/; the results of the real-radio tests are in
bench results.
No build step. Plain ES modules, served as static files; the Pi never needs Node.
frontend/
index.html style.css manifest.webmanifest sw.js icons/
src/
main.js api.js audio.js tuner.js util.js meter-cal.js
pages/ login.js radio.js admin.js
components/
freq-display.js LCD-style frequency; tap a digit = tuning step, wheel over a digit tunes by it
vfo-b.js VFO B readout, swap / A=B / B=A, SPLIT, quick split, set B
dial.js small round dial (tablet/desktop): drag, wheel, arrow keys; Fine / Coarse x10
tuning-strip.js sliding tuning ruler (phones): swipe, flick, x10
meters.js coloured segment meters: S-meter, power, SWR, ALC, COMP
controls.js colour-coded chips, level sliders, and the filter/DSP panel as tabs per group (Filter, Noise,
Receiver, Transmit; last tab remembered). All generated from the radio's capabilities
ptt.js hold-to-transmit, heartbeat, TX timeout bar
| Width | Layout |
|---|---|
| < 700 px (phone) | Compact top menu on one line, radio bar with a green dot and the control status. Same VFO block as large screens, just smaller: VFO A with the signal meter, the small TX meters under it, VFO B as an attached tab (red TX badge when it is the one transmitting in split); a thin sliding tuning scale; smaller buttons in VFO tools / step / band / mode / chips (side-scrolling rows). Bottom tabs: Radio / Filters / Levels / Audio; PTT sticky on the Radio tab |
| Tablet (touch screen, no hover, >= 700 px wide; e.g. iPad 10") | Phone-style tabs Radio / Filters / Audio & Levels / Digital with the large-screen look: Radio tab = the large-screen arrangement in compact form (smaller buttons, thin tuning scale): VFO block (A + meters + B tab), tuning scale, [VFO tools |
| There is no round dial any more: the sliding tuning scale (swipe or drag, x10 button) is used on every screen size. It shows frequency numbers whose spacing follows the tuning step (bigger step or x10 = wider span). | |
| Look: flat dark-slate buttons with a small LED bar on the left edge (grey = off, yellow = selected/on), controls grouped in labelled | |
| blocks (VFO TOOLS, TUNING STEP, MODE, BAND SELECT, SET FREQUENCY, RECEIVER & TOOLS). The active VFO panel has a cyan border, the transmitting | |
| panel turns red with a TX badge, the transmit VFO in split has an amber border and SPLIT badge. Meter colours keep their meaning | |
(blue/green/yellow/red). VFO B shows no S-meter or mode because the CAT command set has none for VFO B. Respects prefers-reduced-motion |
|
| and safe-area insets. | |
| ## Meters (provisional scales: read this) |
The radio's CAT meters are raw 0-255 values and the manual gives no calibration. So:
S_TABLE in src/meter-cal.js: raw -> dB relative to S9, in the style of
the Hamlib calibration for this radio family. It is an approximation, not measured on your radio (the label says "approx. scale").
S0-S9 fill 60% of the bar like a real meter, S9 to +60 dB the rest. Colours: blue, cyan, green up to S9, yellow and orange
above, red for strong signals.ui.meter_alc_full, ui.meter_comp_full). The ALC default is now 160: with normal speech into a dummy load the radio's ALC meter showed about 50% when the raw peak was 79, so 100% ~ 158 (one data point, assumes a straight line); adjust it so the app's bar matches the radio's own meter.
The COMP default (255) is a placeholder: no data yet.To calibrate properly (bench 5 in 03-bench-checklist.md): feed known signals (S-meter), and compare against the radio's own
meters and a wattmeter (power, SWR, ALC).
set_frequency is in flight at a time; the newest target wins. The display shows the target while tuning and returns to the
radio's own reported frequency shortly after (the radio is authoritative).manifest.webmanifest + sw.js (network-first shell cache, never caches /api or /ws) + generated icons
(python tools/make_icons.py). Static files are served with Cache-Control: no-cache; an open page shows a "Reload now" banner
when a new version has been installed under it.
Device detection: src/main.js sets data-tablet on the page when the screen has no hover (touch) and is 700 px or wider. To force a mode open the page with ?device=tablet, ?device=desktop or ?device=auto (remembered per browser).
?device=desktop to get the old all-in-one page back in a browser (remembered); ?device=auto resets it. Manual notch and auto notch
(DNF) moved to the Noise tab of Filters & DSP.Aligned rows: every control in Filters & DSP, every level and every audio meter is one row [button or label | bar or select | value]
(two columns of rows on wide screens). RIT and XIT are two buttons on one row with the shared clarifier offset bar (double-click the bar
to clear it). The offset bar range is now +-9990 Hz in 10 Hz steps: the old -9999..+9999 range produced values such as 201 Hz that the
server rejects (must be a multiple of 10), which is why the clarifier "did nothing". On the FT-991A, setting an offset while neither RIT
nor XIT is on also switches RIT on (RU/RD only move the clarifier knob). NOT bench-verified yet.
Audio devices: Audio tab, "Speaker / output" and "Microphone / input" lists (this browser only, remembered): choose a Bluetooth
headset or a car hands-free kit. Output selection needs setSinkId (Chrome, Edge, Android Chrome); Safari/iOS cannot choose it, there
the system sound settings decide. Device names appear after the microphone was allowed once. If the chosen microphone is gone, the
default one is used.
The REAR/MIC selector was removed from the PTT area: the microphone button in the VFO panel arms the mic and sets menu 106 to REAR (off = MIC).
VFO A: the band text ("20m") sits next to the mode ("USB") under the frequency. VFO B: [VFO-B] [TX] [SPLIT] [mode] [band] ... frequency
with the frequency right aligned. The VFO B mode comes from OI; (opposite band information, manual p.14, read only; same layout as
IF;), polled with the other VFO B reads. Bench-verified. (Earlier notes saying VFO B has no mode were wrong.) VFO B still has
no S-meter. The Tune button has more space from the tuning step buttons.
Radio meters (raw) row on the Audio tab: live raw 0-255 ALC, COMP, power, SWR and S-meter values, plus the peak ALC and COMP of the
last transmission (reset at each new key-down). Used to calibrate the percentage scales (ui.meter_alc_full, ui.meter_comp_full).
PTT on every tab (Radio, Filters, Audio & Levels, and Digital for FreeDV), pinned at the bottom above the tab bar, so levels and audio can be adjusted while transmitting. A long note on the Audio tab (device names hint) no longer widens the page: the page used to grow wider than the screen and push the tab bar out of view.
AGC (Receiver tab of Filters & DSP): OFF / FAST / MID / SLOW / AUTO via GT0 (documented in all four manuals). The radio answers
AUTO as AUTO-FAST / AUTO-MID / AUTO-SLOW (4 / 5 / 6); the app shows them all as AUTO. Offered on the FT-991A too; NOT bench-verified yet.
ATT is a four-step list (OFF / 6 / 12 / 18 dB) on the newer radios. See docs/08-other-radios.md.
Memory channels (FT-991A and FTDX101D): the Memories button, beside Tune in the tuning step row, opens a list of the radio's stored channels (001-099: number, tag,
frequency, mode) with a filter box; tap one to recall it. While the radio is in memory mode a purple MEM 005 badge shows in the VFO A
header and a Back to VFO button appears (it sends VM;, the radio's V/M key, which is a toggle, so it is only sent while in memory mode).
The list is read with MT ccc; (the READ form, which also returns the tag) and cached by the server; Re-read reads it again after you
changed memories on the radio. Recall is MC ccc;. Export saves the list as a CSV. Add / Edit (FT-991A, administrators; capability memory_edit) writes one channel with the WRITE form of MT
(MT ccc + frequency + +0000 + clarifiers off + mode + 0 + tone mode + 00 + shift + 0 + 12-character tag, manual p.11); the frame is checked first (channel 1..99, frequency range, known
mode, ASCII name of at most 12 characters) and matches the layout the radio itself reports. Because a real FT-991A answers every MT read with channel 001 in P1, a write is never trusted: the
whole list is read before and after, the channel must read back as written (only the frequency may be rounded to the radio's step) and no other channel may have changed; if one did, editing is
switched off until the service restarts. Endpoint POST /api/memories/{channel} (administrators, CSRF token, confirm: true, refused while the radio transmits, audited as memory_write).
Bench findings (FT-991A, 2026-10-08): the MT write has no tone-number field, but the radio stores, per channel, the tone number (CN00 CTCSS 000-049, CN01 DCS 000-103) that VFO-A holds at the moment of
the write; so for a channel with a tone the driver sets VFO-A (mode, frequency, OS0 shift in FM, CT0 tone mode, CN00/CN01), writes the channel, recalls it and reads CN00/CN01 back, then puts VFO-A, its own
shift/tone settings and the V/M state back. GET /api/memories/{channel}/tone returns what a stored channel holds (it recalls the channel and puts the radio back); the editor uses it so that editing a name keeps the tone.
Deleting: recall the channel (checked against IF), then AM; (VFO-A TO MEMORY CHANNEL) empties it (POST /api/memories/{channel}/delete); proved the same way as a write, and AM is never sent unless the recall took.
Not offered: the repeater offset (a radio menu setting). Channels 100-117 (the PMS ranges P-1L..P-9U) are not listed. Empty channels are skipped (the radio answers ?;).
Bench finding (FT-991A on a Raspberry Pi): the answer to MT ccc; always says channel 001 in its P1 field while carrying the data of
the channel asked for. The server therefore accepts the next MT answer as the reply to its own question and takes the channel number from
the request. A channel that stays silent is retried once, then tried with MR ccc; (read without tag), then skipped; the list read only fails
after four silent channels in a row. The status answer IF reports the recalled channel correctly.
Status lights in the VFO A header, after RX / TX / MEM: one preamp light whose text is the setting in use (IPO, AMP1 or
AMP2, from PA0;), ATT (RA0;, or the ATT step of the newer radios), one AGC light whose text is the setting (GT0;:
AGC OFF / FAST / MID / SLOW / AUTO; dark while OFF) and TUNER (AC;; blinks amber while tuning). Green =
active, dark = inactive; they appear only for functions
the radio profile has. Read-only: they mirror the state the server already polls. On phones (< 700 px) they wrap onto their own row.
Each light can be hidden per device (Account > Display, kept in localStorage as rr.lights; the header closes up when all are off).
ui.swr_warn (default 3.0, 0 = off). While tx and the SWR is at or above the limit the SWR bar is drawn fully red and a
line "High SWR: x.x:1" appears under the meters; it is kept 5 s after the transmission and a new transmission starts a new reading. Radios
that report a ratio (swr) are compared directly; the FT-991A reports swr_raw (0..255, RM6), turned into an estimated ratio with
1 + 2 * raw / ui.swr_raw_at_3 (PROVISIONAL, default 100 = "3:1"; the manual has no calibration, set it from the radio's own meter).
The limits are delivered in the hello message (ui.meter), so a change needs a restart like the ALC/COMP calibration.Timings.prefetch_s, 4 s) the server reads the 99 channels in the
background, so Memories opens at once. The cache is dropped and re-read on every reconnect.GET /api/admin/backup returns the config as a .toml download; POST /api/admin/restore applies the
settings the web UI may change (EDITABLE) and ignores the rest (never server.*, storage.*, safety.allow_ptt).GET /api/admin/update; when newer is true and that version was
not dismissed (localStorage rr.updateSeen) a bar offers What's new (the release page), How to update (opens Admin > Config >
Updates, which shows the self_update.sh command) and Dismiss. The server asks GitHub once a day (updates.py; updates.check,
updates.repo; the simulated radio never does); Check now in the Updates card asks at once. Nothing is installed from the page.Locked on - tap TX LOCK or the button to stop); it shows STOP while latched, and a tap on TX LOCK or on the big button un-keys. (The earlier slide-up gesture is gone.) HOLD TO TRANSMIT still transmits only while held. The heartbeat keeps running, so the server still un-keys on silence, disconnect or the time limit; the page also releases when it becomes hidden or the transmission ends. The Lock PTT button still blocks starting one.Lock (screen lock): a round padlock button beside HOLD TO TRANSMIT. While on it locks the WHOLE screen of this device: the PTT button, TUNE and
every other control, the top bar and the tabs are inert (no touch, click, wheel or key press reaches them; scrolling still works) and only the padlock stays live. This device only (remembered in localStorage as rr.pttLock; a running transmission can still be released, and locking while PTT is held
releases it). It is a client-side convenience against stray touches; the server-side safeguards are unchanged.
Top bar on phones shows icons only (labels from 700 px up). The radio tab shows only a status dot: green = radio online, yellow (pulsing) = connecting/reconnecting, red = radio offline.
mic_input_reset). If the page comes back with the microphone still armed it sets REAR again.The design below was written before the program existed. This is how it turned out; where the two differ, this section is right.
radio-audio process). It is a self-contained
class with a narrow interface, and the measured CPU cost on a Pi 4 (see audio) made a second process unnecessary.Ft991aProto for the FT-991A, HfProto for the FTDX10,
FTDX101D/MP and FT-710) and one YaesuCatDriver. Hamlib is not used. A Hamlib "NET rigctl" server (so a logbook can follow the radio) is planned.backend/radio_remote/radio/capabilities/*.toml) describe each radio; the page's controls are generated from them.
What each radio supports is generated from the same files.TxGuard (the only thing that can key the transmitter: permission, one owner, heartbeat, time limit, un-key on disconnect or lease
change) and ControlLease (who controls the radio). The transmit permission is off by default and is switched on by an administrator.self_update.sh, tailscale_setup.sh, the installer pack,
and the ready-made Raspberry Pi image with its Imager repository./ws/audio, binary Opus packets both ways, WebCodecs in the browser) is built as the fallback for networks that block WebRTC/UDP.Written as a proposal; the [DECISION] marks were the points confirmed before building.
Browser (tablet / phone / desktop)
│ HTTPS (443) WebSocket (state, control) WebRTC/Opus (audio, UDP)
▼
Caddy (TLS, optional) ──► 127.0.0.1:8080 radio-core (single owner of CAT)
│ ├─ auth / sessions / REST
│ ├─ WebSocket hub ◄── fan-out of RadioState
│ ├─ Radio State Engine (authoritative copy of the rig)
│ ├─ TX Safety Controller (PTT lease, watchdog, timeout)
│ └─ Radio Abstraction: RadioDriver ─► CatTransport
│ ├─ serial (pyserial)
│ ├─ mock (no hardware)
│ └─ hamlib (optional)
│ unix socket (control + tx-permit)
▼
radio-audio (separate process)
ALSA capture/playback ◄─► aiortc WebRTC (Opus)
▲
└── USB audio codec of the radio
USB: CAT serial ──► radio USB: audio codec ──► radio
Principles: the Pi owns the CAT port (no browser ever talks to the radio); the radio is the source of truth; the browser is a view plus an intent sender; safety logic lives in the backend; audio is isolated from CAT so audio problems cannot stall PTT release.
Python 3.11+ (asyncio), packaged in a venv.
| Why | Detail |
|---|---|
| Fits the Pi 3B+ | asyncio is enough for ~5–15 CAT transactions/s and a few WebSocket clients. CAT is I/O-bound. |
| Library fit | pyserial (+ async wrapper), aiohttp (HTTP+WS in one small dependency), aiortc (WebRTC/Opus in Python), numpy, stdlib sqlite3, hashlib.scrypt, tomllib. Few deps = fewer install failures on Pi. |
| Testability | Easy mock transport; pytest + pytest-asyncio; develops and tests on Windows (Python 3.14 already present here) with no Pi. |
| Hamlib | rigctl/rigctld as a subprocess, or Hamlib's own Python binding; either is optional. |
Rejected: Node (not installed here, no better audio story), Go/Rust (excellent performance, but a bigger build/learning surface; revisit only if the aiortc spike fails — see §5.3), Docker (adds a layer to USB device passthrough/udev/ALSA and ~100 MB; systemd is simpler on a dedicated Pi. A Dockerfile can be added later for dev).
Two processes, one repo:
- radio-core — HTTP, WebSocket, CAT, state, auth, TX safety.
- radio-audio — ALSA ↔ WebRTC only. Talks to core over a Unix domain socket. It cannot key the radio; it only streams mic audio to the radio's codec while core says "TX permitted".
No-build ES modules + Preact + htm (vendored, ~12 KB gzip), plain CSS (custom properties, container queries), SVG/Canvas for dial and meters, a service worker + web manifest for PWA.
| Why | Detail |
|---|---|
| No toolchain on the Pi | Node is not needed to run or install. Repo contains the final static files. |
| Light | Single-digit-KB app code; no React/Angular. |
| Maintainable | Components in frontend/src/components/*.js, typed via JSDoc + tsc --checkJs optionally. |
| Touch/PWA | Pointer Events (one code path for mouse/touch/pen), wheel for the dial, screen.wakeLock, installable PWA. |
Trade-off: no TypeScript compile step. If you prefer TypeScript + Vite we can adopt it; it needs Node on the developer machine only, and output is committed/released as static dist/.
Hybrid, native-first. One Python CatTransport per radio; drivers speak Yaesu CAT directly. Hamlib is an optional second backend.
RadioDriver (abstract) capabilities: machine-readable, per model
├─ FT991ADriver (native CAT, verified from the official manual)
├─ FTDX101Driver (declared, disabled until manual verified)
├─ FTDX10Driver (declared, disabled)
├─ FT710Driver (declared, disabled)
└─ HamlibDriver (generic fallback; capability set derived from `--dump-caps`)
CatTransport
├─ SerialTransport (pyserial; exclusive lock file; reconnect with backoff)
├─ MockTransport (simulated radio; default for development; refuses PTT-to-hardware)
└─ ReplayTransport (tests: recorded CAT transcripts)
Core CAT mechanics (all model-agnostic, in radio/cat/):
;-framed parser tolerant of interleaved auto-information frames. Per-command timeout; limited retries; ?; handling.ID; check → AI1; → full read-out → steady state. AI push updates keep latency low and detect front-panel changes. If AI isn't reliable, driver falls back to IF; poll (~2/s).IF;/TX; 2 Hz as AI backstop; slow settings round-robin one command every ~250 ms; paused for the user's own command bursts.
Throughput check: at 38400 baud an SM0;→SM0xxx; pair is ~11 bytes ≈ 3 ms on the wire; the radio's own latency dominates, so a budget of ~15 transactions/s is realistic and leaves headroom.fcntl.flock (Linux) / named mutex (Windows dev); udev symlinks /dev/yaesu-cat by USB serial/interface number. Port auto-detection: enumerate pyserial.tools.list_ports by Yaesu/Silicon Labs VID:PID, probe candidate ports with ID; (read-only, never a set), pick the one that answers with a known ID. Two-port radios (the 991A) are disambiguated by this probe, not by order.connected=false (UI banner), backoff 1→10 s, re-detect port, replay init sequence, then read the radio and overwrite the state (physical radio wins). Pending set-commands queued during disconnect are dropped, never replayed (could surprise the operator).WebRTC with Opus (mono, 20 ms frames, 16–24 kHz wideband-speech profile, ~24–32 kbps, in-band FEC enabled). Chosen because it is the only browser-native low-latency real-time audio stack that works on iOS Safari, Android Chrome and desktop without plugins, includes jitter buffer/loss concealment, and the same peer connection carries the microphone upstream.
Library: aiortc (Python, ctypes libopus). Risk to verify, not assume: aiortc's Opus path is hard-wired to 48 kHz and its CPU cost on a Pi 3B+ with an ARM64 build has not been measured by me. Plan a Phase 6 spike: 2-way stream, measure CPU/latency/jitter on a real Pi 3B+. Fallback A: GStreamer webrtcbin (C, lower CPU, heavier install). Fallback B: WebSocket carrying Opus frames decoded in the browser with WebCodecs (not available on older iOS) — used also when UDP is blocked.
ALSA only (Raspberry Pi OS Lite 64-bit; no PulseAudio/PipeWire). Devices referenced by stable names (hw:CARD=CODEC,DEV=0), never numeric index. radio-audio opens the radio's codec exclusively: RX = capture, TX = playback.
Detection layer: /proc/asound/cards + aplay -L → candidate list, "looks like Yaesu codec" heuristic, user override in the admin UI, hot-plug via udev/inotify with reopen backoff.
rx_gain_db, browser <audio> volume.getUserMedia constraints for echoCancellation/noiseSuppression/autoGainControl are user settings, default AGC off, echo-cancel on for speaker use) → Opus → server decode → tx_gain_db → limiter → ALSA playback to the radio. Limiter on the TX path is mandatory to prevent over-deviation/ALC pumping.radio-audio, sent to core at ~10 Hz and fanned out through the WS; the browser also meters its own mic locally.Browsers only expose getUserMedia on a secure context (HTTPS, or localhost). A plain http://raspberrypi.local/ page cannot capture the microphone on any modern browser, iOS included. So:
*.ts.net name.tailscale serve/tailscale cert) gives real publicly-trusted certificates for the Pi's tailnet hostname — the recommended default for tablets/phones.This is why the installer sets up Caddy by default [DECISION]: the app listens on 127.0.0.1:8080 only; Caddy exposes 443/80. Without HTTPS, the UI disables the microphone button and says why.
No STUN/TURN is needed on LAN or tailnet: ICE host candidates on the LAN/tailscale0 interface suffice. If Tailscale falls back to a DERP relay (TCP/HTTPS), WebRTC UDP latency increases; the UI shows the ICE path and the user can force the WebSocket-Opus transport.
Single WebSocket /ws (JSON; msgpack optional later).
state (full snapshot on connect), patch (changed fields only, coalesced at ≤20 Hz), meters (high-rate, separate small message), event (errors, warnings), lease (who controls), audio (stream status).cmd messages {id, type, args} with ack {id, ok|error}; hello with a short-lived ticket; ptt heartbeat./api/…): config, users, diagnostics, logs, auth. Never used for high-rate data.Origin allowlist check + one-time ticket issued by REST after login (prevents cross-site WS hijack).A single server-side RadioState (dataclass → dict). Sources: driver events (AI push, poll answers). Sinks: WS hub. Rules:
{value, ts, source}; per-field "unsupported" simply isn't in the snapshot.Machine-readable radio/capabilities/<model>.toml, e.g.
[model] id="ft991a" name="Yaesu FT-991A" cat_id="0670"
[verified] source="FT-991A_CAT_OM_ENG_1711-D" bench="pending"
[features]
vhf=true uhf=true antenna_switching=false # no CAT opcode (verified absent)
swr_meter=true alc_meter=true comp_meter=true
power_off_cat=true power_on_cat="unverified"
Each field is true | false | "unverified". The UI builds itself from the capability snapshot sent at connect; "unverified" is treated as false for display. Ranges (rf_gain 0..255, power 5..100) come from the same file. A test asserts every UI control maps to a capability key.
PTT request ──► [auth] ──► [ALLOW_PTT?] ──► [lease holder?] ──► [rate limit] ──► driver.tx(True)
(default false) (one owner)
TxGuard watchdog (core, independent asyncio task + monotonic clock):
• heartbeat: owner's WS must send a ptt-hold ping ≥ every 300 ms; miss 1.0 s ⇒ TX0
• WS close / error / lease loss / auth expiry ⇒ TX0
• max TX duration (default 120 s, config) ⇒ TX0, lock PTT until released
• CAT disconnect while keyed ⇒ attempt TX0 repeatedly; radio TOT (`EX036`) is the final backstop
• SIGTERM / shutdown ⇒ TX0 before exit
• TX0 retried until the radio acknowledges (`TX;` → 0)
Radio-keyed externally (`TX;`=2): shown as TX; software never claims control.
PTT is momentary (hold-to-talk) by default; latching requires an explicit setting + confirm. Test suite: transports are mocks; a RealSerialTransport refuses to open when RADIO_REMOTE_TESTING=1 and PTT code paths require ALLOW_PTT=true in the config file, not the environment, so tests cannot enable it. Tuner start (AC) and any other TX-causing command go through the same guard.
lease.request → current holder gets a prompt (accept/deny/timeout 10 s); admin can force-release; lease expires with heartbeat loss (5 s).| Concern | Measure |
|---|---|
| Authentication | Local accounts, hashlib.scrypt (N=2¹⁴/r=8/p=1 tuned for Pi 3B+, ~100–250 ms), constant-time compare, forced password change on first login (first-run wizard creates admin; no default password). |
| Sessions | Random 256-bit server-side session id in HttpOnly; Secure; SameSite=Strict cookie; idle + absolute expiry; revoke on logout/password change. |
| CSRF | SameSite=Strict plus a per-session CSRF token header on all non-GET REST; WS requires Origin allowlist and ticket. |
| Rate limiting | Login attempts per IP+user with exponential backoff; command rate limits per connection; PTT edge limit. |
| Input validation | Command catalogue: browser sends intents (set_frequency{hz}) validated by schema/range per capability; no raw CAT, no shell, no paths from the browser. Serial/Hamlib args come only from config validated against enumerated devices. |
| Subprocesses | Fixed argv lists, never shell=True; config values never concatenated into commands. |
| Network | Bind 127.0.0.1; Caddy provides TLS. Install script does not touch the firewall unless asked, but docs show ufw allowing LAN + tailscale0 only. No port forwarding; Hamlib rigctld (if enabled) bound to localhost. |
| Tailscale | App-level auth stays on regardless; optional "trust Tailscale identity" header mode is off by default (documented, not enabled). |
| Secrets/logs | Passwords never logged; config stored 0600 owned by service user; log scrubber for Cookie/Authorization. |
| Audit | auth events, PTT on/off with user/IP, config changes, power-off. |
/etc/radio-remote/config.toml (admin-edited through the UI; atomic write + backup .bak)./var/lib/radio-remote/radio-remote.db (SQLite: users, sessions, audit)./var/log/radio-remote/*.log at configurable level.allow_ptt=false, radio.model=mock, mock transport.systemd (no Docker): radio-remote-core.service, radio-remote-audio.service, caddy (stock package), udev rule for stable device names and plugdev/dialout/audio groups, Restart=always, WatchdogSec using sd_notify from core (health), TimeoutStopSec large enough for TX0. install.sh is idempotent; update.sh does git pull, venv update, migrate, restart. Debian packages: python3-venv libopus0 libasound2 libportaudio2 hamlib-utils caddy (Hamlib from Debian may be older than 4.7 — the installer reports the version and can build 4.7+ from source on request).
| Item | Expected | Mitigation |
|---|---|---|
| CAT + state + WS | low CPU | single poll loop, patch coalescing |
| Opus encode+decode mono wideband | single-digit–low-teens % of a core (REQUIRES MEASUREMENT) | 16 kHz, 20 ms, complexity ≤5; audio in its own process |
| WebRTC (SRTP/DTLS) in Python | REQUIRES MEASUREMENT | spike in Phase 6; fallbacks in §5.1 |
| RAM | tens of MB (Python core ~60–80 MB, audio ~80 MB) estimated | few deps; no ML/numpy-heavy ops |
| USB | 2.0 shared with ethernet on 3B+ | not a problem at CAT/48 kHz audio bitrates |
| Network | Ethernet ≤ 300 Mbit/s effective on 3B+; Wi-Fi is fine for audio ~50 kbps | prefer wired |
Structured logs (JSON optional), per-subsystem loggers (cat, audio, auth, ptt, web, config). Diagnostics page: Pi model, uptime, CPU, RAM, temperature (/sys/class/thermal), version, radio model/ID, port, CAT counters (tx/rx/timeouts/errors), Hamlib version, audio devices & stream state, clients, lease holder, last 200 log lines (scrubbed).
SerialTransport code is exercised without hardware.radio-remote/
README.md
install.sh update.sh
pyproject.toml requirements.txt
backend/radio_remote/
app.py config/ auth/ api/ ws/ state/ safety/ services/
radio/
base.py registry.py
cat/ (frame.py transport_serial.py transport_mock.py hamlib_backend.py)
drivers/ ft991a.py ftdx101.py ftdx10.py ft710.py hamlib_generic.py
capabilities/ ft991a.toml ftdx101d.toml …
audio/ (devices.py worker.py webrtc.py levels.py)
frontend/ (index.html manifest.webmanifest sw.js src/ components/ radio/ meters/ controls/ pages/ styles/ vendor/)
config/ radio-remote.example.toml udev/ systemd/ Caddyfile.example
scripts/ detect-radio.py probe-hamlib.sh
docs/ 01-capability-matrix.md 02-architecture.md installation.md …
tests/
| Phase | Deliverable | Gate |
|---|---|---|
| 3 | Skeleton: core + mock radio + config + auth + WS + basic dashboard | UI shows mock radio, login works, tests green |
| 4 | CAT layer (frame parser, serial transport, FT-991A frequency/mode/band/meters, reconnect, AI sync) tested on mock + pty | Bench test #1 with real 991A, PTT disabled |
| 5 | FT-991A DSP/filters/antenna(n/a)/power/VHF-UHF/C4FM; bench tests of power-ON, AGC map, meters calibration | Needs real radio |
| 6 | Audio spike then full RX/TX audio | Pi 3B+ measurements |
| 7 | Production UI | Tablet first |
| 8 | Security hardening, PTT lease/guard tests, Caddy+Tailscale docs | Safety review |
| 9 | Installer + docs | Clean-Pi install |
| later | FTDX101/10/FT-710 drivers once manuals provided | per-model verification |
PS1;, audio-route menus, meter calibration). I will give a checklist; PTT stays disabled until you say otherwise.Status: the benches below were run on a real FT-991A and passed (results: bench results). This page is kept as the procedure for checking a new radio or a new setup, and for re-testing after a change. The experimental radios have their own first-contact checklist in other radios.
Rule for every step: PTT stays disabled (safety.allow_ptt = false) and the antenna jack should have a dummy load or be disconnected until a step says otherwise.
DAKY.ls /dev/serial/by-id/ and lsusb (expect Silicon Labs CP210x, two serial ports).sudo systemctl stop radio-remote # the service holds the port
python scripts/bench1_readonly.py
sudo systemctl start radio-remote
Expected: ID; -> ID0670;. Things to look at:
- whether ?; appears for any read (the manual is silent on error replies),
- the real IF; / SM0; / RM* values at idle (meter calibration starts from these),
- which of the two serial ports answers, and at what baud.
Config: radio.model = "ft991a", serial_port = "auto", allow_ptt = false.
- Turn the VFO knob: browser frequency must follow within ~0.5 s.
- Change mode on the radio and in the browser.
- Pull the USB cable and re-plug: UI shows "radio offline" then recovers on its own.
- Power-cycle the radio: UI recovers; confirm frequency/mode are correct afterwards.
- Check the log shows AI1 re-sent after reconnect.
Change each control in the browser and compare with the radio's own display. Things the manual leaves open:
| Check | What could differ from the code |
|---|---|
| Width per mode | Table is manual p.16. DATA-* modes are deliberately not offered. Confirm USB narrow/wide steps match the front panel, and that code 00 ("default") reads back sensibly. |
Narrow filter (NA0) |
Printed as MA in the manual body, NA in its index. Code uses NA. If the radio answers ?;, report it. |
| Contour / APF / notch step sizes | Manual gives ranges, not steps. UI uses 10 Hz. Confirm the radio accepts e.g. 1210 Hz. |
| APF | Probably CW-only; the radio may answer ?; in SSB. |
| IF shift | Radio steps are 20 Hz; the UI enforces multiples of 20. |
| Clarifier | Offset set via RC; + RU/RD. Check sign, and that the RIT/XIT flags follow. |
IPO / AMP1 / AMP2 (PA0) |
Confirm the labels against the front panel. |
Processor (PR0: 1 = off, 2 = on) |
Confirm on/off is not inverted. |
Band buttons (BS) |
The radio goes to its last frequency on that band. Check the 60m and 12m codes especially. |
| Antenna tuner | The on/off toggle (AC001/AC000) does not transmit. TUNE (AC002) keys the transmitter (a carrier): only with an antenna or dummy load, and only through the transmit safeguards. On the FT-991A both work. |
| Power off (admin only) | PS0;. Confirm the radio goes to standby and the app shows "radio offline". Then see whether USB serial still answers ID; in standby (this decides whether CAT power-on is possible). |
Never offered: VOX (VOX plus remote audio risks unintended transmission) and menu (EX) writes, with one exception: the REAR / MIC input switch (menu 106).
AGC and power on/off are offered because they were verified on the radio.
The tuner on/off chip (TUNER) was once reported as "does nothing" on the real radio (it was fine; the radio replies were checked). If a
function seems dead, diagnose with raw reads first (no transmit):
sudo systemctl stop radio-remote
sudo -u radio-remote /opt/radio-remote/venv/bin/python - <<'EOF'
import serial, time
s = serial.Serial(); s.port, s.baudrate, s.timeout = '/dev/ttyUSB0', 38400, 0.5
s.dtr = False; s.rts = False; s.open()
def ask(c):
s.reset_input_buffer(); s.write(c); time.sleep(0.4); print(c, '->', s.read(64))
ask(b'AC;'); ask(b'AC001;'); ask(b'AC;'); time.sleep(2); ask(b'AC000;'); ask(b'AC;')
s.close()
EOF
sudo systemctl start radio-remote
(AC001;/AC000; switch the tuner on/off; they do not transmit.) Watch the radio's display for the tuner indicator.
TUNE button (works on the FT-991A; re-test it on any other radio): sends AC002; (manual: "tuning start / tuning stop"), which makes the radio
transmit a carrier. Server-side it is guarded like PTT: needs allow_ptt = true, control, an operator account; hard limit
15 s; stops if the browser disconnects; a stop (AC002; again) is only ever sent while the radio itself reports RF going
out, because the command is a toggle and would otherwise start a new tune. Test ONLY with a dummy load / suitable antenna, at
5 W, tuner switched on, and be ready to switch the radio off.
Questions to answer on a new radio: does AC002; start a tune; does the radio report TX (TX;) or a power reading while
tuning; does a second AC002; stop it; how long does a tune take.
DG-ID (new): radio menu 153 "WIRES DG-ID" via EX153 (00 = AUTO, 01-99), shown next to the mode buttons in C4FM mode only.
The CAT manual lists ONE DG-ID item; no separate TX/RX one. If the radio has separate TX and RX DG-ID settings, give me their
menu names/numbers (or the operating-manual page); they will not be guessed. Check: set a value in the app, compare with the
radio's menu 153; set it on the radio, see the app follow within about 10 s (read every slow-poll cycle).
PS0; power off / PS1; power on from standby (does the USB UART answer in standby?).Everything below was tried on a real FT-991A with a Raspberry Pi 4 (Raspberry Pi OS Trixie, Python 3.13).
| Area | Result |
|---|---|
| CAT link, auto-detection of the port, reconnect after unplug / power cycle | works |
| Frequency, mode, bands, VFO B (frequency and mode), split, A=B / B=A / swap | works |
| RIT / XIT, filters and DSP (width, IF shift, contour, notches, DNR, NB), IPO / ATT / AGC | works |
| Memory channels: list and recall | works (the radio answers every MT read with channel 001: handled; see below) |
| Meters: S, power, SWR (warning limit), ALC and COMP | work; ALC calibrated from one point, SWR ratio is an estimate from the raw value |
| PTT, TUNE, REAR / MIC switch, transmit switch in Admin, Lock PTT | work |
| Remote audio both ways, Bluetooth headset, background listening on a phone | works |
| Power off and power on over USB from standby | works |
Installer pack, ready-made image, Imager's own customisation screens, in-app update (self_update.sh), Tailscale (tailscale_setup.sh) |
work |
| Settings backup and restore, update notice | work |
| C4FM RX/TX DG-ID | not reachable over CAT (no menu value changes) |
Quick split (QS;) |
implemented, not yet tried |
Automatic rollback of a failed update, restore.sh, uninstall.sh |
scripts exist; a full real-Pi run of each is not recorded yet |
Radio behaviour that differs from the CAT manual (all handled in the code):
- PR (speech processor): the radio uses 0 = off, 1 = on, not 1 / 2 as printed.
- FT (TX VFO): set 2 / 3 (TX on A / B), read 0 / 1.
- MT (memory read with tag): the answer to MT005; carries the data of channel 5 but always says channel 001; empty channels answer ?;.
The server matches the answer on MT alone and takes the channel number from its own request. A channel that stays silent is retried and
then read with MR (no tag).
- EX153 (WIRES DG-ID): the radio answers with three digits (EX153000;), the manual shows two.
- The radio answers a read taken immediately after a VFO command with the old value: reads after VFO operations wait 0.35 s and repeat.
- The radio does not announce VFO B changes: VFO B is polled every second.
The sections below are the log of the tests in the order they were done (some notes at the time said "to be tested": the summary above is current).
Date: 2026-10-01. Hardware: Raspberry Pi (hostname FT991A), FT-991A over USB, app v0.1.0.
Method: bench1_readonly.py (sends read commands only).
| Command | Reply | Meaning / status |
|---|---|---|
ID; |
ID0670; |
FT-991A identified. Confirmed on hardware |
AI; |
AI1; |
auto-information was already on (the service enabled it). Confirmed |
IF; |
IF001014220000+000000200000; |
25-character body matches the manual's layout (mem 001, 14.220000 MHz, clarifier +0000, RIT/XIT off, mode 2 = USB, VFO). Confirmed |
FA; / FB; |
FA014220000; / FB007000000; |
9-digit frequencies. Confirmed |
MD0; |
MD02; |
USB. Confirmed |
TX; |
TX0; |
receiving. Confirmed |
AG0; RG0; MG; PC; |
AG0040 RG0255 MG041 PC005 |
levels read as 3 digits; TX power was 5 W. Confirmed |
SM0; |
SM0032; |
S-meter raw 32 at idle (first calibration point; antenna state unknown) |
RM3..RM6; |
RM3000 RM4000 RM5000 RM6000 |
COMP/ALC/PO/SWR are 0 while receiving. Confirmed |
Link statistics: tx 16, rx 28, timeouts 0, rejected (?;) 0, unsolicited 12.
Findings:
- The CAT port is /dev/ttyUSB0 (the FT-991A exposes two ports; detection picked the right one at 38400 baud).
- No read command was answered with ?;.
- The service user needs read access to /opt/radio-remote/..., not /home/<user>/... (home directories are private).
- The service must be stopped while running the bench script: it holds the port.
AC) and split (FT): replies from the real radio| Sent | Radio replied | Meaning |
|---|---|---|
AC; |
AC000; |
tuner off |
AC001; / AC; |
AC001; / AC001; |
tuner on, reads back on |
AC000; / AC; |
AC000; / AC000; |
tuner off again. The command layer is correct; the app's TUNER chip lights teal (operator report) |
FT; |
FT0; |
transmit on VFO A (normal) |
TS; |
TS0; |
TXW off |
FT3; |
FT1; + RI50; RI61; |
transmit now on VFO B = split. The radio also announces RI5x/RI6x (A/B transmit flags) |
FT2; |
FT0; + RI51; RI60; |
back to VFO A |
So FT behaves as the manual says: set 2 / 3 (TX on A / B), read 0 / 1. Split in the app = FT3;, normal = FT2;; the state
follows the radio's own FT announcements. QS; (quick split) and the TUNE button (AC002;) are implemented but NOT yet verified.
Auto-information also streams RM0nnn; (front-panel meter) continuously; currently ignored.
FB; and FT; are now in the 1 s backstop poll.FB test requested.FB check on the real radio: FB; -> FB007498700;, FB007074000; accepted (the radio sent no echo of the new B
frequency, unlike AC, FT which announce changes), FB; -> FB007074000;. So Set B works at the CAT level; the radio does not
announce VFO B changes, they must be polled (now every 1 s). The "Set B does not work" symptom was the stale label, not the command.EX<number>; reads
could be probed; nothing will be guessed.install.sh ran on Raspberry Pi OS (aarch64, Python 3.13.5): service, Caddy, udev rule, config created; app healthy.
(First install was done before these notes; it worked, app reachable, radio detected.)update.sh from a tarball copy (no git): backup written, new release deployed beside the old one, dependencies already
satisfied, switched, restarted, health check passed ("Updated and healthy"). Config and users untouched.restore.sh, uninstall.sh.PR)The service logged bad frame from radio: bad PR every slow-poll cycle. Raw capture from the real radio:
| Sent | Radio replied |
|---|---|
PR0; |
PR00; (processor off) |
PR1; |
PR10; (parametric mic EQ off) |
The manual says the on/off digit is 1 = OFF, 2 = ON; the radio uses 0 = OFF, 1 = ON. Set test on the real radio:
| Sent | Radio |
|---|---|
PR01; |
accepted; PR0; then reads PR01; (processor ON) |
PR02; |
?; (rejected) |
PR00; |
accepted; reads PR00; (OFF) |
Fixed in both directions (parser and encoder): the processor toggle now sends PR01; / PR00;. Before the fix the UI would
have turned the processor on when asked to turn it off. Lesson: the CAT manual can be wrong on value tables; every set
command in the controls panel should be checked on the real radio (Bench 2b), not trusted from the manual.
Side note: with auto-information on, the radio streams RM0nnn; (front-panel meter) frames continuously; they are
currently ignored.
All other polled controls (NA, SH, IS, CO, BP, BC, NR, RL, NB, NL, PA, RA, RT, XT, PL, ML, AC) produced no warnings on the
real radio, i.e. their reply formats parsed. Rejections (?;) are not logged at warning level, so a command refused in the
current mode would not show up in that log.
Listen in the browser: receive audio is heard and the RX meter moves. Observation: with IPO selected nothing useful is heard, with AMP1/AMP2 it receives. Expected radio behaviour (IPO = no preamp gain) but needs confirming against the radio's own display (open question: do app labels IPO/AMP1/AMP2 match the radio's front panel?).
arecord -l / aplay -l (Pi 4, radio on): the radio appears as card 1: CODEC [USB Audio CODEC], device 0 for both capture
and playback; the other playback cards are the Pi's headphones and two HDMI outputs. Exactly one USB audio CODEC, so
auto-detection selects plughw:CARD=CODEC,DEV=0 for both directions.
audio_spike.py on a Raspberry Pi 4 Model B (aarch64, 4 cores, Python 3.13.5):
| Test | Result |
|---|---|
| Opus encode 96 / 32 / 24 kbps | 599 / 851 / 849 us per 20 ms frame (3.0 / 4.3 / 4.2 % of one core) |
| Opus decode | 124-156 us per frame (about 0.7 %) |
| measured bitrate for the 32 kbps target | 31.7 kbps (the bitrate patch works) |
| WebRTC loopback, 1 listener | 20.8 % of one core (server encode + client decode + SRTP, both ends in one process) |
| WebRTC loopback, 3 listeners | 55.6 % of one core |
| frame delivery | 50 frames/s per listener in both cases |
Reading: real server-side cost is below these figures because the loopback also runs the client. A Pi 4 has ample headroom
for the default audio.max_peers = 3; a separate audio process is not needed there. A Pi 3B+ is expected (estimate, not
measured) to be 2-3x slower per core: use max_peers 1-2.
At that point still unverified (all of it was verified later, see the summary at the top): every set command, the controls panel, PTT,
audio, NA/GT quirks, PS0;/PS1;. Meter calibration against real signals is still open.
DG-ID follow-up (bench): scripts/ex_dump.py dumped EX001..EX160 (153 readable) before and after changing the DG-ID on the radio
(GM long press, C4FM). No EX value changed, so the C4FM RX/TX DG-ID is not reachable over CAT. The web selector was replaced by
a hint "DG-ID: set on the radio (hold GM)"; the backend dgid control (menu 153) remains but is not offered in the UI.
Mic input (menu 106 SSB MIC SELECT, manual p.9: 0 = MIC, 1 = REAR): added as a control mic_select (EX106), shown as a "Mic: REAR/MIC"
button in the Receiver & tools block, refused while transmitting. Verified later: the button flips menu 106 on the radio (now the REAR / MIC buttons above PTT).
Power on (PS1): the CAT manual (p.15) says the command needs dummy data first and PS1; one to two seconds later. Implemented in the
driver supervisor (power_on, wake_gap_s = 1.4 s): while the radio is not connected an admin gets a "Power on radio" button in the
offline banner; the server opens the serial port, sends ;, waits, sends PS1;, closes it and keeps retrying the normal connection.
Bench to do at the time (done, see below): radio in standby with the DC supply on, USB cable connected: does the USB serial port stay present and answer? If the
port disappears in standby this cannot work over USB (the Pi would need a relay on the radio's power input instead).
Power on / off VERIFIED on the real FT-991A (operator report): "Power off" (PS0) and "Power on radio" (dummy data, 1.4 s, PS1) both work over USB with the radio in standby; the USB serial port stays available in standby.
VFO B mode: added OI; (manual p.14) to the sync and the 1 s backstop poll; the answer is decoded like IF and only mode_b is used.
Bench to do at the time (done, see below): check that VFO B shows the right mode, also after A<->B / A=B / B=A.
RX audio level (operator report): the browser audio was too quiet with menu 107 SSB OUT LEVEL at its default (50). Setting menu 107 to
90 fixed it, with audio.rx_gain_db left at 0. So the radio's USB output level menus (107 SSB, 073 DATA, 046 AM, 075 FM, 054 CW,
099 RTTY) are the right place to set the receive level for the Pi.
ALC calibration (operator report, dummy load, FT-991A): normal speech: radio ALC meter about 50%, app raw ALC peak 79 (with the old
scale 50 the app bar was full). So ui.meter_alc_full default raised to 160. One point only: the radio's ALC meter may not be linear;
a second point (louder speech) is still wanted.
VFO B mode via OI; VERIFIED on the real FT-991A (operator report): VFO A and VFO B show the correct band and mode.
RIT/XIT clarifier VERIFIED (operator report): the RIT + XIT buttons with the shared offset bar work on the FT-991A. COMP scale
(ui.meter_comp_full = 255 default) looks right to the operator, no change needed.
Background audio VERIFIED (operator report, phone): Listen keeps playing with the app in the background.
Memory channels (to be tested at the time; verified below): Memories button, list read with MT001;..MT099;, recall with MC005;, back with VM;.
Check: do the tags and frequencies match the radio's own memory list, are empty channels skipped (?;), does the MEM badge follow the
radio, and does Back to VFO return to the previous VFO frequency? If an empty channel is answered with frequency 000000000 instead
of ?; it is skipped as well.
Memory channels VERIFIED on the real FT-991A (operator report: "it works"): Memories list, recall and back to VFO.
AGC VERIFIED on the real FT-991A (operator report): the AGC list (OFF / FAST / MID / SLOW / AUTO, GT0) works.
Bluetooth audio device choice VERIFIED (operator report): choosing a Bluetooth output/input on the Audio tab works.
ALC scale accepted by the operator for now (ui.meter_alc_full = 160); a second point (louder speech) is optional.
Installer pack, static checks (development PC, Debian under WSL1): every shell script passes bash -n; install-everything.sh --dry-run
prints the expected sequence (packages, user, release copy, offline pip from the bundled wheels, config, udev, systemd, Caddy, docs URL);
--model ftdx10 prints the experimental note, a bad model and a non-root run are refused. (Run for real on a Pi 4 afterwards: it found one
bug, the program folders were copied with private permissions so the service user could not read them; fixed and covered by a test.)
Ready-made image, Imager and updates (2026-10-03): the image built by GitHub Actions booted on a Pi 4 and ran. Raspberry Pi Imager 2.x
shows its own hostname / user / Wi-Fi screens for it through the content repository (os-list.json); the settings script was needed before.
The in-app update (self_update.sh: checksum, backup, install, health check) installed a newer release. tailscale_setup.sh made the Pi
reachable over its .ts.net name with a real certificate. The memory list read failed until the "answer says channel 001" behaviour was handled.
Hardware: Raspberry Pi 4 (hostname FT991A), FTDX101D over USB, app v1.1.9 plus the local FTDX101D work. The radio first stayed silent until its menu CAT RTS was set to OFF (with RTS high it answered
ID0681;), CAT rate 38400, Enhanced port /dev/ttyUSB0. Read-only first contact: 36 commands answered, 0 timeouts, 0 rejected.
| Check | Result |
|---|---|
Frequency, band, mode (MAIN) and the SUB receiver (FB, OI) |
work, both directions |
FR (which receivers listen), FT (transmit MAIN / SUB), VS (operated receiver) |
work; the radio's own keys are followed |
| USB audio carries one receiver | SSB OUT SELECT 0 / 1 switches it; the other mode families answer the same way |
Audio out level, audio in level (EX0101 09, EX010113 and the AM / FM / DATA / RTTY / CW versions) |
work |
Filter, noise, receiver controls for MAIN and for SUB (P1 = 0 / 1) |
work |
| Speech processor | PR0 is 0 = OFF / 1 = ON, the manual says 1 / 2 and PR02; gets ?;; the radio's PROC light follows |
| PROC / PITCH knob | sets the AMC output level (AO) while the menu PROC LEVEL is not COMP; sets PL when it is COMP |
CW keyer (KS, KP, KR, BI) |
the values apply; the radio's main screen does not show them outside CW mode. SD answers 2 digits, not 4: not offered |
TUNE (AC002;), antenna tuner on / off |
work |
Memories (MT read, MC recall, VM; = V/M key) |
work; the radio has only channel 001 stored. MT answers have the FT-991A layout |
| PTT and meters | PTT keyed several times by the operator; SUB on the air not tried |
Not tried: transmitting on the SUB receiver, and the FTDX101MP.
What is finished, what is verified on a real radio, and what is still open. For the details of each test see bench results; for what each radio supports see the capability matrix.
self_update.sh
with automatic rollback, Tailscale access (tailscale_setup.sh).QS;) on the FT-991A.rigctl only)rigctl;
a test with Log4OM itself on the real radio is still to do. WSJT-X transmitting through the Pi is out of scope.| Item | Why it is open |
|---|---|
| WebSocket audio latency | WebSocket audio (listen, microphone, automatic fallback with UDP blocked) is built and verified on the real Pi; its latency has not been measured |
| Meter calibration | S-meter, power and SWR are shown from raw values (SWR ratio is an estimate on the FT-991A); ALC has one calibration point |
| Audio latency | not measured |
| Antenna switching | the FT-991A has no CAT command for it |
| C4FM RX/TX DG-ID | not reachable over CAT (checked on the radio); only the WIRES DG-ID menu value is read |
| Writing memories from the page | deliberately not built: the program never changes a memory |
Automatic rollback test, restore.sh, uninstall.sh |
scripts exist; a full real-Pi run of each is still to be recorded |
| Real-device UI testing | tablets, iPhone/iPad and PWA install have had little testing |
| Colour themes / colour-blind mode | the dark theme is the only one |