Direct network control and IQ streaming for the SunSDR2 PRO (no ExpertSDR3)
Project description
solsdr
A pure-Python software-defined radio for the Expert Electronics SunSDR2 PRO — no ExpertSDR3 required.
solsdr is a lightweight replacement for ExpertSDR2/3 that talks directly to a
SunSDR2 PRO over the network. It wakes and powers on the radio, tunes it, streams
IQ, demodulates to audio, and (with the transmit chain) modulates audio back to
IQ. It's built for experimentation:
- GNU Radio — consume the radio as raw complex64 IQ over TCP, or as demodulated audio, and drive it from your own flowgraphs.
- Digital modes — run JS8Call, WSJT-X, or fldigi against the radio through virtual audio devices and a Hamlib-compatible control port, with no vendor software in the loop.
- Scripting / DSP — a clean Python API (
Radio,Demodulator,Modulator,TXSession) for building your own receivers, transmitters, and measurements.
The core use case is digital modes (JS8Call, WSJT‑X, fldigi) and software‑driven IQ/DSP — that's what solsdr is built and most heavily used for. But voice / SSB phone is also a first‑class transmit mode, from a PC mic or the radio's own front‑panel Mic1/Mic2 jacks, keyed by a footswitch, spacebar, software, or CAT — all hardware‑verified on‑air (see "Voice / SSB").
Design: solsdr is engine‑first, GUI‑optional. The core is a headless,
"front‑panel‑less" command‑line engine meant to be driven by other software
over the network — Hamlib (rigctld), JS8Call, WSJT‑X, fldigi, GNU Radio, or
your own scripts — with an interactive sdr> shell for hands‑on control. It
ships no built‑in GUI in the core, but a standalone panadapter client
(clients/panadapter.py, PyQt/pyqtgraph) gives you a live spectrum + waterfall
and a full click‑to‑tune control panel when you want one — it just talks to the
same network interfaces (RX IQ server + control API) any other client would. So:
run it fully headless as an engine, drive it from digital‑mode software, operate
it by hand at the shell, or add the graphical panadapter — your choice.
Author: Jeff Francis, N0GQ.
⚠️ Maturity — beta core, alpha edges
Maturity varies by feature. RX1 + TX (CW, digital, and voice/SSB) are beta‑quality — hardware‑verified on‑air and exercised heavily. RX2 (second receiver / dual‑watch) is still alpha — it functions, but not all of the plumbing around it is complete, so expect rough edges there. VHF, the DX variant, and antenna switching are incomplete/unverified. The project is young; testing, refinement, enhancement, and documentation are ongoing. Always validate TX into a dummy load before going on the air.
Model support
The SunSDR2 PRO is hardware‑verified. A SunSDR2 DX profile is fully implemented but UNVERIFIED. The DX differs from the PRO in several critical details (magic byte
0x32, 312.5 kHz native rate, 92.5 kHz DDC offset, RX+TX bidirectional on a single port50002, and a distinct power‑on sequence). All of these values are populated insolsdr/protocol/profiles.pyfrom the ArtemisSDR reference — select the DX with--variant DX— but this project has never run against a real DX. The code prints an "UNVERIFIED" warning when a DX profile is used, and may not work. DX owners: please try it and report results so the profile can be confirmed (or corrected).
Credits — protocol reverse engineering
The SunSDR2 network protocol is not publicly documented by the manufacturer.
Everything this project does is possible only because of the reverse‑engineering
work in ArtemisSDR by K0KOZ, whose
ChannelMaster/sunsdr.c implementation was the reference for the discovery,
power‑on, control, tuning, and IQ‑framing formats. Full credit and thanks to
K0KOZ and the ArtemisSDR project — without their work, none of this would exist.
A note on the relationship between the two projects, offered in the same spirit
of sharing findings: the ArtemisSDR authors state they had only a SunSDR2 DX
to test with, so several of their PRO‑specific constants are extrapolated from
the DX. Where this project found the PRO to differ (verified on real PRO
hardware), it uses corrected values, and it has decoded some SunSDR2 features
ArtemisSDR doesn't implement. Those PRO findings — corrected constants, new
opcodes, and a likely fix for ArtemisSDR issue #47 — are collected in
ARTEMISSDR.md, contributed back to that project.
Status
Receive: working and hardware‑verified. Live FT8 decoded off‑air (validated
with WSJT‑X's jt9), WWV AM confirmed, CW demod/decode validated end‑to‑end,
and live JS8Call decoding through the virtual‑audio bridge.
Transmit: working — real RF verified on all HF bands. The complete audio→IQ→paced‑wire chain, PTT/drive/PA control, timing, and safety interlocks are implemented, calibrated into a dummy load (6–17 W across HF), and driven on‑air by JS8Call through the audio bridge. See Transmit below.
HF focus: development and testing so far have concentrated on HF. The PRO's VHF paths (2 m, the VHF LNA, VHF‑specific antenna/TX routing) are largely untested here and some code carries HF‑only assumptions. VHF was deliberately never keyed during TX calibration (no load on that port). Treat VHF operation as unverified for now.
| Capability | State |
|---|---|
| Broadcast wake / discovery | ✅ verified (byte‑identical to ExpertSDR3's probe) |
| Power‑on (verified PRO init sequence) | ✅ verified |
| Tuning, live retune (no restart) | ✅ verified |
| RX IQ streaming @ 39062.5 Hz + bidirectional keepalive | ✅ verified |
| Selectable IQ rate 39062.5 / 78125 / 156250 / 312500 Hz | ✅ verified (FT8 decoded at 312.5 kHz) |
| RX2 second receiver (independent freq/mode; dual-watch) | ⚠️ alpha — functions (interleaved on one stream, per-receiver demod/IQ; --rx2 <kHz>) but the surrounding plumbing is incomplete; rougher than RX1/TX |
| RX1↔RX2 phase coherence (shared antenna) | ✅ measured γ²≈0.999 — coherent dual-channel (DF/beamforming viable; see below) |
| External 10 MHz reference (GPSDO) on/off | ✅ opcode verified (bytes identical to ExpertSDR3) |
| Supply telemetry: voltage / current / temperature / forward‑power | ✅ 0x1F fully decoded; shown in status (V/A, °F). Includes a forward‑power field (fwd_power_raw). No SWR is reported by the radio. Temperature is read‑only — the radio runs its own fan in firmware (no host setpoint) |
| Front-end: HF.LPF, VHF.LNA toggles | ✅ opcodes verified (relay‑confirmed); lpf/lna shell cmds |
| Mic source (Mic1/Mic2/PC) | ✅ verified (0x21); mic shell cmd. Mic gain is client‑side (no radio cmd) |
| Voice / SSB phone (pc / front‑panel mic1 / mic2 sources) | ✅ hardware‑verified on‑air. First‑class TX source with per‑source calibrated gain + comms voice shaping. See "Voice / SSB" below. |
| Front‑panel mic (Mic1/Mic2 jack) → on‑air voice | ✅ hardware‑verified 2026‑07‑10. tx source mic1/mic2: the radio digitizes the mic and streams it back as 0xFD; solsdr modulates it. See ARTEMISSDR.md §7. |
| Radio's external/hardware PTT input (footswitch) | ✅ hardware‑verified 2026‑07‑10. tx hwptt on: the radio's 0x1F edge packet keys/unkeys TX. Footswitch + hand‑mic PTT both work. See ARTEMISSDR.md §8. |
| Per‑source mic calibration + comms voice shaping | ✅ tx cal (measure → gain), tx shape flat/comms/dx (band‑limit + speech compression), saved per source. |
| Adjustable voice‑to‑RF latency | ✅ tx latency <ms> (default 120), live + saved. |
| Software / spacebar PTT | ✅ key/unkey; voice = hold‑SPACE push‑to‑talk or tap‑ENTER latched. |
| USB / LSB / AM / FM / CW demodulation + S‑meter | ✅ verified |
| CW receive: BFO demod + Morse decoder | ✅ verified (synthetic full‑chain) |
| Automatic reconnection on network loss | ✅ verified (simulated interruption) |
CAT control via real Hamlib rigctld (mirrored to the radio) |
✅ verified with JS8Call/WSJT‑X/rigctl |
| Raw‑IQ TCP server + text control API (GNU Radio client) | ✅ verified |
| Raw‑IQ TX server (GNU Radio → radio; complex64 in, transmitted verbatim) | ✅ hardware‑verified (2026‑07‑08, RF into dummy load) — --iq-tx-server (:5558); disarmed by default, --tx-arm to key |
| Virtual‑audio bridge for JS8Call / WSJT‑X / fldigi | ✅ RX decoding + TX on‑air verified |
| Stateful DSP filters (NR / NB / notch / APF / squelch) | ✅ unit‑tested |
| Transmit chain (SSB/AM/FM modulate, pacing, PTT/drive/PA) | ✅ on‑air verified into dummy load |
| Per‑band TX power calibration + amp‑protection limit | ✅ calibrated on all 10 HF bands (wattmeter‑anchored); ~6 W (160 m) to ~17 W (10 m) |
| Unified transceiver: one program, RX + in-process TX bridge, live shell control | ✅ shell keys via tune / cw <text>; tx sets power/mode/mic gain/wpm live |
| CW transmit (keyboard sending, Farnsworth) | ✅ cw <text> from the shell; tx wpm <char> [word], tx cwtone <Hz> |
| Antenna port selection | ⛔ not implemented — PRO opcode not yet identified |
| SunSDR2 DX support | ⚠️ fully coded from ArtemisSDR, untested on hardware (--variant DX) |
Known gaps / TODO
- Audio output routing — the mic source selector (
0x21) is decoded, but the radio's front‑panel audio output routing (e.g. Phones on the front) has not been captured or identified. Needs an EESDR3 capture of the output‑routing controls. - GPSDO lock/sync status — the external‑reference select bit (
0x1D) is decoded, but whether the radio reports 10 MHz lock isn't known. Capture the radio with the reference present vs. absent and diff for a status bit/packet. - Antenna port selection — PRO opcode not yet identified (ArtemisSDR's
0x15mapping doesn't apply to the PRO). - TX frequency coverage — RESOLVED (2026‑07‑08): the PRO transmits out of band. Keyed at 13000 kHz (non‑ham) into a dummy load, it made RF with no firmware band lock and no tune refusal. Note off‑band forward power reads lower for the same DC draw (no band‑specific output match), and no band there is calibrated — so use raw drive, not a watts setpoint, off the ham bands. TX responsibly and legally.
- VHF — largely untested; deliberately never keyed during TX calibration.
- SunSDR2 DX — profile is coded from ArtemisSDR but unverified on hardware.
- CW receive decode → shell — the RX Morse decoder exists but decoded text
isn't yet surfaced in the transceiver shell (planned). CW transmit is done
(
cw <text>).
Expected features not yet built (a user would reasonably want these):
- Access control /
--bind— the RX IQ server binds all interfaces with no authentication, and the control + IQ‑TX servers have no auth (they default to loopback). Since solsdr can transmit — including out of band — exposing these on a network without a token check or a deliberate bind address is a real risk. This is the top item to address before running solsdr exposed. - Band‑plan / band‑edge awareness — solsdr tunes and keys out of band silently; expect at least a warning outside amateur allocations.
- Spectrum / panadapter feed — solsdr serves raw IQ, but not a ready band‑scope (periodic FFT bins) that a thin remote display could consume without its own DSP. The most on‑brand missing feature for a no‑GUI SDR.
- Split / VFO‑A‑B transmit — RX2 is dual‑watch only; no "TX here, RX there".
- Memory channels / presets — the config file holds one default; no stored recallable list of favorite frequencies/modes.
- Scanning — no scan‑a‑range or scan‑memories‑stop‑on‑signal.
- Decoder output over the network — the CW decoder prints to the shell but isn't exposed on the control API for remote clients.
- Built‑in recording — one‑shot IQ capture exists as a tool, but there's no scheduled / triggered / rotating‑buffer recording as a server capability.
Install
# Option A — install the package (gives you the `solsdr` command):
pip install . # from a clone; or: pip install solsdr
# Option B — run from a source checkout without installing:
pip install -r requirements.txt # numpy, scipy, sounddevice
python3 -m solsdr 14074 # run it this way from source
Installed, the command is solsdr (alias solsdr-shell); from a source
checkout, python3 -m solsdr. rigctld (from hamlib / libhamlib-utils)
and PulseAudio/PipeWire are required for the TX/digital-mode bridge (it warns and
runs RX-only if they're absent). For an always-on headless setup, see
systemd/README.md.
Quick start
# Tune 20 m FT8, live audio, type commands at the sdr> prompt.
# One program = full transceiver (RX + TX bridge in-process). --no-tx for RX-only.
solsdr 14074 --device 5 # if installed (pip install .)
python3 -m solsdr 14074 --device 5 # from a source checkout
--device N selects the audio output (list with
python3 -c "import sounddevice as sd; print(sd.query_devices())"). On a
PipeWire/PulseAudio desktop use the pipewire/pulse device, not a raw
hw: ALSA device.
Configuration file
Rather than retype --radio-ip, --local-ip, --device, etc. on every run,
put station defaults in ~/.config/solsdr/config.* (searched in this order:
config.json, config.conf, config.ini, config.cfg). Keys mirror the CLI
flag names (argparse dest), so --local-ip → local_ip, --radio-ip →
radio_ip, and so on. Precedence: CLI flag > config file > built‑in default,
so a flag always wins over the file.
JSON (config.json) — a minimal real‑world example (the author's station file):
{
"freq_khz": 14074.0,
"local_ip": "10.1.2.185",
"mode": "USB",
"radio_ip": "10.1.2.3",
"tx_latency_ms": 120.0,
"tx_mode": "USB"
}
write-config in the shell snapshots all current live settings into this
file — after you calibrate mics and pick shapes, it grows to include the
per‑source voice profiles, TX source, power, etc. A fuller flat key = value
config (config.conf; values auto‑typed int/float/bool, # starts a comment):
# ~/.config/solsdr/config.conf
radio_ip = 10.1.2.3 # omit to broadcast‑discover the radio
local_ip = 10.1.2.185 # omit to bind all interfaces
device = 5
variant = PRO
freq_khz = 14074
mode = USB
rate = 39062.5
ext_ref = true
log_level = info
# --- transmit / voice ---
tx_mode = USB
tx_source = pc # pc | mic1 | mic2
tx_hwptt = false # key from the radio's external PTT input
tx_latency_ms = 120 # voice->RF pre-buffer (lower = less delay)
tx_watts = 5
max_power_watts = 10
# per-source voice profiles (written by `tx cal` / `tx shape` / `write-config`)
tx_src_pc_gain = 2.3
tx_src_pc_shape = comms # flat | comms | dx
tx_src_pc_cal = true
tx_src_mic2_shape = flat
With a config in place, solsdr (no args) tunes 20 m USB on the right radio; add
a frequency to override just that: solsdr 7074. Point at a different file with
--config /etc/solsdr/config.conf (handy for the systemd service). Unknown keys
are ignored with a warning, so a shared config can carry extra keys.
Interactive shell — commands with examples
solsdr is one program for both RX and TX. By default it brings the
digital‑mode/TX bridge up in‑process (virtual audio + a real Hamlib rigctld +
PTT→transmit), sharing the one radio connection with the receiver. So the single
sdr> shell controls the whole radio — receive, DSP, front‑end, and transmit
characteristics — with TX changes applied live (e.g. tx power 3 retunes the
drive on an in‑progress over). --no-tx reverts to a lean RX‑only program. Type
help at the prompt for the full list. Bare commands act on RX1; prefix with
2 to target the second receiver (see RX2).
| Command | Example | Effect |
|---|---|---|
<kHz> |
7074 |
Tune to 7074 kHz |
m <mode> |
m CW |
Set mode: USB / LSB / AM / FM / CW / CWU / CWL |
cw on|off |
cw on |
Toggle the live Morse decoder |
cw pitch|bw <Hz> |
cw pitch 700 |
CW beat‑note pitch / filter bandwidth |
agc <mode> |
agc off |
RX audio AGC: auto/on/off/fixed:<gain> |
gain <n> / vol <n> |
gain 8000 |
RX audio output level (fixed gain, AGC off) |
tx |
tx |
Show all TX settings (live) |
tx power <W> |
tx power 3 |
TX output setpoint — applies live while keyed |
tx maxpower <W> |
tx maxpower 5 |
Amp‑protection ceiling (safety; next over only) |
tx mode <m> |
tx mode USB |
TX modulation mode |
tx source <s> |
tx source mic2 |
Voice TX audio source: pc (the ‑tx sink) / mic1 / mic2 (front‑panel mic). Each keeps its own gain + shape. |
tx hwptt on|off |
tx hwptt on |
Key TX from the radio's external/footswitch PTT input |
tx cal [s] |
tx cal |
⚠️ Calibrate the current source's mic gain (talk normally s s; mic1/mic2 key the radio to measure). Saved per source. |
tx shape [src] <p> |
tx shape pc comms |
Voice shaping preset: flat/comms/dx (band‑limit + speech compression). Per source. |
tx gain [src] <x> |
tx gain pc 2.3 |
Manually set a source's input gain (marks it calibrated) |
tx micgain <x> |
tx micgain 1.5 |
Extra TX‑audio gain, all sources — applies live |
tx latency <ms> |
tx latency 60 |
Voice→RF pre‑buffer (lower = less delay hearing yourself; default 120). Live + saved. |
tx wpm <c> [w] |
tx wpm 25 15 |
CW send speed — element c wpm, optional Farnsworth spacing w wpm |
tx cwtone <Hz> |
tx cwtone 700 |
CW send sidetone/pitch (default 600) |
tx prefix <name> |
tx prefix myrig |
Rename the virtual audio devices → <name>-rx.monitor (fldigi/WSJT‑X input) + <name>-tx (output). Also settable at launch with --prefix. |
key / unkey |
key |
⚠️ Software PTT: key / unkey TX with the current tx source (aliases ptt/unptt) |
voice |
voice |
⚠️ Push‑to‑talk console: HOLD SPACE to talk, tap ENTER to latch on/off, q/Esc exits |
cw <text> |
cw cq de n0gq |
⚠️ Transmit text as Morse |
tune [s] [W] |
tune 5 3 |
⚠️ Key a CW tuning carrier for s sec (default 3) at W watts (default current power). |
s |
s |
Print S‑meter + full status line |
ref ext|int |
ref ext |
External 10 MHz (GPSDO) vs internal reference |
lpf on|off |
lpf on |
HF low‑pass filter relay |
lna on|off |
lna on |
VHF LNA |
preamp <state> |
preamp -10 |
RX preamp/att: -20 -10 0 +10 dB, or off/preamp |
mic <src> |
mic pc |
Mic source at the radio: mic1 / mic2 / pc |
rit <Hz> |
rit 250 |
Receiver incremental tuning (rit 0 = off) |
nr <0-1> |
nr 0.4 |
Noise reduction strength |
nb <0-1> |
nb 0.3 |
Noise blanker |
notch <Hz> |
notch 800 |
Manual notch (notch 0 = off) |
apf <0-1> |
apf 0.6 |
Audio peak filter (CW) |
sql <0-1> |
sql 0.2 |
Squelch threshold |
devices |
devices |
List audio devices (sounddevice + PulseAudio) |
read-config |
read-config |
Apply every setting in the config file, now |
write-config |
write-config |
Save all current live parameters to the config |
2 <cmd> |
2 m CW |
Run a per‑receiver command against RX2 (e.g. 2 7074) |
q |
q |
Quit |
TX settings take effect immediately (live). They're saved to the config file
with write-config and reloaded on startup — including per‑source mic gains,
voice shaping, the TX source, hardware‑PTT enable, and tx latency. To transmit
digital modes, point a digital‑mode app (JS8Call / WSJT‑X / fldigi) at the
bridge's virtual audio + rigctld; it's the same process as the receiver, so the
shell sees and controls the transmit state directly.
Pinning RX audio output (PipeWire). --device 5 is the pipewire device,
which follows the default sink — so plugging in a USB mic/headset can steal
your RX audio onto it. To pin RX to a fixed output regardless of hotplug, set
PULSE_SINK to a stable sink name (from pactl list short sinks), e.g.:
PULSE_SINK=alsa_output.pci-0000_00_1b.0.analog-stereo solsdr
CLI flags — common use cases
# Plain listening: 40 m LSB, audio out on device 5
solsdr 7185 --mode LSB --device 5
# Point at a specific radio / interface (overrides the config file)
solsdr 14074 --radio-ip 10.1.2.3 --local-ip 10.1.2.185
# The RX IQ server is ON BY DEFAULT (:5555) — GNU Radio / panadapter / recorders
# can connect straight away. Just tune:
solsdr 14074
# Expose CAT to WSJT-X / fldigi / JS8Call (real rigctld on :4532)
solsdr 14074 --hamlib
# Disable the RX IQ server if you need the port free / to reduce load
solsdr 14074 --no-iq-server
# Accept raw IQ from GNU Radio and TRANSMIT it (tcp :5558). No RF without --tx-arm.
solsdr 14074 --iq-tx-server # wiring test, no RF
solsdr 14074 --iq-tx-server --tx-arm --max-power-watts 5 --tx-watts 3
# IQ + control API are ON BY DEFAULT; here just add a wider 312.5 kHz IQ rate
solsdr 14074 --rate 312500
# (disable a default server if you must: --no-iq-server / --no-control-api / --no-tx)
# Dual-watch: RX1 20 m (audio + IQ :5555), RX2 40 m CW (IQ :5557)
solsdr 14074 --rx2 7025 --rx2-mode CW
# External 10 MHz reference (GPSDO) on / off
solsdr 14074 --ext-ref
solsdr 14074 --no-ext-ref
# Headless for a fixed time then exit (no prompt) — scripting/capture
solsdr 14074 --seconds 60
# Move the CAT or IQ ports
solsdr 14074 --hamlib --hamlib-port 4540 --iq-port 5560
# SunSDR2 DX (UNVERIFIED profile) and quieter logging
solsdr 14074 --variant DX --log-level warning
# Show version
solsdr --version
--iq-port sets RX1's IQ port; RX2's is always RX1+2 (default 5557). Full list:
solsdr --help.
Panadapter (live spectrum + waterfall)
clients/panadapter.py is a standalone, visually-nice panadapter that reads the
RX IQ stream (and, optionally, the control API for live radio state). Pure
Python — PyQt5/PyQt6/PySide6 + pyqtgraph + numpy — no GNU Radio, no ExpertSDR3.
Display only: it never tunes or keys the radio.
# on the radio host (RX IQ + control API are both on by default):
solsdr 14074
# then, anywhere that can reach it (needs a display; ssh -X for remote):
python3 clients/panadapter.py --host 127.0.0.1
# no radio? replay a recorded capture (loops for a hands-off demo):
python3 clients/panadapter.py --file clients/examples/solsdr_20m_demo30.iq
Features: FFT spectrum over a scrolling waterfall on a shared absolute-frequency
axis; frequency across the bottom (MHz) and level up the side (dBFS, or dBm via
--ref-offset); auto-scale or fixed ref/range; auto-adapts to solsdr's sample
rate + center and follows retunes; perceptual colormaps for strong signal/noise
contrast; live mouse crosshair readout (freq + level); an info bar with tuned
freq, mode, PTT, TX power, S-meter, span, and RBW; a draggable splitter to trade
spectrum vs. waterfall height; frequency zoom (+/−/Full toolbar buttons,
centered on the tuned freq); averaging, peak-hold, DC-spike hide, FFT size, and
freeze. Keys: +/- zoom (0 = full), A auto-scale, R rescale now,
P peak-hold, C cycle colormap, space freeze, Q quit. Run
python3 clients/panadapter.py --help for options.
Performance: it's tuned to hit 30 fps+ on a CPU-only box. The two costs that matter in software rendering are re-ranging the axis and an antialiased filled trace — so auto-scale recomputes the range only every few seconds (
--rescale SEC, default 5; pressRto snap now) and the trace is a thin non-antialiased line by default. On a GPU/fast machine,--prettyrestores a filled antialiased trace.
Absolute power: solsdr RX isn't power-calibrated, so the axis is dBFS by default. If you've measured your front-end offset,
--ref-offset <dB>relabels it as approximate dBm.
GNU Radio — IQ in and out
solsdr exposes the radio to GNU Radio (or any SDR tooling) two ways: raw IQ over TCP for receive, and audio into the digital‑mode bridge for transmit.
Receiving IQ (radio → GNU Radio)
The RX IQ server is on by default, so just tune and connect a TCP source:
solsdr 14074 --rate 312500 # complex64 IQ on tcp 0.0.0.0:5555 (on by default)
On the first connect, solsdr sends one newline‑terminated text header, then a
continuous stream of interleaved little‑endian float32 I/Q (i.e. GNU Radio
complex float 32):
SOLSDR IQ rate=312500.0 fmt=complex64 freq=14074000\n<raw complex64 samples…>
In GNU Radio Companion:
- Socket PDU or TCP Source — use a TCP Client Socket PDU, or the
blocks.socket_pdu/a TCP source block, pointed at<host>:5555, Type = Complex Float 32. - Set the flowgraph sample rate to match
--rate(39062.5 / 78125 / 156250 / 312500). solsdr announces it in the header line; read it once and hardcode the variable, or strip the header in a small Python block. - The IQ is baseband, centered on the tuned frequency; set your QT GUI Sink
center frequency to the tuned freq so the axis reads in absolute Hz. A ready
combined FFT+waterfall flowgraph is in
clients/gnuradio/qt_iq_waterfall.py; seeclients/README.md.
The header ends at the first \n; a client that doesn't care can skip those
bytes and treat the rest as pure complex64. RX2, if enabled, is a second
identical stream on :5557, so a flowgraph can pull both receivers for
coherent two‑channel work (see phase coherence).
Transmitting IQ from GNU Radio (GNU Radio → radio)
The raw‑IQ TX server is the transmit counterpart of the RX IQ server: it
accepts raw complex64 baseband IQ over TCP and streams it to the radio verbatim
(gain + clip only — no modulation, no resampling), so your flowgraph is the
modulator. Unlike the RX server it is opt‑in (--iq-tx-server) — transmit is
always a deliberate act.
# Raw-IQ TX. Off (no RF) unless you add --tx-arm. Always into a dummy load first.
solsdr 14074 --iq-tx-server # wiring test: runs, no RF
solsdr 14074 --iq-tx-server --tx-arm \
--max-power-watts 5 --tx-watts 3 # ARMED: keys on connect
Then in GNU Radio Companion, end your TX chain in a TCP Sink (or
blocks.socket_pdu as a TCP client), Type = Complex Float 32, pointed at
<host>:5558:
- Produce baseband IQ at the radio wire rate (39062.5 Hz by default — match
--rate). There is no resampler on this path, so a rate mismatch transmits at the wrong speed; the server announces the required rate in itsSOLSDR IQTX rate=… fmt=complex64header. Keep samples in[-1, 1](the server clips at ~0.98 to protect the 24‑bit packing). - The radio keys automatically while a client is connected and unkeys on disconnect (or after an idle gap). Only one transmitter at a time. No separate PTT step is needed — connecting is keying.
So a full baseband‑to‑RF SSB/data waveform you build in GNU Radio goes straight
out the antenna, and RX (:5555, on by default) + TX (--iq-tx-server, :5558)
give you a symmetric complex‑IQ pipe in and out of the radio.
Alternative — audio bridge: for JS8Call/WSJT‑X/fldigi (or any app that emits audio, not IQ), use the bridge instead, which runs the audio through solsdr's own SSB/AM/FM modulator and keys via CAT:
python3 -m solsdr.audio --radio 10.1.2.3 --local-ip 10.1.2.185 \
--freq 14074000 --tx-mode USB --max-power-watts 5 --tx-watts 3
# app TX audio -> solsdr-tx sink -> modulator -> radio; PTT = CAT
⚠️ Both TX paths obey the same safety interlocks (arming, amp‑protection watt
ceiling, calibration gating, dead‑man). The raw‑IQ server is disarmed by
default — it runs the whole chain with no RF until you pass --tx-arm.
Always key into a dummy load first. For pure IQ analysis GNU Radio only
needs the RX path above.
Second receiver (RX2 / dual-watch)
The PRO's second receiver runs alongside the first with an independent frequency and mode — e.g. watch 20 m and 40 m at once:
python3 -m solsdr 14074 --rx2 7074
# RX1 -> audio + IQ on :5555 | RX2 -> IQ on :5557 (both on by default)
# --rx2-mode CW / --rx2-device N set RX2's mode / audio output
Both receivers stream interleaved on one link (the radio tags each packet with
its receiver index); solsdr routes them to independent demodulators and per-
receiver IQ servers (RX1 :5555, RX2 :5557). In the interactive shell, prefix a
command with 2 to target RX2 (e.g. 2 7074, 2 m CW); bare commands act on
RX1. Both receivers share one sample rate (a hardware constraint).
RX2 is for dual-watch, not receive-through-transmit. Verified 2026-07-08:
while RX1 is keyed, neither receiver streams — the single UDP link on port
50002 carries the 0xFD TX frames in place of RX IQ, so both RX1 and RX2 packet
rates drop to under 1 % of normal for the key-down and resume the instant you
unkey. Fine for monitoring two frequencies between overs; not a way to hear RX2
during a transmission.
Phase coherence: fed from a single antenna, the two receivers are strongly
phase-coherent — measured γ² ≈ 0.999 at the signal (tools/rx2_coherence.py).
That makes coherent dual-channel work (direction finding, beamforming, two-
antenna noise cancelling) viable, with two caveats: the fixed phase offset is
not repeatable across restarts (needs a per-session phase calibration), and
real DF needs two separate antennas (the measured coherence is what makes the
inter-antenna phase difference meaningful once you split the feed).
JS8Call / WSJT‑X / fldigi (audio + control, no ExpertSDR3)
The audio bridge presents the radio to digital‑mode software as virtual PulseAudio devices (RX audio in, TX audio out) plus a real Hamlib rigctld for CAT and PTT — so the app runs exactly as it would with any radio, but with nothing between it and the SunSDR2 except this project.
# Bring up virtual audio + CAT, tuned to 20 m JS8, TX capped at 5 W / set to 3 W
python3 -m solsdr.audio --radio 10.1.2.3 --local-ip 10.1.2.185 \
--freq 14078000 --tx-mode USB \
--max-power-watts 5 --tx-watts 3
Then in JS8Call (or WSJT‑X / fldigi):
- Rig:
Hamlib NET rigctl, server127.0.0.1:4532, PTT = CAT - Audio input (RX):
solsdr-rx.monitor(orsolsdr-rx-micif the app hides monitor sources) - Audio output (TX):
solsdr-tx
Control goes to a genuine rigctld (the bridge launches it and mirrors
freq/mode/PTT to the radio), so CAT/PTT/split negotiation is Hamlib's own
battle‑tested code rather than a reimplementation.
Run python3 -m solsdr.audio --help for all flags. Key ones: --max-power-watts
(runtime‑locked amp‑protection ceiling), --tx-watts (output setpoint),
--prefix (device name prefix), --monitor <sink> (also play RX+TX audio to a
speaker so you can hear glitches). If the app can't open the device, quit it,
start the bridge first, then relaunch the app so it binds to the current
PulseAudio nodes.
Voice / SSB — a first‑class TX source
solsdr does SSB/AM/FM phone as a first‑class transmit mode, from any of three
audio sources, keyed however you like. All of it is hardware‑verified on a PRO
(on‑air, into a dummy load). solsdr transmits host‑modulated IQ — the mic
audio (whichever source) runs through solsdr's own Modulator and is streamed to
the radio as 0xFD IQ — so voice, digital modes, and CW all share one TX chain
and one set of safety interlocks.
Three TX audio sources (tx source)
| Source | What it is | Notes |
|---|---|---|
pc |
Audio on the solsdr‑tx PulseAudio sink — a computer mic routed in, or any app's output |
Default. No RF needed to set up; calibrate any time. |
mic1 |
The radio's front‑panel Mic1 jack | The radio digitizes the mic and streams it back to the host, which modulates it. Selecting it also sets the radio mic source. |
mic2 |
The radio's front‑panel Mic2 jack (e.g. a Yaesu hand mic) | Same mechanism as mic1. |
Set the source in the shell: tx source pc / mic1 / mic2. Each source keeps
its own calibrated gain and voice‑shaping preset (below), saved in the config
file and re‑applied automatically when you switch — so a hand mic on mic2 and a
studio USB mic on pc each "just work" at their own settings.
Keying: hardware PTT, software key, spacebar, or CAT
There is no VOX; you key deliberately, any of these ways:
- Hardware PTT — a footswitch or hand‑mic PTT wired to the radio's rear‑panel
PTT input. Turn it on with
tx hwptt on; the radio reports each PTT edge to the host and solsdr keys/unkeys from it. - Software key —
keyto transmit,unkeyto stop (aliasesptt/unptt). voice(hands‑on‑keyboard) — entervoicefor a push‑to‑talk console: HOLD SPACE to talk (release to unkey), or tap ENTER to toggle a latched (hands‑free) over on/off;q/Escexits. Uses the currenttx source.- CAT PTT — any Hamlib client (JS8Call/WSJT‑X/fldigi/
rigctl) with PTT = CAT pointed at127.0.0.1:4532(this is how digital modes key).
All of them fire the same interlocked TXSession (arming, dead‑man, drive
ceiling, calibration‑gated amp limit). Validate into a dummy load first.
Connecting a USB / computer mic to pc
pc transmits whatever is on the solsdr‑tx sink, so loop your mic into it. With
the bridge running:
# Find your mic's PulseAudio source name:
pactl list short sources # e.g. alsa_input.usb-Blue_Microphones-00.analog-stereo
# Route it into the TX sink the modulator reads (low latency):
pactl load-module module-loopback \
source=alsa_input.usb-Blue_Microphones-00.analog-stereo \
sink=solsdr-tx latency_msec=30
# In the solsdr shell:
# tx source pc
# tx cal # calibrate your gain (talk normally ~4 s; no RF for pc)
# voice # hold SPACE to talk
# When done, unload the loopback (id is printed by load-module, or list it):
pactl list short modules | grep loopback
pactl unload-module <id>
Mic gain calibration (tx cal)
Instead of guessing a mic‑gain number, calibrate: tx cal measures your
normal speaking level for a few seconds and computes the gain that puts your
voice at a sensible level with headroom. It's saved per source.
pccalibrates from thesolsdr‑txsink with no transmit.mic1/mic2can only be measured while transmitting (the radio only streams the front‑panel mic while keyed), sotx calkeys the radio for the measurement window — dummy load required. It says so before keying.
A calibrated source uses a fixed input gain (like a real rig's mic gain);
an uncalibrated one falls back to auto‑leveling. You can also set a gain by hand:
tx gain [pc|mic1|mic2] <value>.
Voice shaping for comms (tx shape)
SSB is a communications mode, not hi‑fi. Each source has a shaping preset that band‑limits the audio and adds speech compression for intelligibility and talk power (a studio/USB mic picks up rumble, proximity bass and sibilance that just waste your ~3 kHz of SSB bandwidth):
| Preset | Band | Compression | For |
|---|---|---|---|
flat |
300–2700 Hz | none | a mic already voiced for comms (Yaesu hand mic) |
comms |
250–2800 Hz | 6 dB | studio / USB mics (default for mic1 & pc) |
dx |
350–2700 Hz | 12 dB | maximum readability in a pileup (punchy) |
Set per source: tx shape pc comms, tx shape mic2 flat, etc. Defaults: mic2 =
flat, mic1/pc = comms.
Latency (tx latency)
The voice‑to‑RF delay (how long until you hear yourself on a monitor receiver) is dominated by the IQ pre‑buffer ahead of the transmit pacer. Default 120 ms; lower it for less delay, raise it if you hear clicks/gaps:
tx latency 60 # 60 ms — snappier, more underrun risk
tx latency 250 # safer on a loaded box
Saved in config as tx_latency_ms. Front‑panel mic (mic1/mic2) audio arrives
over the network, so it may need a slightly higher value than a local pc mic
before it's click‑free.
Use --monitor <sink> (or tx to see all settings) to hear the exact audio
being modulated while you dial these in.
Radio control (CAT) — Hamlib rigctld is required
solsdr does not implement the rigctld wire protocol itself. Control software
(WSJT‑X, fldigi, JS8Call, Log4OM, rigctl, …) talks to a real Hamlib
rigctld, and solsdr connects to that same rigctld as a second client,
mirroring its frequency/mode/PTT to the radio over UDP. This means all the
finicky CAT/PTT/split capability negotiation is Hamlib's own battle‑tested code,
not a reimplementation — the tradeoff is that rigctld must be installed
(Debian/Ubuntu: libhamlib-utils; Arch: hamlib).
Both solsdr --hamlib and python3 -m solsdr.audio launch the
rigctld for you (Hamlib dummy backend, model 1) on port 4532 and do the
mirroring. You do not start rigctld yourself; you just point your software at it:
Your software (WSJT-X/fldigi/JS8Call/Log4OM/rigctl)
│ Hamlib NET rigctl -> 127.0.0.1:4532
▼
real rigctld (dummy backend) ← launched by solsdr
▲
│ solsdr polls freq/mode/PTT and mirrors to the radio (UDP)
SunSDR2 PRO
Client configuration:
- WSJT‑X / JS8Call / fldigi: Rig =
Hamlib NET rigctl, Network Server =127.0.0.1:4532. For TX, set PTT method =CAT. rigctl(command line): talk to the running rigctld as a NET client —rigctl -m 2 -r 127.0.0.1:4532 # -m 2 = "NET rigctl" # then, at the prompt: F 14074000 (set freq) f (get freq) # M USB 3000 (set mode) m (get mode)
- Any Hamlib app: point its rigctld/NET‑rigctl host at
127.0.0.1:4532.
If you need rigctld on a different port, use --hamlib-port N (receiver) or
--hamlib-port N (solsdr.audio).
How it works — verified PRO protocol facts
Confirmed against real PRO hardware. Where noted, they differ from the ArtemisSDR reference (whose PRO values were extrapolated from a DX):
| Fact | Value |
|---|---|
| Control | UDP port 50001. The client must bind source port 50001 — the radio ignores control traffic from any other source port (discovery still works from an ephemeral port). |
| Wake / discovery | Broadcast probe <family> ff 00 1a + one's‑complement checksum to <subnet>.255:50001 and 255.255.255.255:50001. The radio replies from any powered state — no power‑cycle needed. |
| Magic byte | PRO 0x01 (DX 0x32). |
| RX IQ | UDP 50002, 1210‑byte packets = 10‑byte header + 200 complex samples, 24‑bit Q‑first little‑endian. |
| PRO IQ rate | Selectable 39062.5 / 78125 / 156250 / 312500 Hz (index 0–3). Default 39062.5 (195 pkt/s). Set by a rate index in the STATE_SYNC packet — see ARTEMISSDR.md. |
| RX keepalive | The PRO RX stream is bidirectional on 50002: the client must echo one silence packet per received packet, or streaming stops after ~8 s. |
| Tuning | 0x09 primary + 0x08 companion. PRO DDC offset = 0 (the ArtemisSDR 92.5 kHz value is DX‑specific and yielded only noise on the PRO). |
| TX IQ | UDP 50002 (same port as RX — verified; not 50003), opcode 0xFD, paced every 5.12 ms (< 1 ms jitter required). |
| TX control | PTT 0x06, drive 0x17 (raw 0–255 byte), PA 0x24. TX entry order: config‑block(TX) → drive → MOX. Drive→watts is per‑band (calibrated, see below). |
| Reference clock | 0x1D u32: 1 = external 10 MHz (GPSDO), 0 = internal. The PRO boots with external enabled. |
Higher sample rates cost proportionally more CPU/network (312.5 kHz is 8× the data of 39062.5); fine on a workstation or Pi 4/5, tighter on a Pi 3/Zero.
Reverse‑engineering notes — the wire‑level details (sample‑rate index mechanism,
0x1D/0x1Fopcodes, front‑end toggles, PRO‑vs‑DX differences, and the ArtemisSDR issue #47 fix) live inARTEMISSDR.md, contributed back to that project. Most users don't need it.
Transmit (working — RF verified on all HF bands)
The full transmit path is implemented, validated to a loopback socket with real program audio (encode → SSB‑modulate → packetize → pace at 5.12 ms → demodulate back → speech‑envelope match), and confirmed producing real RF into a dummy load across the whole HF range, including on‑air JS8Call transmissions.
- Real‑time modulator (
dsp/modulator.py): USB/LSB/AM/FM, Hilbert‑based SSB, with input leveling so a quiet app (e.g. JS8Call with its slider well down) still drives the modulator to full scale — TX power depends on the drive byte, not the app's volume. - Precise pacer (
protocol/tx_pacer.py): Linuxtimerfd+SCHED_FIFO. Measured jitter well under the 1 ms budget (≈0.03–0.7 ms), even under load. - Orchestration (
tx_session.py): the exact ExpertSDR3 TX entry/exit command ordering.
Safety interlocks (all enforced in code)
- Arming: nothing keys the radio unless
arm(confirm=True)is called; unarmed runs the whole chain to a loopback with no RF. - Amp‑protection power limit: an output‑watts ceiling (e.g. 5 W to protect a downstream amplifier) is set only at construction from CLI/config — it has no runtime setter, so neither the interactive shell nor a Hamlib client can raise it. It clamps both watts and raw‑drive requests.
- Calibration‑gated: because the watts→drive mapping is only trustworthy on a calibrated band, the amp limit refuses to key on an uncalibrated band.
- Dead‑man auto‑unkey (default 5 min, refreshed by live audio flow) and a raw drive ceiling.
⚠️ Always validate your own TX chain into a dummy load + wattmeter (and ideally a spectrum analyzer) before going on a real antenna.
Per‑band power calibration — you must do your own
Absolute TX watts depend on your bench (any RF‑sample tap/attenuator, coax, and the individual radio), so the calibration is per‑installation — every user must run it. The tooling is included:
tools/tx_firstkey.py— safe first key‑up (steady tone into a dummy load).tools/tx_anchor.py— anchor one band's output to a through‑line wattmeter reading (a ~20 s keydown).tools/tx_bandcal.py— sweep drive across a band and build the drive→watts curve; results load intoTXPowerCaland are stored in~/.config/solsdr/tx_power_cal.json.tools/cal_tap.py— characterize an RF‑sample tap's frequency‑dependent loss (if you use one to read power on a spectrum analyzer).
The files in reference/cal/ are example data from the author's bench
(a ~6–17 W PRO across HF). They illustrate the JSON format and a worked result —
they are not valid for your setup and must not be used as‑is. Delete or
replace them with your own calibration. A useful cross‑check while calibrating is
DC‑input efficiency: W_rf / (keyed − idle DC input) should land ≈ 36–47 % for a
class‑AB HF final — wildly outside that means the power figure is wrong.
Architecture
solsdr/ the Python package
radio.py High-level Radio: wake + power-on + tune + stream,
bidirectional keepalive, auto-reconnect, front-end
toggles, dual-receiver (RX2) routing
wake.py Broadcast discovery / wake
tx.py Real-time audio->IQ->paced-UDP TX chain (no PTT)
tx_session.py Safety-interlocked TX orchestration (arm/key/drive/deadman)
server.py Unified daemon (mock or real radio) + control API + rigctld mirror
mock_radio.py Behavioral radio emulator for offline testing
protocol/
profiles.py Per-variant constants (PRO verified; DX from ArtemisSDR)
control.py Control socket: freq, mode, PTT, drive, PA, front-end, keepalive
packet.py Vectorized IQ codec + discovery/TX packet helpers
poweron_pro.py Verified PRO power-on sequence
tx_pacer.py timerfd/SCHED_FIFO 5.12 ms packet pacer
rx_stream.py RX IQ receive loop
opcodes.py Opcode + constant definitions
dsp/
demod.py USB/LSB/AM/FM/CW demod, BFO CW, S-meter, AGC modes
modulator.py Audio -> IQ (SSB/AM/FM) for TX, with input leveling
filters.py Stateful IQ/audio filters: channel, notch, APF, NB, NR, squelch
rx_chain.py Composable RX DSP chain
cw_decode.py Morse decoder + Farnsworth encoder
tx_power.py Per-band watts<->drive calibration table (TXPowerCal)
tap_cal.py RF-sample-tap loss calibration (TapCal), log-f interpolation
api/
control_api.py Text control API (:5556)
iq_server.py Raw complex64 IQ TCP stream server, RX (GNU Radio, etc.)
iq_tx_server.py Raw complex64 IQ TCP server, TX — client IQ -> radio,
interlocked (disarmed unless --tx-arm)
audio/ digital-mode bridge (python3 -m solsdr.audio)
__main__.py entry point: radio + real rigctld + virtual audio
audio_bridge.py RX demod -> virtual sink; app audio -> modulator -> TX
pulse_devices.py PulseAudio null sinks + monitor-source remap
rigctld_poller.py launches real rigctld + mirrors freq/mode/PTT to the
radio — the shared CAT mechanism used by the receiver,
server, and audio bridge
solsdr/cli.py Transceiver shell: RX + in-process TX bridge + servers
clients/ example IQ client + GNU Radio notes
tools/ user utilities: FT8 self-test, IQ capture, spectrum,
TX power calibration (first-key, anchor, band sweep, tap)
reference/cal/ EXAMPLE calibration data (author's bench — replace with yours)
tests/ 12 test suites
Testing
# Offline suites (no radio needed); TX pacer/session skip without Linux timerfd
for t in test_iq_decode test_codec test_apis test_mock \
test_iq_server test_iq_tx_server test_tx_pacer test_modulator \
test_tx_session test_cw test_filters test_js8_bridge; do
python3 tests/$t.py
done
The FT8 self‑test (tools/ft8_selftest.py) is the project's ground‑truth RX
check: it records off‑air audio through the full chain and decodes it with
WSJT‑X's jt9. Decoded callsigns mean the receiver is genuinely correct, not
just "producing audio." (test_js8_bridge.py requires PulseAudio and skips
without it.)
Requirements
- Python 3.10+
numpy,scipy,sounddevice(seerequirements.txt)- An audio backend for
sounddevice(PipeWire/PulseAudio/PortAudio) - For the JS8Call/WSJT‑X audio bridge: PulseAudio/PipeWire with
pactl/pacat/parec(Arch:libpulse; Debian/Ubuntu:pulseaudio-utils) and Hamlibrigctld(Debian/Ubuntu:libhamlib-utils) - A Linux host for TX (the pacer uses
timerfd;SCHED_FIFOwants root orCAP_SYS_NICE) - Optional: WSJT‑X (
jt9) for FT8 self‑validation;matplotlibfor the spectrum tool
License
GNU General Public License v2.0 — see LICENSE.
This project is built on protocol reverse engineering from ArtemisSDR, which is distributed under the GNU GPL v2. This project is released under the same GPLv2 to stay aligned with that lineage; if you redistribute it or derivatives, you must do so under the GPL and provide complete source.
Author & acknowledgements
- Jeff Francis, N0GQ — author of solsdr.
- K0KOZ / ArtemisSDR — SunSDR2 protocol reverse engineering; the reference this project is built on.
- Expert Electronics — the SunSDR2 PRO hardware.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file solsdr-0.2.0.tar.gz.
File metadata
- Download URL: solsdr-0.2.0.tar.gz
- Upload date:
- Size: 194.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
518318bf72f3de8bbf4ec283f6edaa8837a5faaf586686cb0196dcc37dba71d5
|
|
| MD5 |
ab69874a1bd3789b36ca29b2af0fddb4
|
|
| BLAKE2b-256 |
adc12f07f1978b9b3fb6f2b239ca2ee77de250a956af82a4dee04a967f7f4f2c
|
File details
Details for the file solsdr-0.2.0-py3-none-any.whl.
File metadata
- Download URL: solsdr-0.2.0-py3-none-any.whl
- Upload date:
- Size: 155.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
837b98b8759230d97e87fbb697efab217110ebc68272b693c6a3ceac6e26db4f
|
|
| MD5 |
524c8b1795d26593605c1d43d967a65f
|
|
| BLAKE2b-256 |
296d7ee9c34e8258ba459f94acc857f7a33898d577440da1d735a01124c6d3c9
|