Radio Remote - manual

Print this page to PDF for a paper copy.

Contents

  1. Installation guide
  2. User guide
  3. Operations
  4. Radio connection
  5. Remote audio
  6. FreeDV digital voice
  7. SSTV
  8. CW
  9. Radio menu
  10. JS8
  11. RTTY
  12. FT8 and FT4
  13. Logbook link
  14. Remote access with Tailscale
  15. Security and remote access
  16. Troubleshooting
  17. Other radios
  18. What each radio supports
  19. User interface notes
  20. Architecture
  21. Bench checklist
  22. Bench results
  23. Status and roadmap

Installation guide (Raspberry Pi, or a Debian PC)

Four ways to the same result:

Download the files from the latest release.

1. What you need

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)

2. Route A: the ready-made image

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.

  1. Open Raspberry Pi Imager 2.x. In its application options choose Content Repository, enter a custom repository and paste: https://github.com/dev-jmatias/YaesuFT991A-Remote/releases/latest/download/os-list.json
  2. Choose your Pi, then Radio Remote for Yaesu (Raspberry Pi OS Lite, 64-bit) in the OS list. (Do not use Use custom: that route shows no customisation screens.)
  3. Choose the SD card and fill in Imager's screens: hostname (for example radio), your user name and password, Wi-Fi if you do not use a cable, the time zone, and keep SSH enabled. Write the card.
  4. Put the card in the Pi, connect the radio, power on and wait about two minutes.
  5. Open 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.

3. Route B: the installer pack on Raspberry Pi OS

  1. In Raspberry Pi Imager choose Raspberry Pi OS (other) > Raspberry Pi OS Lite (64-bit) (Bookworm or newer: Python 3.11 or newer is needed; Lite avoids PipeWire/PulseAudio grabbing the radio's sound card). In the customisation screens set a hostname, a user and password, Wi-Fi if needed, and enable SSH. Write the card, boot the Pi and wait two minutes.
  2. From your PC connect and update the system: bash ssh <user>@<hostname>.local sudo apt update && sudo apt full-upgrade -y && sudo reboot
  3. Download radio-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.
  4. Copy the folder to the Pi. From a Windows PC (PowerShell, in the folder that holds radio-remote-installer): powershell scp -r radio-remote-installer <user>@<hostname>.local:~
  5. On the Pi: 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).
  6. It prints the address to open. Continue with section 5.

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.

4. Route C: from the source code

# 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.

4b. Route D: a Debian PC (not a Raspberry 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:

If the page does not open (Debian PC or any install)

5. First run (all routes)

  1. Browse to 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.
  2. The first visit asks you to create the administrator account: use your callsign as the user name and a password of at least 10 characters. Do this on your home network, not over a forwarded port.
  3. Admin > Config: choose the radio model and the serial port (auto finds an FT-991A), Save, restart the service when asked.
  4. Check the radio shows online and the frequency follows the VFO knob.
  5. Set the radio's own menus for USB audio and CAT: radio connection (FT-991A: CAT rate, USB audio levels, mic source).
  6. Transmitting is off. When you are ready (with a dummy load first), sign in as the administrator, open Admin > Config > Transmitting (PTT), choose Enable transmitting… and type your password again. It takes effect at once and is kept after a restart. (Alternatively edit /etc/radio-remote/config.toml, set allow_ptt = true under [safety], then sudo systemctl restart radio-remote.)

6. Remote access with Tailscale

Tailscale gives you a private way to the Pi from anywhere, with no router port opened.

  1. Install Tailscale on the Pi (the installer option --with-tailscale, or follow tailscale.com/download) and sign in once: sudo tailscale up.
  2. In the Tailscale admin console switch on MagicDNS and HTTPS Certificates.
  3. Run 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.

7. Updating, backup, removal

8. Manual install (without the installer)

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.

9. If something stops

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.

User guide

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.

1. First visit

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.

2. Who is in control

Many people can watch at once, but only one controls the radio. The top bar shows your state:

3. The screen

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).

Radio tab

Filters, Audio & Levels tabs

PTT stays at the bottom on every tab so you can adjust a level while transmitting.

