Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ RUN git clone --depth 1 https://github.com/pothosware/SoapyBladeRF.git /tmp/Soap
rm -rf /tmp/SoapyBladeRF && \
ldconfig

# Signal Hound VSG60A (Soapy driver SignalHoundVSG60) — repo bundles libvsg_api + headers
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y libusb-1.0-0 && \
rm -rf /var/lib/apt/lists/*
# Install bundled libvsg_api into ld path with SONAME libvsg_api.so.1 (required at runtime by libSignalHoundVSG60.so)
RUN git clone --depth 1 https://github.com/SignalHound/soapy-vsg.git /tmp/soapy-vsg && \
VSG_SRC=$(ls /tmp/soapy-vsg/lib/libvsg_api.so.* | head -n1) && \
cp -a "$VSG_SRC" /usr/local/lib/ && \
VSG_BASE=$(basename "$VSG_SRC") && \
ln -sf "$VSG_BASE" /usr/local/lib/libvsg_api.so.1 && \
ln -sf libvsg_api.so.1 /usr/local/lib/libvsg_api.so && \
cd /tmp/soapy-vsg/lib && ln -sf "$VSG_BASE" libvsg_api.so && \
cd /tmp/soapy-vsg && mkdir build && cd build && \
cmake .. && make -j"$(nproc)" && make install && \
rm -rf /tmp/soapy-vsg && \
ldconfig

# Pre-download bladeRF FPGA image so the entrypoint can load it at runtime
RUN mkdir -p /opt/bladerf && \
wget -q https://www.nuand.com/fpga/hostedxA4-latest.rbf \
Expand Down
3 changes: 3 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ services:

environment:
- SDR_TYPE=${SDR_TYPE:-pluto}
- SDR_MODE=${SDR_MODE:-full}
- PLUTO_URI=${PLUTO_URI:-ip:192.168.2.1}
# Signal Hound VSG60A (TX): SDR_TYPE=signalhound forces SDR_MODE=tx_only
# - SIGNALHOUND_SERIAL=

volumes:
- ./:/app
Expand Down
134 changes: 75 additions & 59 deletions src/stream_web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ def rx_drop_positions(self, v):

state = SharedState()
tx_fg: "TXFlowgraph | None" = None
# GNU Radio top_block is not thread-safe; Flask serves /api/tx/* concurrently.
tx_api_lock = threading.Lock()


# ===========================================================================
Expand Down Expand Up @@ -309,6 +311,8 @@ def api_status():
chipset_stats=cs_stats,
known_chipsets=sorted(config.SYNTH_RES.keys()),
lo_freq_hz=state.lo_freq_hz,
sdr_mode=config.SDR_MODE,
sdr_type=config.SDR_TYPE,
)


Expand Down Expand Up @@ -451,83 +455,88 @@ def _get_tx() -> TXFlowgraph:

@app.route("/api/tx/start", methods=["POST"])
def api_tx_start():
data = flask_request.get_json(silent=True) or {}
mode = data.get("mode", "tone")
print(f"[TX] /api/tx/start called: mode={mode}, data={data}", flush=True)
print("[TX] Getting TX flowgraph...", flush=True)
fg = _get_tx()
print(f"[TX] Flowgraph ready (running={fg.is_running}, mode={fg.mode})", flush=True)
try:
if mode == "tone":
print("[TX] Switching to tone mode...", flush=True)
fg.tone_mode()
print("[TX] Tone mode set.", flush=True)
elif mode == "packet":
file_name = data.get("file", "")
repeat = data.get("repeat", True)
if not file_name:
return jsonify(error="file is required for packet mode"), 400
file_path = os.path.join(TX_SOURCE_DIR, file_name)
print(f"[TX] Switching to packet mode: {file_path} "
f"(exists={os.path.isfile(file_path)}, "
f"size={os.path.getsize(file_path) if os.path.isfile(file_path) else 'N/A'}), "
f"repeat={repeat}", flush=True)
fg.packet_mode(file_path, repeat=repeat)
print("[TX] Packet mode set.", flush=True)
else:
return jsonify(error=f"Unknown mode: {mode}"), 400
if not fg.is_running:
print("[TX] Starting flowgraph...", flush=True)
fg.start()
print("[TX] Flowgraph started.", flush=True)
else:
print("[TX] Flowgraph already running.", flush=True)
return jsonify(fg.status_dict())
except Exception as e:
print(f"[TX] ERROR: {e}", flush=True)
import traceback
traceback.print_exc()
return jsonify(error=str(e)), 500
with tx_api_lock:
data = flask_request.get_json(silent=True) or {}
mode = data.get("mode", "tone")
print(f"[TX] /api/tx/start called: mode={mode}, data={data}", flush=True)
print("[TX] Getting TX flowgraph...", flush=True)
fg = _get_tx()
print(f"[TX] Flowgraph ready (running={fg.is_running}, mode={fg.mode})", flush=True)
try:
if mode == "tone":
print("[TX] Switching to tone mode...", flush=True)
fg.tone_mode()
print("[TX] Tone mode set.", flush=True)
elif mode == "packet":
file_name = data.get("file", "")
repeat = data.get("repeat", True)
if not file_name:
return jsonify(error="file is required for packet mode"), 400
file_path = os.path.join(TX_SOURCE_DIR, file_name)
print(f"[TX] Switching to packet mode: {file_path} "
f"(exists={os.path.isfile(file_path)}, "
f"size={os.path.getsize(file_path) if os.path.isfile(file_path) else 'N/A'}), "
f"repeat={repeat}", flush=True)
fg.packet_mode(file_path, repeat=repeat)
print("[TX] Packet mode set.", flush=True)
else:
return jsonify(error=f"Unknown mode: {mode}"), 400
if not fg.is_running:
print("[TX] Starting flowgraph...", flush=True)
fg.start()
print("[TX] Flowgraph started.", flush=True)
else:
print("[TX] Flowgraph already running.", flush=True)
return jsonify(fg.status_dict())
except Exception as e:
print(f"[TX] ERROR: {e}", flush=True)
import traceback
traceback.print_exc()
return jsonify(error=str(e)), 500


@app.route("/api/tx/stop", methods=["POST"])
def api_tx_stop():
print(f"[TX] /api/tx/stop called (fg={tx_fg is not None}, "
f"running={tx_fg.is_running if tx_fg else False})", flush=True)
if tx_fg is None or not tx_fg.is_running:
with tx_api_lock:
print(f"[TX] /api/tx/stop called (fg={tx_fg is not None}, "
f"running={tx_fg.is_running if tx_fg else False})", flush=True)
if tx_fg is None or not tx_fg.is_running:
return jsonify(running=False)
tx_fg.stop()
print("[TX] Flowgraph stopped.", flush=True)
return jsonify(running=False)
tx_fg.stop()
print("[TX] Flowgraph stopped.", flush=True)
return jsonify(running=False)


@app.route("/api/tx/freq", methods=["GET", "POST"])
def api_tx_freq():
fg = _get_tx()
if flask_request.method == "POST":
data = flask_request.get_json(silent=True) or {}
freq = int(data.get("freq_hz", fg.freq_hz))
fg.set_frequency(freq)
with tx_api_lock:
fg = _get_tx()
if flask_request.method == "POST":
data = flask_request.get_json(silent=True) or {}
freq = int(data.get("freq_hz", fg.freq_hz))
fg.set_frequency(freq)
return jsonify(freq_hz=fg.freq_hz)
return jsonify(freq_hz=fg.freq_hz)
return jsonify(freq_hz=fg.freq_hz)


@app.route("/api/tx/attn", methods=["GET", "POST"])
def api_tx_attn():
fg = _get_tx()
if flask_request.method == "POST":
data = flask_request.get_json(silent=True) or {}
attn = float(data.get("attn_db", fg.attn_db))
fg.set_attn(attn)
with tx_api_lock:
fg = _get_tx()
if flask_request.method == "POST":
data = flask_request.get_json(silent=True) or {}
attn = float(data.get("attn_db", fg.attn_db))
fg.set_attn(attn)
return jsonify(attn_db=fg.attn_db)
return jsonify(attn_db=fg.attn_db)
return jsonify(attn_db=fg.attn_db)


@app.route("/api/tx/status", methods=["GET"])
def api_tx_status():
if tx_fg is None:
return jsonify(running=False, mode=None, freq_hz=0, attn_db=30)
return jsonify(tx_fg.status_dict())
with tx_api_lock:
if tx_fg is None:
return jsonify(running=False, mode=None, freq_hz=0, attn_db=30)
return jsonify(tx_fg.status_dict())


TX_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB
Expand Down Expand Up @@ -671,9 +680,16 @@ def _mock_injector(state, interval_s: float = 2.0):
def main():
"""Start RX thread, processor process, result drainer, and Flask."""
state.running.set()
print(
f"[main] SDR_TYPE={config.SDR_TYPE!r} SDR_MODE={config.SDR_MODE!r}",
flush=True,
)
mock_mode = config.SDR_TYPE == "mock"
tx_only = config.SDR_MODE == "tx_only"

if mock_mode:
if tx_only:
print("[main] TX-only mode — no RX, processor, or mock injector; Flask + /api/tx/* only.")
elif mock_mode:
print("[main] Mock mode active — no SDR hardware required.")
state.rx_connected.set()
threading.Thread(target=_mock_injector, args=(state,), daemon=True).start()
Expand Down
18 changes: 17 additions & 1 deletion src/stream_web/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,26 @@
)

# -- SDR selection (override with environment variables) --------------------
# "pluto" (ADALM-PLUTO & PlutoPlus) or "bladerf"
# "pluto" (ADALM-PLUTO & PlutoPlus) | "bladerf" | "signalhound" (Signal Hound VSG60A TX via Soapy SignalHoundVSG60)
SDR_TYPE = os.environ.get("SDR_TYPE", "pluto").lower()

# -- Run mode: "full" (RX + decode + web) | "tx_only" (Flask + TX API only; no RX/processor)
SDR_MODE = os.environ.get("SDR_MODE", "full").lower()

# VSG60 is TX-only; running the RX pipeline is never valid for this backend.
if SDR_TYPE == "signalhound":
SDR_MODE = "tx_only"

# -- PlutoSDR connection (ignored when SDR_TYPE != "pluto") -----------------
PLUTO_URI = os.environ.get("PLUTO_URI", "ip:192.168.2.1")

# -- Signal Hound VSG60 (Soapy driver=SignalHoundVSG60), ignored unless SDR_TYPE == "signalhound"
SIGNALHOUND_SERIAL = os.environ.get("SIGNALHOUND_SERIAL", "").strip()

# TX output level mapping for Signal Hound (dBm; Soapy RF gain), used when SDR_TYPE == "signalhound"
SIGNALHOUND_TX_DBM_MIN = float(os.environ.get("SIGNALHOUND_TX_DBM_MIN", "-120"))
SIGNALHOUND_TX_DBM_MAX = float(os.environ.get("SIGNALHOUND_TX_DBM_MAX", "10"))

# -- Radio parameters (shared across SDR backends) -------------------------
CENTER_FREQ_HZ = 2_482_440_375
SAMPLE_RATE = 781_250 # 6.25 MHz / 8
Expand All @@ -74,6 +88,8 @@

if SDR_TYPE == "bladerf":
RX_GAIN_MAX_DB = 60
elif SDR_TYPE == "signalhound":
RX_GAIN_MAX_DB = 71 # unused in tx_only / no RX
else:
RX_GAIN_MAX_DB = 71

Expand Down
6 changes: 6 additions & 0 deletions src/stream_web/gnuradio_rx.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ def _soapy_driver_args() -> str:
args += f",serial={serial}"
return args

if sdr_type == "signalhound":
args = "driver=SignalHoundVSG60"
if config.SIGNALHOUND_SERIAL:
args += f",serial={config.SIGNALHOUND_SERIAL}"
return args

# PlutoSDR / PlutoPlus (default) — both use the same SoapyPlutoSDR driver
uri = config.PLUTO_URI
if uri.startswith("ip:"):
Expand Down
38 changes: 29 additions & 9 deletions src/stream_web/gnuradio_tx.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@
# TX Flowgraph
# ---------------------------------------------------------------------------

def _tx_sink_gain_db(attn_db: float) -> float:
"""Soapy sink ``set_gain(0, value)``: Pluto uses fake gain from attenuation; VSG60 uses dBm."""
if config.SDR_TYPE == "signalhound":
lo = config.SIGNALHOUND_TX_DBM_MIN
hi = config.SIGNALHOUND_TX_DBM_MAX
t = min(1.0, max(0.0, float(attn_db) / 89.75))
return hi - t * (hi - lo)
return 89.75 - attn_db


class TXFlowgraph(gr.top_block):
"""SoapySDR-based TX flowgraph with tone and packet-file modes."""

Expand All @@ -51,11 +61,11 @@ def __init__(self):
self._sink.set_sample_rate(0, TX_SAMPLE_RATE)
self._sink.set_frequency(0, TX_DEFAULT_FREQ_HZ)
self._sink.set_bandwidth(0, TX_BANDWIDTH)
self._sink.set_gain(0, 89.75 - TX_DEFAULT_ATTN_DB)
self._sink.set_gain(0, _tx_sink_gain_db(TX_DEFAULT_ATTN_DB))

self._source_block = None
self._mode: str | None = None
self._lock = threading.Lock()
self._lock = threading.RLock()
self._freq_hz: int = TX_DEFAULT_FREQ_HZ
self._attn_db: float = TX_DEFAULT_ATTN_DB
self._running = False
Expand Down Expand Up @@ -111,13 +121,19 @@ def packet_mode(self, file_path: str, repeat: bool = True) -> None:
def start(self) -> None: # type: ignore[override]
if self._source_block is None:
raise RuntimeError("Set tone_mode() or packet_mode() before start()")
super().start()
self._running = True
with self._lock:
if self._running:
return
super().start()
self._running = True

def stop(self) -> None: # type: ignore[override]
super().stop()
self.wait()
self._running = False
with self._lock:
if not self._running:
return
super().stop()
self.wait()
self._running = False

# -- runtime controls ---------------------------------------------------

Expand All @@ -126,9 +142,13 @@ def set_frequency(self, freq_hz: int) -> None:
self._freq_hz = freq_hz

def set_attn(self, attn_db: float) -> None:
"""Set TX attenuation (0 = max power ≈ 0 dBm, 89 = min power)."""
"""Set TX attenuation (0 = max power, 89.75 = min power).

For Pluto/bladeRF this maps to driver gain. For Signal Hound VSG60 the same
knob maps linearly to output level between SIGNALHOUND_TX_DBM_MAX and MIN.
"""
attn_db = max(0.0, min(89.75, attn_db))
self._sink.set_gain(0, 89.75 - attn_db)
self._sink.set_gain(0, _tx_sink_gain_db(attn_db))
self._attn_db = attn_db

# -- status -------------------------------------------------------------
Expand Down
Loading