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.