4. Memory channels

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.

5. Transmitting

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:

6. Audio

Equalizer

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.

Noise gate (Receive and Transmit)

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.

Saved equalizer sets

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.

Transmit leveler and limiter

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.

Recorder (REC, Play, Send, Recordings)

A Recorder block under Mode on the Radio tab has four buttons of one size:

7. Remote access

8. Administrators

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.

9. If something is wrong

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

Operations: configuration, services, updating, backup

Where things live (after 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

Configuration

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

Services

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.

Updating

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.)

Backup and restore

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.

Forgot the admin password

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 on the Pi

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/.

Connecting the radio

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.

USB cable

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.

Radio menu settings (FT-991A)

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.

Powering the radio on and off

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.

Config keys involved

[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.

Remote audio

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).

Using it

Safety design

What has been verified

On a real FT-991A with a Raspberry Pi 4:

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).

When WebRTC cannot connect: WebSocket audio

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).

Pi setup notes

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.

If there is no audio

  1. arecord -l / aplay -l: confirm the codec appears when the radio is on, and what its card ID is.
  2. 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.
  3. In the app: Listen. Check the RX meter and what you hear. Adjust audio.rx_gain_db.
  4. For transmit, test with a dummy load: enable Mic, speak, watch "Your mic" move; then hold PTT and watch "TX audio". Tune audio.tx_gain_db and the radio's data-out level until the ALC is just touching.
  5. The connection can drop and come back for a second when the browser renegotiates (reloading the page, toggling Listen, a network change): that is normal.

Receive volume too low

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 digital voice

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.

What you need

Using it

  1. Open the Digital tab and choose FreeDV in its switch (on the all-in-one desktop layout it is the card under Levels and Audio). Switch FreeDV on starts it; if the radio is not in USB or LSB it puts it in USB (LSB below 10 MHz), which is the FreeDV convention. The same button switches it off. The Radio mode box next to the FreeDV mode chooses the mode the radio is put in: Automatic (the default: LSB below 10 MHz, USB above, the FreeDV convention) or one of USB, LSB, DATA-U, DATA-L, FM, as on the other digital screens. It is remembered in this browser; picking a channel and the Tune buttons of the FreeDV Reporter list use it, and choosing it changes the radio's mode at once. Other stations are on USB/LSB: with a DATA mode or FM your signal may sit somewhere else in the audio than theirs (see the DATA mode section below), so keep Automatic unless you know why you want another.
  2. The tab shows the status (whether the modem is locked on a signal and its signal-to-noise ratio) and has the preset channels: one tap sets LSB or USB, the frequency and the FreeDV mode, and switches FreeDV on.
  3. Switch Listen on (speaker icon). When a FreeDV signal is locked you hear the decoded speech. Without a signal you hear nothing: the Pi passes no noise on. Tune carefully (the modem finds the signal within about 100 Hz); watch the status.
  4. Transmit: arm the microphone (microphone button, which also sets the radio's input to REAR), hold PTT (or tap TX LOCK to transmit hands-free and tap it again to stop) and talk. The Pi encodes your voice for as long as you hold PTT.

Switching FreeDV off passes the radio's audio through as before (it does not change the radio's mode back).

The tuning aid: the Pi finds the tuning for you

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):

Radio settings that matter for FreeDV

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 (the neural modes)

RADE V1

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.

Your callsign in the transmission (RADE V1)

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.

RADE V2

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.

When your transmission is unreadable at the other station

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.

FreeDV Reporter: be listed, and see who is on the air

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.

The channels

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.

Setting the transmit level (the level test)

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.

  1. Switch FreeDV on, put the radio in its FreeDV mode (DATA-U / DATA-L, or USB / LSB), and connect a dummy load (or use a free frequency). Set the radio's power to the level you want to use.
  2. On the FreeDV tab tick Level test. The PTT button now sends a steady 1500 Hz tone at the modem level (it stops by itself after 15 seconds; hold again for more).
  3. Hold PTT and raise Transmit level until the ALC just starts to move, then go down one step. The PO meter should be close to the radio's set power (the tone reads about 2 dB higher than the voice signal, because a steady tone has less peak than the modem's signal for the same power).
  4. Untick Level test. Do not leave it on: the PTT button would keep sending the tone.

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.

