Skip to content
Open
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
2 changes: 1 addition & 1 deletion PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ pkgdesc="Spectrum analyzer for multiple SDR platforms (PyQtGraph based GUI for s
arch=('any')
url="https://github.com/xmikos/qspectrumanalyzer"
license=('GPL3')
depends=('python-qt.py' 'python-pyqt5' 'python-pyqtgraph' 'soapy_power>=1.6.0')
depends=('python-pyqt6' 'python-pyqtgraph' 'soapy_power>=1.6.0')
makedepends=('python-setuptools')
optdepends=(
'hackrf: hackrf_sweep backend (wideband spectrum monitoring with sweep rate of 8 GHz/s)'
Expand Down
20 changes: 15 additions & 5 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ Requirements
------------

- Python >= 3.3
- PyQt4 / PyQt5 / PySide / PySide2
- Qt.py (https://github.com/mottosso/Qt.py)
- PyQtGraph (http://www.pyqtgraph.org)
- PyQt6
- PyQtGraph (http://www.pyqtgraph.org) (Qt6-compatible release)
- soapy_power (https://github.com/xmikos/soapy_power)
- Optional: hackrf / rtl-sdr / rtl_power_fftw / rx_tools

Expand Down Expand Up @@ -168,16 +167,27 @@ You can also install QSpectrumAnalyzer manually from `PyPI <https://pypi.python.
(bundled as part of Pothos SDR installer: `download <http://downloads.myriadrf.org/builds/PothosSDR/?C=M;O=D>`_)
3. Open ``cmd.exe`` and run::

pip install PyQt5
pip install PyQt6
pip install QSpectrumAnalyzer

You should then be able to run it with ``qspectrumanalyzer`` (or ``python -m qspectrumanalyzer``
if it doesn't work for you).

Exporting FFT history
---------------------

Use *File -> Export FFT history...* to save the current waterfall buffer to ``.npz`` (NumPy archive)
or ``.csv`` for offline analysis.

DragonOS
--------

QSpectrumAnalyzer is preinstalled in DragonOS, so you can launch it immediately without extra setup.
Dependencies (PyQt6, pyqtgraph, SDR backends) are already bundled in the distro.

Todo:
-----

- save FFT history (allow big waterfall plot saved to file)
- automatic peak detection / highlighting
- display average noise level
- frequency markers / bookmarks with notes (even importing and exporting .csv file with
Expand Down
81 changes: 53 additions & 28 deletions qspectrumanalyzer/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import sys, os, signal, time, argparse

from Qt import QtCore, QtGui, QtWidgets
from PyQt6 import QtCore, QtGui, QtWidgets

from qspectrumanalyzer import backends
from qspectrumanalyzer.version import __version__
Expand Down Expand Up @@ -31,6 +31,7 @@ def __init__(self, parent=None):
# Initialize UI
super().__init__(parent)
self.setupUi(self)
self.add_menu_actions()

# Set window icon
icon_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "qspectrumanalyzer.svg")
Expand Down Expand Up @@ -63,6 +64,12 @@ def __init__(self, parent=None):
self.update_buttons()
self.load_settings()

def add_menu_actions(self):
"""Add extra File menu actions"""
self.action_ExportHistory = QtGui.QAction(self.tr("Export FFT history..."), self)
self.action_ExportHistory.triggered.connect(self.on_action_ExportHistory_triggered)
self.menu_File.insertAction(self.action_Quit, self.action_ExportHistory)

def setup_power_thread(self):
"""Create power_thread and connect signals to slots"""
if self.power_thread:
Expand Down Expand Up @@ -275,9 +282,9 @@ def update_progress(self, value):
elif value > value_max:
value = value_max
else:
self.progressbar.setRange(0, value_max)
self.progressbar.setRange(0, int(value_max))

self.progressbar.setValue(value)
self.progressbar.setValue(int(value))

def on_power_thread_started(self):
"""Update buttons state when power thread is started"""
Expand All @@ -300,7 +307,7 @@ def start(self, single_shot=False):
self.start_timestamp = self.prev_data_timestamp

if self.intervalSpinBox.value() >= 1:
self.progressbar.setRange(0, self.intervalSpinBox.value() * 1000)
self.progressbar.setRange(0, int(self.intervalSpinBox.value()) * 1000)
else:
self.progressbar.setRange(0, 0)
self.update_progress(0)
Expand Down Expand Up @@ -363,55 +370,55 @@ def stop(self):
if self.power_thread.alive:
self.power_thread.stop()

@QtCore.Slot()
@QtCore.pyqtSlot()
def on_startButton_clicked(self):
self.start()

@QtCore.Slot()
@QtCore.pyqtSlot()
def on_singleShotButton_clicked(self):
self.start(single_shot=True)

@QtCore.Slot()
@QtCore.pyqtSlot()
def on_stopButton_clicked(self):
self.stop()

@QtCore.Slot(bool)
@QtCore.pyqtSlot(bool)
def on_mainCurveCheckBox_toggled(self, checked):
self.spectrumPlotWidget.main_curve = checked
if self.spectrumPlotWidget.curve.xData is None:
self.spectrumPlotWidget.update_plot(self.data_storage)
self.spectrumPlotWidget.curve.setVisible(checked)

@QtCore.Slot(bool)
@QtCore.pyqtSlot(bool)
def on_peakHoldMaxCheckBox_toggled(self, checked):
self.spectrumPlotWidget.peak_hold_max = checked
if self.spectrumPlotWidget.curve_peak_hold_max.xData is None:
self.spectrumPlotWidget.update_peak_hold_max(self.data_storage)
self.spectrumPlotWidget.curve_peak_hold_max.setVisible(checked)

@QtCore.Slot(bool)
@QtCore.pyqtSlot(bool)
def on_peakHoldMinCheckBox_toggled(self, checked):
self.spectrumPlotWidget.peak_hold_min = checked
if self.spectrumPlotWidget.curve_peak_hold_min.xData is None:
self.spectrumPlotWidget.update_peak_hold_min(self.data_storage)
self.spectrumPlotWidget.curve_peak_hold_min.setVisible(checked)

@QtCore.Slot(bool)
@QtCore.pyqtSlot(bool)
def on_averageCheckBox_toggled(self, checked):
self.spectrumPlotWidget.average = checked
if self.spectrumPlotWidget.curve_average.xData is None:
self.spectrumPlotWidget.update_average(self.data_storage)
self.spectrumPlotWidget.curve_average.setVisible(checked)

@QtCore.Slot(bool)
@QtCore.pyqtSlot(bool)
def on_persistenceCheckBox_toggled(self, checked):
self.spectrumPlotWidget.persistence = checked
if self.spectrumPlotWidget.persistence_curves[0].xData is None:
self.spectrumPlotWidget.recalculate_persistence(self.data_storage)
for curve in self.spectrumPlotWidget.persistence_curves:
curve.setVisible(checked)

@QtCore.Slot(bool)
@QtCore.pyqtSlot(bool)
def on_smoothCheckBox_toggled(self, checked):
settings = QtCore.QSettings()
self.data_storage.set_smooth(
Expand All @@ -420,47 +427,47 @@ def on_smoothCheckBox_toggled(self, checked):
settings.value("smooth_window", "hanning")
)

@QtCore.Slot(bool)
@QtCore.pyqtSlot(bool)
def on_baselineCheckBox_toggled(self, checked):
self.spectrumPlotWidget.baseline = checked
if self.spectrumPlotWidget.curve_baseline.xData is None:
self.spectrumPlotWidget.update_baseline(self.data_storage)
self.spectrumPlotWidget.curve_baseline.setVisible(checked)

@QtCore.Slot(bool)
@QtCore.pyqtSlot(bool)
def on_subtractBaselineCheckBox_toggled(self, checked):
settings = QtCore.QSettings()
self.data_storage.set_subtract_baseline(
checked,
settings.value("baseline_file", None)
)

@QtCore.Slot()
@QtCore.pyqtSlot()
def on_baselineButton_clicked(self):
dialog = QSpectrumAnalyzerBaseline(self)
if dialog.exec_():
if dialog.exec():
settings = QtCore.QSettings()
self.data_storage.set_subtract_baseline(
bool(self.subtractBaselineCheckBox.isChecked()),
settings.value("baseline_file", None)
)

@QtCore.Slot()
@QtCore.pyqtSlot()
def on_smoothButton_clicked(self):
dialog = QSpectrumAnalyzerSmoothing(self)
if dialog.exec_():
if dialog.exec():
settings = QtCore.QSettings()
self.data_storage.set_smooth(
bool(self.smoothCheckBox.isChecked()),
settings.value("smooth_length", 11, int),
settings.value("smooth_window", "hanning")
)

@QtCore.Slot()
@QtCore.pyqtSlot()
def on_persistenceButton_clicked(self):
prev_persistence_length = self.spectrumPlotWidget.persistence_length
dialog = QSpectrumAnalyzerPersistence(self)
if dialog.exec_():
if dialog.exec():
settings = QtCore.QSettings()
persistence_length = settings.value("persistence_length", 5, int)
self.spectrumPlotWidget.persistence_length = persistence_length
Expand All @@ -472,10 +479,10 @@ def on_persistenceButton_clicked(self):
else:
self.spectrumPlotWidget.recalculate_persistence(self.data_storage)

@QtCore.Slot()
@QtCore.pyqtSlot()
def on_colorsButton_clicked(self):
dialog = QSpectrumAnalyzerColors(self)
if dialog.exec_():
if dialog.exec():
settings = QtCore.QSettings()
self.spectrumPlotWidget.main_color = str_to_color(settings.value("main_color", "255, 255, 0, 255"))
self.spectrumPlotWidget.peak_hold_max_color = str_to_color(settings.value("peak_hold_max_color", "255, 0, 0, 255"))
Expand All @@ -485,18 +492,36 @@ def on_colorsButton_clicked(self):
self.spectrumPlotWidget.baseline_color = str_to_color(settings.value("baseline_color", "255, 0, 255, 255"))
self.spectrumPlotWidget.set_colors()

@QtCore.Slot()
@QtCore.pyqtSlot()
def on_action_Settings_triggered(self):
dialog = QSpectrumAnalyzerSettings(self)
if dialog.exec_():
if dialog.exec():
# Force reinitialization so newly selected backend defaults are applied
self.backend = None
self.setup_power_thread()

@QtCore.Slot()
@QtCore.pyqtSlot()
def on_action_About_triggered(self):
QtWidgets.QMessageBox.information(self, self.tr("About - QSpectrumAnalyzer"),
self.tr("QSpectrumAnalyzer {}").format(__version__))

@QtCore.Slot()
@QtCore.pyqtSlot()
def on_action_ExportHistory_triggered(self):
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
self.tr("Export FFT history"),
filter=self.tr("NumPy archive (*.npz);;CSV file (*.csv);;All files (*)")
)
if not path:
return

success = self.data_storage.export_history(path)
if success:
self.show_status(self.tr("History exported to {}").format(path), timeout=4000)
else:
self.show_status(self.tr("No data to export"), timeout=4000)

@QtCore.pyqtSlot()
def on_action_Quit_triggered(self):
self.close()

Expand Down Expand Up @@ -533,7 +558,7 @@ def main():
app.setOrganizationDomain("qspectrumanalyzer.eutopia.cz")
app.setApplicationName("QSpectrumAnalyzer")
window = QSpectrumAnalyzerMainWindow()
sys.exit(app.exec_())
sys.exit(app.exec())
finally:
# Unhide console window on Windows (we don't want to leave zombies behind)
if sys.platform == 'win32' and not debug:
Expand Down
6 changes: 3 additions & 3 deletions qspectrumanalyzer/backends/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os, threading, shlex

from Qt import QtCore
from PyQt6 import QtCore

from qspectrumanalyzer import subprocess

Expand Down Expand Up @@ -53,8 +53,8 @@ def help_params(cls, executable):

class BasePowerThread(QtCore.QThread):
"""Thread which runs Power Spectral Density acquisition and calculation process"""
powerThreadStarted = QtCore.Signal()
powerThreadStopped = QtCore.Signal()
powerThreadStarted = QtCore.pyqtSignal()
powerThreadStopped = QtCore.pyqtSignal()

def __init__(self, data_storage, parent=None):
super().__init__(parent)
Expand Down
9 changes: 5 additions & 4 deletions qspectrumanalyzer/backends/hackrf_sweep.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import struct, shlex, sys, time

import numpy as np
from Qt import QtCore
from PyQt6 import QtCore

from qspectrumanalyzer import subprocess
from qspectrumanalyzer.backends import BaseInfo, BasePowerThread
Expand Down Expand Up @@ -85,7 +85,7 @@ def process_start(self):
"""Start hackrf_sweep process"""
if not self.process and self.params:
settings = QtCore.QSettings()
cmdline = shlex.split(settings.value("executable", "hackrf_sweep"))
cmdline = shlex.split(settings.value("executable_hackrf_sweep", "hackrf_sweep"))
cmdline.extend([
"-f", "{}:{}".format(int(self.params["start_freq"] - self.lnb_lo / 1e6),
int(self.params["stop_freq"] - self.lnb_lo / 1e6)),
Expand All @@ -102,7 +102,8 @@ def process_start(self):
if self.params["single_shot"]:
cmdline.append("-1")

additional_params = settings.value("params", Info.additional_params)
backend_name = __name__.rsplit(".", 1)[-1]
additional_params = settings.value(f"params_{backend_name}", Info.additional_params)
if additional_params:
cmdline.extend(shlex.split(additional_params))

Expand All @@ -115,7 +116,7 @@ def process_start(self):
def parse_output(self, buf):
"""Parse one buf of output from hackrf_sweep"""
(low_edge, high_edge) = struct.unpack('QQ', buf[:16])
data = np.fromstring(buf[16:], dtype='<f4')
data = np.frombuffer(buf[16:], dtype='<f4')
step = (high_edge - low_edge) / len(data)

if (low_edge // 1000000) <= (self.params["start_freq"] - self.lnb_lo / 1e6):
Expand Down
Loading