diff --git a/Dockerfile b/Dockerfile index 4a268bb..66df0ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/compose.yml b/compose.yml index 2f287c0..165b4ff 100644 --- a/compose.yml +++ b/compose.yml @@ -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 diff --git a/src/stream_web/app.py b/src/stream_web/app.py index 136bd99..3abed05 100644 --- a/src/stream_web/app.py +++ b/src/stream_web/app.py @@ -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() # =========================================================================== @@ -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, ) @@ -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 @@ -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() diff --git a/src/stream_web/config.py b/src/stream_web/config.py index b47ef0b..b3a5e3f 100644 --- a/src/stream_web/config.py +++ b/src/stream_web/config.py @@ -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 @@ -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 diff --git a/src/stream_web/gnuradio_rx.py b/src/stream_web/gnuradio_rx.py index 3262d9b..c113345 100644 --- a/src/stream_web/gnuradio_rx.py +++ b/src/stream_web/gnuradio_rx.py @@ -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:"): diff --git a/src/stream_web/gnuradio_tx.py b/src/stream_web/gnuradio_tx.py index 1a49389..41aebb0 100644 --- a/src/stream_web/gnuradio_tx.py +++ b/src/stream_web/gnuradio_tx.py @@ -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.""" @@ -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 @@ -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 --------------------------------------------------- @@ -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 -------------------------------------------------------------