Notes and limits

How much CPU does it need?

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.

If it does not work

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

SSTV (slow-scan television)

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.

Modes

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).

Receiving

  1. Open the screen. The radio is put on the frequency and mode of your last visit (the first time, 20 m 14.230 in data-USB) and reception is switched on. Choose a preset from the Band list or tune freely (see Tuning below). The presets are the usual calling frequencies: 80 m 3.845 LSB, 40 m 7.171 LSB, 20 m 14.230, 15 m 21.340, 10 m 28.680 USB, and the ISS SSTV events on 2 m 145.800 FM and 70 cm 437.550 FM.
  2. When a header is heard the picture area starts and fills line by line, with the mode, the line count and the time left. A finished picture is saved on the Pi and appears under Received pictures: tap it to see it large, download it as a PNG, or delete it. A picture that is cut short is kept as partial when it got at least 10 % of the way.
  3. Leaving the screen switches reception off and puts the radio back on the mode and frequency it had; Clear only empties the picture area. The next visit returns to the frequency, mode and step you used last.

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).

Tuning

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 SSTV events (437.550 MHz FM)

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

  1. Press Choose a picture and pick a photo or drawing from the device (any format the browser can read). Choose the mode; the preview shows exactly what will be sent, at the mode's size. Fit decides whether the picture fills the frame (and is cropped) or is shown whole with bars.
  2. My callsign prints your callsign on the picture (the callsign set under FT settings in the FT8/FT4 screen), and Message adds a line of text (a report, your name, "CQ SSTV"). Text at puts them at the bottom or the top.
  3. Send picture asks for confirmation and sends the picture to the Pi, which transmits it. A bar shows the progress; Halt TX stops it at once.

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.

Sound

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.

If no picture appears

CW (Morse)

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.

Using it

  1. Open Digital and press CW. The radio is put in a CW mode (CW-U, or the last CW mode and frequency you used on this screen) and decoding starts.
  2. Pick a band, type a frequency (MHz, kHz or Hz), or step with - and +. Use the radio's own CW filter (narrow, 250-500 Hz) for the best result.
  3. The decoded text appears in the Received box. Copy copies it; Clear empties it for everyone on this radio.

Leaving the screen switches decoding off and puts back the mode and frequency the radio had.

What you see

Sending

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:

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.

Logging contacts

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.

How good is it?

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 radio's menu

The Menu tab shows the radio's own menu and lets you change it from the browser: every item the radio's CAT manual lists. It is the menu you reach with the MENU key on the radio, with the same names. The FT-991A has 153 items numbered 001 to 153 as on the radio; the FTDX10 (196 items), FTDX101D and FTDX101MP (192) and FT-710 (290) have sections, groups and items, shown as numbers like 01-01-04 (section, group, item) and chosen in the Group list by section.

Using it

Items that cannot be changed here

A few items are shown but read only, because changing them could cut the app's link with the radio or change how the radio is keyed, which the app relies on:

On the FT-991A these are 027 to 033, 047, 048, 060, 071, 072, 076, 077, 087, 096, 101, 108 and 109.

If you really need one of them, change it on the radio itself.

Backup and restore

Back up menu reads the whole menu and saves it as a file (menu-ft991a-DATE.json). Restore... (administrators) writes such a file back: only the items that differ are written, the items that cannot be changed here are skipped, and a report lists what was changed. Keep a backup before trying new settings: the radio itself can only do this with an SD card.

Every change, backup and restore is written to the audit log (Admin) with the old and new value and who made it. Changes are refused while the radio is transmitting.

About the table

The item numbers, names and allowed values come from the radio's CAT Operation Reference Book (the EX MENU table). The groups are ours. Where the manual has a mistake (for example the RTTY shift codes, or the digit counts of 068 and 069) the table follows what the radio does; if the radio ever answers with something the table does not expect, the item shows the raw answer with a ? and is not edited. This is new: it has been tested against a simulated radio, not yet on a real one.

