Live rolling spectrogram and packet decoder for SDR devices. Streams IQ data, displays a real-time spectrogram, decodes packets, and supports full-duplex TX — all served as a web dashboard on port 8050.
Uses hubble-satnet-decoder for packet detection and decoding.
| Device | Interface | Notes |
|---|---|---|
| ADALM-PLUTO (PlutoSDR) | Ethernet (ip:192.168.2.1) or USB |
Default. USB on Mac requires NCM firmware (see below) |
| Nuand bladeRF 2.0 Micro A4 | USB | Set SDR_TYPE=bladerf |
All SDR hardware is accessed through a single code path: GNU Radio's
gr-soapy block, which wraps SoapySDR. Adding support for a new device
(RTL-SDR, HackRF, LimeSDR, USRP, …) requires only a SoapySDR module for
that device — zero application code changes.
| Component | Process / Thread | Description |
|---|---|---|
| SDR RX | GNU Radio flowgraph (C++ threads) | soapy.source → custom sink that writes into a 2 s shared-memory circular buffer |
| Processor | separate OS process | Every 0.5 s: compute spectrogram chunk, render 10 s image, decode. Runs in its own process to avoid GIL contention with the RX thread |
| Flask server | main process | Serves web dashboard + JSON API on port 8050 |
Main Process Processor Process
──────────── ─────────────────
SDR ──gr-soapy──> BufferSink ──> shared-memory IQ buffer ──> 0.5 s chunk ──> spectrogram
(2 s circular) 1.0 s chunk ──> detection + decode
│
result_queue
│
Flask (/api/status, /api/packets) <── drain thread <────────┘
src/stream_web/
├── config.py # SDR / display constants (protocol constants via hubble-satnet-decoder)
├── gnuradio_rx.py # GNU Radio RX flowgraph: soapy.source → BufferSink
├── gnuradio_tx.py # GNU Radio TX flowgraph: soapy.sink (tone / packet file)
├── sdr.py # Re-exports rx_loop
├── spectrogram.py # Spectrogram image rendering (computation via hubble-satnet-decoder)
├── processor.py # Processing loop (runs in separate OS process)
├── app.py # Flask app, RX/TX routes, API endpoints, orchestration
├── templates/
│ └── index.html # Dashboard HTML
└── static/
└── style.css # Dashboard CSS
The application has two layers of dependencies:
| Layer | What | How to install |
|---|---|---|
| Python packages (pip) | hubble-satnet-decoder, flask, numpy, scipy, matplotlib, Pillow | pip install -e . |
| System libraries (apt / brew / source) | GNU Radio >= 3.9, SoapySDR, per-device SoapySDR modules, device libraries | See platform-specific sections below |
GNU Radio and SoapySDR ship Python bindings that are installed system-wide
(not via pip). To make them visible inside a virtualenv, always create the
venv with --system-site-packages:
python3 -m venv --system-site-packages .venvThe Dockerfile serves as the canonical reference for all system-level dependencies and their build steps.
| SDR | Device library | SoapySDR module | Notes |
|---|---|---|---|
| PlutoSDR | libiio (>= 0.23), libad9361-iio | SoapyPlutoSDR (build from source) | apt install on Linux; build both libs from source on macOS |
| bladeRF 2.0 | libbladerf | SoapyBladeRF (build from source) | bladeRF firmware >= 2.6.0 required for FPGA v0.16.0 |
| Variable | Default | Description |
|---|---|---|
SDR_TYPE |
pluto |
SDR backend: pluto or bladerf |
PLUTO_URI |
ip:192.168.2.1 |
PlutoSDR connection URI (e.g. ip:192.168.2.1 or usb:) |
BLADERF_SERIAL |
(empty) | Optional bladeRF serial number for multi-device setups |
Docker is the recommended way to run on Linux. It works for both SDR devices, over Ethernet or USB.
Official instructions: https://docs.docker.com/engine/install/
Quick install with the convenience script:
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh ./get-docker.sh
sudo usermod -aG docker $USERLog out and back in, then verify:
groups | grep dockergit clone https://github.com/HubbleNetwork/sdr-docker.git
cd sdr-docker/
docker build -t sdr-docker .Non-x86 architectures: download the correct libiio
.debfrom libiio releases and update thewgetline in the Dockerfile.
No special flags needed — the container reaches Pluto at 192.168.2.1 via the
host network stack:
docker run --restart unless-stopped -d -p 8050:8050 sdr-dockerOr use the pre-built image from GitHub Container Registry:
docker run --restart unless-stopped -d -p 8050:8050 ghcr.io/hubblenetwork/sdr-docker:latestTo use a different Pluto IP:
docker run --restart unless-stopped -d -p 8050:8050 \
-e PLUTO_URI=ip:192.168.3.1 sdr-dockerPass the USB bus into the container:
docker run --restart unless-stopped -d -p 8050:8050 \
--privileged \
-e PLUTO_URI=usb: \
sdr-dockerOr, for tighter security, map only /dev/bus/usb:
docker run --restart unless-stopped -d -p 8050:8050 \
--device=/dev/bus/usb \
-e PLUTO_URI=usb: \
sdr-dockerdocker run --restart unless-stopped -d -p 8050:8050 \
--privileged \
-e SDR_TYPE=bladerf \
sdr-dockerThe container's entrypoint automatically loads the bladeRF FPGA bitstream before starting the app — no manual
bladeRF-clistep needed.
Why
--restart unless-stopped? See Connection recovery below.
docker build -t sdr-docker .
# PlutoSDR over Ethernet (default):
docker compose up
# PlutoSDR over USB:
SDR_TYPE=pluto PLUTO_URI=usb: docker compose up
# bladeRF:
SDR_TYPE=bladerf docker compose upUSB passthrough with Compose: uncomment the
privileged: trueordevices:section incompose.yml.
Navigate to http://localhost:8050 in a browser.
docker ps
docker kill <container_id>Docker Desktop for Mac runs Linux inside a VM, which makes USB device passthrough unreliable. Running natively is recommended on macOS for both PlutoSDR (USB) and bladeRF.
Install GNU Radio and base SDR support via Homebrew:
brew install gnuradio libusb cmakeThis installs GNU Radio 3.10+ with gr-soapy (the unified SDR backend) and SoapySDR. Both are linked to the Homebrew Python (currently 3.14).
For PlutoSDR, build libiio, libad9361-iio, and SoapyPlutoSDR from source (none are available as Homebrew formulae):
# 1. Build libiio from source
# -DOSX_FRAMEWORK=OFF is critical — without it cmake produces a .framework
# bundle that requires root to install and causes rpath issues downstream.
git clone --depth 1 --branch v0.25 https://github.com/analogdevicesinc/libiio.git
cd libiio && mkdir build && cd build
cmake .. -DCMAKE_INSTALL_PREFIX=/opt/homebrew \
-DWITH_TESTS=OFF -DWITH_SERIAL_BACKEND=OFF \
-DOSX_PACKAGE=OFF -DOSX_FRAMEWORK=OFF
make -j$(sysctl -n hw.ncpu) && make install
cd ../..
# 2. Build libad9361-iio (AD9361 transceiver support library)
# Same -DOSX_FRAMEWORK=OFF requirement as libiio.
git clone --depth 1 https://github.com/analogdevicesinc/libad9361-iio.git
cd libad9361-iio && mkdir build && cd build
cmake .. -DCMAKE_INSTALL_PREFIX=/opt/homebrew \
-DOSX_PACKAGE=OFF -DOSX_FRAMEWORK=OFF
make -j$(sysctl -n hw.ncpu) && make install
cd ../..
# 3. Build SoapyPlutoSDR module
git clone --depth 1 https://github.com/pothosware/SoapyPlutoSDR.git
cd SoapyPlutoSDR && mkdir build && cd build
cmake .. -DCMAKE_INSTALL_PREFIX=/opt/homebrew
make -j$(sysctl -n hw.ncpu) && make install
cd ../..
# 4. Fix dynamic library paths (macOS rpath issue)
# Even with -DOSX_FRAMEWORK=OFF, the built binaries sometimes end up with
# framework-style references (@rpath/iio.framework/...) instead of dylib
# references. These install_name_tool commands patch them to use the
# correct dylib names and add /opt/homebrew/lib to the rpath search.
for lib in \
/opt/homebrew/lib/libad9361.0.2.dylib \
/opt/homebrew/lib/SoapySDR/modules0.8/libPlutoSDRSupport.so; do
install_name_tool -change \
"@rpath/iio.framework/Versions/0.25/iio" \
"@rpath/libiio.0.dylib" "$lib" 2>/dev/null
install_name_tool -add_rpath /opt/homebrew/lib "$lib" 2>/dev/null
done
# 5. Clean up source trees
rm -rf libiio libad9361-iio SoapyPlutoSDR
# Verify:
SoapySDRUtil --find="driver=plutosdr"Why not
sudo make install? On Apple Silicon Macs,/opt/homebrewis owned by the user, sosudois not needed. If you see permission errors, prefix themake installcommands withsudo.
For bladeRF Micro A4, install libbladerf and build SoapyBladeRF:
brew install libbladerf
# Build SoapyBladeRF module
git clone --depth 1 https://github.com/pothosware/SoapyBladeRF.git
cd SoapyBladeRF && mkdir build && cd build
cmake .. -DCMAKE_INSTALL_PREFIX=/opt/homebrew
make -j$(sysctl -n hw.ncpu) && make install
cd ../.. && rm -rf SoapyBladeRF
# Flash FPGA to auto-load (one-time — persists across power cycles)
wget https://www.nuand.com/fpga/hostedxA4-latest.rbf -O /tmp/hostedxA4.rbf
bladeRF-cli -L /tmp/hostedxA4.rbf
# Verify:
SoapySDRUtil --find="driver=bladerf"Verifying all modules loaded:
SoapySDRUtil --info
# Should list "Available factories... bladerf, plutosdr, rtlsdr"
# If a module shows "dlopen() failed", check the error for missing libraries.Use a venv with --system-site-packages so the Homebrew-installed GNU Radio
and SoapySDR Python bindings are visible inside the venv:
cd sdr-docker/
python3 -m venv --system-site-packages .venv
source .venv/bin/activate
pip install -e .Why
--system-site-packages? GNU Radio's Python bindings are installed system-wide by Homebrew (into Python 3.14'ssite-packages). A standard venv isolates from system packages and would not see them. The--system-site-packagesflag allows the venv to fall through to the system packages for anything not installed locally.
PlutoSDR over USB:
PLUTO_URI=usb: python3 run_stream.pyPlutoSDR over Ethernet (requires NCM firmware — see troubleshooting below):
python3 run_stream.pybladeRF Micro A4:
SDR_TYPE=bladerf python3 run_stream.pyOpen http://localhost:8050.
Running natively (without Docker) on Linux follows the same pattern.
sudo apt update
sudo apt install -y python3-pip python3-venv git
# GNU Radio (with gr-soapy built in) — available in Ubuntu 22.04+ repos.
# For older distros, add the PPA: sudo add-apt-repository -y ppa:gnuradio/gnuradio-releases
sudo apt install -y gnuradio
# SoapySDR runtime and development files
sudo apt install -y libsoapysdr-dev python3-soapysdr
# Build tools (needed to compile SoapySDR device modules from source)
sudo apt install -y cmake g++PlutoSDR support (libiio and libad9361 are available as system packages on Linux):
sudo apt install -y libiio-dev libiio-utils libad9361-dev
git clone --depth 1 https://github.com/pothosware/SoapyPlutoSDR.git
cd SoapyPlutoSDR && mkdir build && cd build
cmake .. && make -j$(nproc) && sudo make install
cd ../.. && rm -rf SoapyPlutoSDR
sudo ldconfigIf using PlutoSDR over USB, add a udev rule for non-root access:
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="0456", ATTR{idProduct}=="b673", MODE="0666"' \
| sudo tee /etc/udev/rules.d/53-plutosdr.rules
sudo udevadm control --reload-rules && sudo udevadm triggerVerify:
# PlutoSDR reachable (should list Analog Devices PlutoSDR):
iio_info -s
# SoapySDR sees the module:
SoapySDRUtil --find="driver=plutosdr"bladeRF support (optional):
sudo apt install -y libbladerf-dev libbladerf2 bladerf
git clone --depth 1 https://github.com/pothosware/SoapyBladeRF.git
cd SoapyBladeRF && mkdir build && cd build
cmake .. && make -j$(nproc) && sudo make install
cd ../.. && rm -rf SoapyBladeRF
sudo ldconfigUSB permissions — add a udev rule so the bladeRF is accessible without root:
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="2cf0", MODE="0666"' \
| sudo tee /etc/udev/rules.d/53-bladerf.rules
sudo udevadm control --reload-rules && sudo udevadm triggerUnplug and re-plug the bladeRF after applying.
FPGA bitstream — the bladeRF 2.0 needs an FPGA image loaded at each power-on. Flash it to auto-load so this is handled permanently:
wget https://www.nuand.com/fpga/hostedxA4-latest.rbf -O /tmp/hostedxA4.rbf
bladeRF-cli -L /tmp/hostedxA4.rbfCapital
-Lwrites the FPGA image to flash so it loads automatically on every power-on. If you skip this step, the app will fail on first connect and leave the bladeRF in a bad state requiring a power cycle.
Verify with SoapySDRUtil --info — you should see plutosdr and/or
bladerf listed under "Available factories".
cd sdr-docker/
# --system-site-packages is required so the venv can see
# GNU Radio and SoapySDR Python bindings installed by apt
python3 -m venv --system-site-packages .venv
source .venv/bin/activate
pip install -e .NumPy constraint:
pyproject.tomlpinsnumpy>=1.26,<2. The GNU Radio packages from apt (and Homebrew) are compiled against the NumPy 1.x ABI. If NumPy 2.x is installed,import gnuradiowill fail with_ARRAY_API not found.
GNU Radio vmcircbuf warning: on native Linux you may see
vmcircbuf_prefs::get :error: …/vmcircbuf_default_factory: No such file. This is harmless (GNU Radio falls back automatically), but to silence it:mkdir -p ~/.gnuradio/prefs echo "shm_open" > ~/.gnuradio/prefs/vmcircbuf_default_factory
# PlutoSDR (Ethernet, default):
python3 run_stream.py
# PlutoSDR (USB):
PLUTO_URI=usb: python3 run_stream.py
# bladeRF Micro A4:
SDR_TYPE=bladerf python3 run_stream.pyAll tuneable parameters live in
src/stream_web/config.py. Key settings:
| Parameter | Default | Description |
|---|---|---|
SDR_TYPE |
pluto |
SDR backend (pluto or bladerf) |
PLUTO_URI |
ip:192.168.2.1 |
PlutoSDR connection URI |
CENTER_FREQ_HZ |
2.482754875 GHz | Centre frequency |
SAMPLE_RATE |
781 250 Hz | ADC sample rate |
RX_INITIAL_GAIN_DB |
20 | Initial RX gain (adjustable from the UI) |
FLASK_PORT |
8050 | Web server port |
SPEC_DURATION_S |
10.0 | Rolling spectrogram window |
DECODE_INTERVAL_S |
0.5 | Decode cycle interval |
The dashboard auto-refreshes every 500 ms and provides:
- Live spectrogram — 10 s rolling window with coloured detection boxes (orange = PHY v-1, red = PHY v1).
- Decodes tab — per-device summary: PHY version, device ID, chipset, RSSI, frequency delta, last 10 sequence numbers, last-seen timestamp.
- Statistics tab — per-chipset decode success rates.
- Signal Viewer — view time-domain + spectrogram plots per device or per chipset. Includes symbol timing, frequency labels, and full decode diagnostics. Can capture failures for a specific chipset to aid debugging.
- Gain control — adjust RX gain from the browser.
- LO control — adjust LO frequency in 1 kHz increments.
- TX Control — transmit a CW tone or play back IQ files, with attenuation control. Full-duplex (TX runs simultaneously with RX).
A poll-and-drain JSONL endpoint for external agents / scripts. Each call returns all packets decoded since the last call, one JSON object per line, then clears the buffer.
Response format (application/x-ndjson):
{"device_id": "0xBBAABB01", "seq_num": 155, "device_type": "silabs", "timestamp": 1709571234.567, "rssi_dB": -30.6, "channel_num": 13, "freq_offset_hz": 20902.0, "payload_b64": "AQIDBAUGBwgJCgsMDQ=="}
{"device_id": "0xBBAABB01", "seq_num": 156, "device_type": "silabs", "timestamp": 1709571235.102, "rssi_dB": -31.2, "channel_num": 10, "freq_offset_hz": 20910.5, "payload_b64": "AQIDBAUGBwgJCgsMDQ=="}Fields:
| Field | Description |
|---|---|
device_id |
Network ID as hex string |
seq_num |
Packet sequence number |
device_type |
Chipset name (e.g. silabs, nordic, ti) |
timestamp |
Unix epoch (float, seconds) |
rssi_dB |
Signal energy in dBFS |
channel_num |
Frequency channel from the decoded header |
freq_offset_hz |
Measured frequency offset from nominal channel center (Hz) |
payload_b64 |
Packet payload as base64-encoded string (empty if no payload) |
CLI examples:
# One-shot: fetch all new packets
curl -s http://localhost:8050/api/packets
# Continuous polling (every 2 seconds), append to JSONL file
while true; do curl -s http://localhost:8050/api/packets >> decodes.jsonl; sleep 2; done
# Pretty-print with jq
curl -s http://localhost:8050/api/packets | jq .
# Filter for a specific device
curl -s http://localhost:8050/api/packets | jq 'select(.device_id == "0xBBAABB01")'
# Monitor in real-time with watch
watch -n 2 'curl -s http://localhost:8050/api/packets | jq .'Full-duplex TX runs simultaneously with RX. All TX endpoints live under /api/tx/.
| Endpoint | Method | Description |
|---|---|---|
/api/tx/start |
POST | Start transmitting. Body: {"mode":"tone"} for CW carrier, or {"mode":"packet","file":"<name>","repeat":true} for IQ file playback |
/api/tx/stop |
POST | Stop transmitting |
/api/tx/status |
GET | Current TX state (running, mode, freq, attenuation) |
/api/tx/freq |
GET/POST | Get or set TX frequency. POST body: {"freq_hz": 2482440375} |
/api/tx/attn |
GET/POST | Get or set TX attenuation. POST body: {"attn_db": 0} (0 = max power) |
/api/tx/files |
GET | List available TX IQ files with name, size, and SHA256 hash |
/api/tx/files |
POST | Upload an IQ binary file (multipart form, max 1 GB). Returns {name, size, sha256} |
/api/tx/files/<name> |
DELETE | Delete a TX file |
CLI examples:
# Start a CW tone at max power
curl -X POST http://localhost:8050/api/tx/start \
-H 'Content-Type: application/json' -d '{"mode":"tone"}'
# Set attenuation to 0 dB (max power)
curl -X POST http://localhost:8050/api/tx/attn \
-H 'Content-Type: application/json' -d '{"attn_db": 0}'
# Upload an IQ file for TX
curl -X POST http://localhost:8050/api/tx/files -F "file=@my_waveform.bin"
# List available TX files
curl -s http://localhost:8050/api/tx/files | jq .
# Start packet TX from an uploaded file
curl -X POST http://localhost:8050/api/tx/start \
-H 'Content-Type: application/json' \
-d '{"mode":"packet","file":"my_waveform.bin"}'
# Stop TX
curl -X POST http://localhost:8050/api/tx/stop
# Delete a TX file
curl -X DELETE http://localhost:8050/api/tx/files/my_waveform.binThe SDR RX thread monitors liveness by tracking the last time IQ samples arrived. If no samples are received for 5 seconds, the connection is considered lost.
Why the process exits instead of reconnecting in-process: PlutoSDR uses
libiio for its network/USB backend. Once a libiio pipe breaks (error -32 /
EPIPE), the library's internal state is corrupted and creating a new
iio_context in the same process inherits the broken state. In-process
reconnection does not work — a fresh process is required.
When a connection drop is detected, the process exits with code 3.
Docker's --restart unless-stopped policy (or on-failure) automatically
brings up a clean container, which establishes a fresh libiio context and
reconnects. A typical recovery cycle takes 5–10 seconds.
Initial connection retries still work in-process — if the SDR is not yet available at startup (e.g. device still booting), the code retries every few seconds until the device appears. The exit-on-loss behaviour only applies after a successful streaming session drops.
For native (non-Docker) usage, wrap the command in a process supervisor or a simple restart loop:
while true; do
python3 run_stream.py
echo "[supervisor] Process exited ($?), restarting in 5s..."
sleep 5
doneThe PlutoSDR ships with RNDIS-mode USB networking, which macOS does not support natively. There are two options:
Option A — Switch Pluto to NCM mode (recommended for Ethernet-over-USB):
- Plug in the PlutoSDR. It appears as a USB mass-storage drive (
PlutoSDR). - Open
config.txton the drive and change:usb_ethernet_mode = ncm - Eject the drive and power-cycle the Pluto.
- After reboot, a new network interface should appear and the Pluto will be
reachable at
192.168.2.1.
You may also need to update the PlutoSDR firmware to a version that supports NCM. See the ADI firmware update guide.
Option B — Use the IIO USB backend (no network needed):
Run with PLUTO_URI=usb: — libiio communicates over raw USB, bypassing
Ethernet entirely. This is the simplest option on macOS:
PLUTO_URI=usb: python3 run_stream.py# Check that libbladeRF sees the device:
bladeRF-cli -p
# Check that SoapySDR sees the device:
SoapySDRUtil --find="driver=bladerf"
# If SoapySDRUtil shows nothing, verify the SoapyBladeRF module is installed:
SoapySDRUtil --info
# Look for "bladerf" in the list of available modules.The bladeRF 2.0 requires an FPGA bitstream. If the FPGA has not been flashed to auto-load (see setup sections above), it must be loaded manually at each power-on. Without a loaded FPGA, the app will fail on the first connection attempt and leave the bladeRF in a bad state that requires a USB power cycle (unplug and re-plug).
Recommended fix — flash once so it auto-loads permanently:
wget https://www.nuand.com/fpga/hostedxA4-latest.rbf -O /tmp/hostedxA4.rbf
bladeRF-cli -L /tmp/hostedxA4.rbfIf you only need to load for the current session (lowercase -l):
bladeRF-cli -l /tmp/hostedxA4.rbfFPGA v0.16.0 requires firmware >= 2.6.0. If you see errors like
FPGA v0.16.0 requires firmware v2.6.0+, update the firmware:
# Download latest firmware:
wget https://www.nuand.com/fx3/bladeRF_fw_latest.img -O /tmp/bladeRF_fw.img
# Flash it:
bladeRF-cli -f /tmp/bladeRF_fw.img
# IMPORTANT: power-cycle the bladeRF (unplug & re-plug) after flashing.
# Then reload the FPGA:
bladeRF-cli -l /tmp/hostedxA4.rbfCheck versions with bladeRF-cli -e version.
The bladeRF 2.0 has a known issue
with USB streaming stability on ARM-based platforms (Raspberry Pi 5, Apple
Silicon Macs). Symptoms include NIOS II response: Operation timed out and
bladerf_sync_rx() returned -1 errors, sometimes after only a few successful
reads.
Mitigations:
- Use GNU Radio (this project's default) rather than raw SoapySDR calls. GNU Radio's C++ streaming threads keep the USB transfer loop tight and unblocked, which significantly improves stability.
- Connect directly to the Mac — avoid USB hubs.
- Power-cycle the bladeRF if it gets into a bad state (streaming errors persist until the device is fully unplugged and reconnected).
- For reliable bladeRF operation, Linux x86 is recommended.
Docker Desktop for Mac runs Linux inside a lightweight VM. USB devices on the
Mac host are not visible inside this VM, so --privileged and --device
flags do not help.
Workaround options:
- Run natively (recommended) — see the macOS setup section above.
- Use OrbStack instead of Docker Desktop — it has experimental USB passthrough support.
- For PlutoSDR only: use Ethernet mode (
ip:192.168.2.1) which works fine through Docker on Mac, but requires the NCM firmware change described above.
If SoapySDRUtil --find="driver=plutosdr" detects the device but the app
fails with no device found in this context, the likely cause is a libiio
version mismatch between the host library (e.g. 0.23 from Ubuntu repos) and
the PlutoSDR firmware (e.g. 0.26).
This has already been fixed in the code — the app uses iio_create_context_from_uri()
(via the SoapySDR uri= key) instead of iio_create_network_context() (the
hostname= key), which tolerates version differences.
If you still see this error, verify with:
# Should show the PlutoSDR and its firmware version:
iio_info -s
# Should return device details (not an error):
iio_info -u ip:192.168.2.1GNU Radio's Python bindings are installed system-wide by the package manager
(Homebrew or apt), not via pip. If your venv was created without
--system-site-packages, it cannot see them.
Option A — Recreate the venv (cleanest):
rm -rf .venv
python3 -m venv --system-site-packages .venv
source .venv/bin/activate
pip install -e .Option B — Add a .pth file (if you want to keep your existing venv):
# macOS (Homebrew)
echo "$(brew --prefix gnuradio)/lib/python3.14/site-packages" \
> .venv/lib/python3.14/site-packages/gnuradio-brew.pth
# Linux (apt)
echo "/usr/lib/python3/dist-packages" \
> .venv/lib/python3.*/site-packages/gnuradio-apt.pthVerify with:
source .venv/bin/activate
python3 -c "from gnuradio import gr, soapy; print('OK')"If the SDR is not detected as a non-root user, you likely missed the udev rule during setup. See the udev steps in the Native on Linux section above. After applying the rule, unplug and re-plug the device.