The tables of the FTDX10, FTDX101D/MP and FT-710 were built the same way from their CAT manuals (the FTDX101MP uses the FTDX101D's table). The addresses of the items the app already uses (the mic source, the rear port select, the out levels) agree with what was read on a real FTDX101D. None of these four radios has been tried with the Menu tab yet.

JS8

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.

How it works (and why it needs an install)

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:

First time (administrator)

  1. Open Digital, then JS8. A box says what is missing.
  2. Prepare the system (once): installs a few packages (the libraries JS8Call's toolkit needs even without a screen, and PulseAudio, started only privately for JS8Call) and loads the loopback sound card at every boot. It runs as root through a small helper, takes a few minutes, and its output is shown in the box.
  3. Install JS8Call (once): downloads it (about 60 MB) from its release page and unpacks it into the Pi's data folder.
  4. Set your callsign and grid in the FT settings of the FT8/FT4 screen (JS8 uses the same ones).

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.

Using it

Transmit protection

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.

Limits

RTTY (radio teletype)

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.

Using it

  1. Open Digital and press RTTY. The radio is put in USB (or the mode and frequency you last used on this screen) and decoding starts. Leaving puts back what the radio had.
  2. Pick a band (the usual calling frequencies), type a frequency, or step with - and +.
  3. The spectrum shows the audio from 300 to 3000 Hz. The decoder looks for two steady tones one shift apart: the green line is the mark tone and the red line the space tone. Auto keeps them tuned by itself; untick it to type the lower tone in Hz, or click the spectrum to put the lower tone there.
  4. The decoded text is in the Received box. Double-click a callsign in the text to fill the Call box of the log below.

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.

Sending

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.

Logging contacts

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.

How good is it?

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.

FT8 and FT4

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.

Time

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):

Before the first use

  1. The Pi's clock must be right. FT8 and FT4 are timed to the second: a clock that is more than about a second off decodes nothing and transmits in the wrong place. A Pi with internet access keeps its clock by itself; the tab shows Clock not synchronised when the Pi says it does not.
  2. Set your callsign and grid square (FT settings at the bottom of the tab; administrators only).
  3. The radio must be in a data mode (DATA-USB: DATA-U on the FT-991A). Choosing a band in the tab sets it. The radio's USB audio and data-port menus must be set as described in Radio connection (an FT-991A needs the data port menus on USB).
  4. Switch the speech processor off and keep the radio's ALC at zero when you transmit: FT8 is a constant-envelope signal and compression only spreads it.

The screen

Calling and answering

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.

  1. Press Enable TX. Nothing is transmitted until it is on, and it switches itself off when the page is reloaded.
  2. Call CQ sends 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
  1. The contact is logged when it is complete for you (you send RR73 or 73, or the last message arrives). Halt TX stops everything at once and switches Enable TX off.

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.

Transmit safety

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.

The log, ADIF and uploading

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.

What it does not do (yet)

If nothing is decoded

Logbook link (Hamlib rigctl)

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.

What it can and cannot do

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.

Switch it on

  1. Sign in as an administrator, Admin > Config > Logbook link.
  2. Tick Switch the logbook link on and press Apply. It starts at once (no restart).
  3. The card now says Listening on port 4532 and shows which computers are connected.

Settings in the card:

Never forward port 4532 on your router. Anyone who could reach it could retune the radio. Keep it for the home network (or Tailscale).

Log4OM

  1. In Log4OM open Settings > Program Configuration > Hardware Configuration and add a new radio / CAT interface.
  2. Choose Hamlib as the interface and, as the radio model, NET rigctl (Hamlib model 2, sometimes listed as "Hamlib NET rigctl").
  3. As the port / address enter the Pi's name or address and the port: 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.)
  4. Save, then connect. The frequency and mode in Log4OM should now follow the radio; clicking a spot should retune it.

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".

From the command line (to check it)

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

If it does not work

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.

Remote access with Tailscale

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.

Setup

  1. Install Tailscale on the Pi (the installer can do it: --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.
  2. Install Tailscale on your phone/tablet/PC and sign in to the same account.
  3. In the Tailscale admin console (login.tailscale.com/admin/dns) switch on MagicDNS and HTTPS Certificates.
  4. On the Pi run the setup script:

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.

Audio over Tailscale

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).

Hardening checklist

Security and remote operation (Phase 8)

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.

Who can do what

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

Control lease (many watch, one controls)

What is enforced where

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:

Network layout

Internet  X  (never port-forward this)
LAN / Tailscale ---> Caddy :443 (TLS) ---> app 127.0.0.1:8080 ---> radio (USB)

Recipe A - LAN with Caddy's internal CA

  1. sudo apt install caddy, copy config/Caddyfile.example to /etc/caddy/Caddyfile (Option A), sudo systemctl reload caddy.
  2. Each phone/tablet/PC must trust Caddy's root certificate once. The root is at /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.
  3. Browse to https://raspberrypi.local.

Recipe B - Tailscale (preferred for remote use)

  1. Install Tailscale on the Pi and on your devices, sudo tailscale up. Enable MagicDNS and HTTPS certificates in the admin console.
  2. Run 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.
  3. Keep application logins on. Tailscale is the outer wall, not a replacement for accounts.
  4. WebRTC audio uses UDP; over a direct tailnet path it is fine, over a relayed (DERP) path latency rises. The UI will tell you if audio cannot connect; a WebSocket-audio fallback is not built yet.
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).

Operating notes

What has been tested

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.

Troubleshooting

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.

Radio not detected ("radio offline", "no FT-991A found on any USB serial port")

  1. lsusb: is there a Silicon Labs CP210x entry? No: cable, radio power, try another Pi USB port, avoid unpowered hubs.
  2. ls /dev/serial/by-id/: do ports exist? No: kernel did not create them (check dmesg | tail).
  3. Is the radio on (not standby)? CAT does not answer in standby.
  4. radio.model must be ft991a, not mock (Admin > Config).
  5. Detection tries 4800/9600/19200/38400. If it still fails, the radio's menu 031 CAT RATE may not match any, or another program holds the port.

CAT not responding / timeouts

Wrong serial device

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.

USB audio missing

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/*).

No RX audio

No TX audio

PTT does not work

  1. The button says "PTT DISABLED IN CONFIG": an administrator enables it in Admin > Config > Transmitting (PTT) (password required), or sets allow_ptt = true in /etc/radio-remote/config.toml and restarts.
  2. "Request control first" / "X has control": take control (control bar).
  3. Radio offline, or "radio did not key": check CAT first.
  4. Released too soon: PTT is hold-to-talk; it un-keys if the page loses focus, the tab is hidden, the network drops (heartbeat 1 s), or after safety.tx_timeout_s.
  5. The radio's own menu 036 TX TOT may also be cutting you off.

CAT reconnect problems

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").

Browser cannot connect

Tailscale connectivity

Service will not start / keeps restarting

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).

Start over

sudo scripts/uninstall.sh (keeps data) then sudo ./install.sh; or --purge for a completely clean slate.

Mic armed but "To radio: 0 frames"; log says "Unknown PCM 6"

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.

Other radios: FTDX101D (tested), FTDX101MP (same commands), FTDX10, FT-710

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).

FTDX101D: what was verified on a real radio

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:

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

Not tried yet: transmitting on the SUB receiver on the air, the DATA / RTTY / CW audio-source menus with a signal, and the MP.

How a model is selected

radio.model (Admin > Config > Radio > Model, restart needed) picks, for that model:

  1. its capability file (radio/capabilities/<model>.toml): which functions the UI offers (only true entries; "unverified" is hidden);
  2. its protocol object (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.

What is the same as the FT-991A and what is not (from the manuals)

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

What is deliberately NOT offered yet ("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.

Practical notes

First contact checklist (do it in this order, report each step)

  1. Stop the service, run the read-only script and keep the output: 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.
  2. Start the service with radio.model set, check the banner, the frequency, mode, band and the S-meter.
  3. Change frequency, mode, band; switch NB / DNR / notch / contour; try the AGC, ATT and IF-shift controls.
  4. Into a dummy load: PTT (a short key-up), meters (power, SWR, ALC), mic source REAR / MIC.
  5. Only then consider TUNE, the clarifier, split and the speech processor.

Every function confirmed this way should be written into docs/bench-results.md and the capability toml flipped to bench_tested = true.

What each radio supports

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

Functions

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 - - - -

Modes, bands and frequency range

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;

Levels

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

Not available on any of them (over CAT)

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.

UI

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

Layouts

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:

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).

Tuning behaviour

Honest limits

PWA

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).

Changes after the first field tests

Architecture

As built (version 1.1)

The design below was written before the program existed. This is how it turned out; where the two differ, this section is right.


Original design (kept for the reasoning behind the decisions)

Written as a proposal; the [DECISION] marks were the points confirmed before building.

1. System overview

 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.

2. Backend technology [DECISION]

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".

3. Frontend technology [DECISION]

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/.

4. CAT / Hamlib architecture [DECISION]

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/):

5. Audio architecture [DECISION]

5.1 Transport

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.

5.2 Local capture/playback

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.

5.3 Levels and echo/AGC

5.4 Important constraint: HTTPS is required for the microphone

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:

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.

5.5 WebRTC and Tailscale

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.

6. Real-time protocol

Single WebSocket /ws (JSON; msgpack optional later).

7. Radio State Engine

A single server-side RadioState (dataclass → dict). Sources: driver events (AI push, poll answers). Sinks: WS hub. Rules:

8. Capability model

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.

9. TX safety (server-side, authoritative)

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.

10. Multi-client control

11. Security

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.

12. Configuration & storage

13. Deployment

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).

14. Raspberry Pi 3B+ budget

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

15. Logging & diagnostics

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).

16. Testing strategy

17. Proposed project structure

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/

18. Roadmap (each phase ends runnable)

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

19. What I need from you

  1. Approve or change the decisions: Python backend, no-build Preact frontend, native-CAT-first with optional Hamlib, aiortc (with spike), systemd + Caddy HTTPS.
  2. Provide the FTDX101D/MP, FTDX10 and FT-710 CAT manuals (and the FT-991A operating manual) so those drivers can be verified. Until then I'll build FT-991A + mock only.
  3. Confirm you have an FT-991A + Pi available for the bench tests in Phases 4–6 (power-ON via PS1;, audio-route menus, meter calibration). I will give a checklist; PTT stays disabled until you say otherwise.

Bench checklist (FT-991A + Raspberry Pi 4)

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.

Before connecting

  1. Radio menu 031 CAT RATE: note the value (default 4800). Recommended: 38400 on both ends.
  2. Radio menus 071 DATA PTT SELECT, 076 FM PKT PTT SELECT, 108 SSB PTT SELECT, 047 AM PTT SELECT: note them. Our software holds DTR/RTS low when opening the port, but if any of these is set to RTS/DTR, a different program opening the port could key the radio. Prefer DAKY.
  3. Menu 036 TX TOT: set to a non-zero value (for example 3 min) as a radio-side backstop for later phases.
  4. USB-connect the radio, power it on. On the Pi: ls /dev/serial/by-id/ and lsusb (expect Silicon Labs CP210x, two serial ports).

Bench 1 - read-only

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.

Bench 2 - app against the real radio, RX only

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.

Bench 2b - Phase 5 controls (RX only, PTT disabled, dummy load)

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.

Bench 2c - antenna tuner and C4FM DG-ID (done; the diagnosis method is kept)

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).

Later benches (not yet implemented)

Bench results (real FT-991A)

Summary (as of version 1.1.9, 2026-10-05)

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).

Bench 1 - read-only: PASSED

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.

Bench 2 - app against the real radio, receive only, PTT disabled: PASSED (operator report)

Antenna tuner on/off (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.

VFO operations, tuner, DG-ID: operator report after the first VFO B build

Installer and updater on a real Pi: PASSED

Bench finding: manual vs radio, speech processor (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.

Receive audio: PASSED (operator report)

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?).

Audio: device detection and CPU

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.

FTDX101D (real radio, 2026-10-05)

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.

Status and roadmap

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.

Done and verified on a real FT-991A (Raspberry Pi 4)

Done, but only against the simulator

Built, not yet released (simulator and real Hamlib rigctl only)

Open

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