#!/usr/bin/env python # # Horus Telemetry GUI # # Mark Jessop # # Python 3 check import sys if sys.version_info < (3, 0): print("This script requires Python 3!") sys.exit(1) import datetime import glob import logging import pyqtgraph as pg import numpy as np from queue import Queue from pyqtgraph.Qt import QtCore, QtGui, QtWidgets from pyqtgraph.dockarea import * from threading import Thread from .widgets import * from .audio import * from .fft import * from .modem import * from .config import * from . import __version__ # Setup Logging logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s", level=logging.INFO) # Global widget store widgets = {} # Queues for handling updates to image / status indications. fft_update_queue = Queue(256) status_update_queue = Queue(256) # List of audio devices and their info audio_devices = {} # Processor objects audio_stream = None fft_process = None horus_modem = None # Global running indicator running = False # # GUI Creation - The Bad way. # # Create a Qt App. pg.mkQApp() # GUI LAYOUT - Gtk Style! win = QtGui.QMainWindow() area = DockArea() win.setCentralWidget(area) win.setWindowTitle("Horus Telemetry GUI") # Create multiple dock areas, for displaying our data. d0 = Dock("Audio", size=(300, 50)) d0_modem = Dock("Modem", size=(300, 80)) d0_habitat = Dock("Habitat", size=(300, 200)) d0_other = Dock("Other", size=(300, 100)) d1 = Dock("Spectrum", size=(800, 400)) d2 = Dock("Modem Stats", size=(800, 300)) d3 = Dock("Data", size=(800, 50)) d4 = Dock("Log", size=(800, 150)) # Arrange docks. area.addDock(d0) area.addDock(d1, "right", d0) area.addDock(d0_modem, "bottom", d0) area.addDock(d0_habitat, "bottom", d0_modem) area.addDock(d0_other, "below", d0_habitat) area.addDock(d2, "bottom", d1) area.addDock(d3, "bottom", d2) area.addDock(d4, "bottom", d3) d0_habitat.raiseDock() # Controls w1_audio = pg.LayoutWidget() # TNC Connection widgets["audioDeviceLabel"] = QtGui.QLabel("Audio Device:") widgets["audioDeviceSelector"] = QtGui.QComboBox() widgets["audioSampleRateLabel"] = QtGui.QLabel("Sample Rate (Hz):") widgets["audioSampleRateSelector"] = QtGui.QComboBox() w1_audio.addWidget(widgets["audioDeviceLabel"], 0, 0, 1, 1) w1_audio.addWidget(widgets["audioDeviceSelector"], 0, 1, 1, 1) w1_audio.addWidget(widgets["audioSampleRateLabel"], 1, 0, 1, 1) w1_audio.addWidget(widgets["audioSampleRateSelector"], 1, 1, 1, 1) d0.addWidget(w1_audio) w1_modem = pg.LayoutWidget() # Modem Parameters widgets["horusModemLabel"] = QtGui.QLabel("Mode:") widgets["horusModemSelector"] = QtGui.QComboBox() widgets["horusModemRateLabel"] = QtGui.QLabel("Baudrate:") widgets["horusModemRateSelector"] = QtGui.QComboBox() widgets["horusMaskEstimatorLabel"] = QtGui.QLabel("Enable Mask Estim.:") widgets["horusMaskEstimatorSelector"] = QtGui.QCheckBox() widgets["horusMaskSpacingLabel"] = QtGui.QLabel("Tone Spacing (Hz):") widgets["horusMaskSpacingEntry"] = QtGui.QLineEdit("270") # Start/Stop widgets["startDecodeButton"] = QtGui.QPushButton("Start") w1_modem.addWidget(widgets["horusModemLabel"], 0, 0, 1, 1) w1_modem.addWidget(widgets["horusModemSelector"], 0, 1, 1, 1) w1_modem.addWidget(widgets["horusModemRateLabel"], 1, 0, 1, 1) w1_modem.addWidget(widgets["horusModemRateSelector"], 1, 1, 1, 1) w1_modem.addWidget(widgets["horusMaskEstimatorLabel"], 2, 0, 1, 1) w1_modem.addWidget(widgets["horusMaskEstimatorSelector"], 2, 1, 1, 1) w1_modem.addWidget(widgets["horusMaskSpacingLabel"], 3, 0, 1, 1) w1_modem.addWidget(widgets["horusMaskSpacingEntry"], 3, 1, 1, 1) w1_modem.addWidget(widgets["startDecodeButton"], 4, 0, 2, 2) d0_modem.addWidget(w1_modem) w1_habitat = pg.LayoutWidget() # Listener Information widgets["habitatHeading"] = QtGui.QLabel("Habitat Settings") widgets["habitatUploadLabel"] = QtGui.QLabel("Enable Habitat Upload:") widgets["habitatUploadSelector"] = QtGui.QCheckBox() widgets["habitatUploadSelector"].setChecked(True) widgets["userCallLabel"] = QtGui.QLabel("Callsign:") widgets["userCallEntry"] = QtGui.QLineEdit("N0CALL") widgets["userCallEntry"].setMaxLength(20) widgets["userLocationLabel"] = QtGui.QLabel("Lat/Lon:") widgets["userLatEntry"] = QtGui.QLineEdit("0.0") widgets["userLonEntry"] = QtGui.QLineEdit("0.0") widgets["userAntennaLabel"] = QtGui.QLabel("Antenna:") widgets["userAntennaEntry"] = QtGui.QLineEdit("") widgets["userRadioLabel"] = QtGui.QLabel("Radio:") widgets["userRadioEntry"] = QtGui.QLineEdit("Horus-GUI " + __version__) w1_habitat.addWidget(widgets["habitatUploadLabel"], 0, 0, 1, 1) w1_habitat.addWidget(widgets["habitatUploadSelector"], 0, 1, 1, 1) w1_habitat.addWidget(widgets["userCallLabel"], 1, 0, 1, 1) w1_habitat.addWidget(widgets["userCallEntry"], 1, 1, 1, 2) w1_habitat.addWidget(widgets["userLocationLabel"], 2, 0, 1, 1) w1_habitat.addWidget(widgets["userLatEntry"], 2, 1, 1, 1) w1_habitat.addWidget(widgets["userLonEntry"], 2, 2, 1, 1) w1_habitat.addWidget(widgets["userAntennaLabel"], 3, 0, 1, 1) w1_habitat.addWidget(widgets["userAntennaEntry"], 3, 1, 1, 2) w1_habitat.addWidget(widgets["userRadioLabel"], 4, 0, 1, 1) w1_habitat.addWidget(widgets["userRadioEntry"], 4, 1, 1, 2) d0_habitat.addWidget(w1_habitat) w1_other = pg.LayoutWidget() widgets["horusUploadLabel"] = QtGui.QLabel("Enable Horus UDP Output:") widgets["horusUploadSelector"] = QtGui.QCheckBox() widgets["horusUploadSelector"].setChecked(True) widgets["horusUDPLabel"] = QtGui.QLabel("Horus UDP Port:") widgets["horusUDPEntry"] = QtGui.QLineEdit("55672") widgets["horusUDPEntry"].setMaxLength(5) w1_other.addWidget(widgets["horusUploadLabel"], 0, 0, 1, 1) w1_other.addWidget(widgets["horusUploadSelector"], 0, 1, 1, 1) w1_other.addWidget(widgets["horusUDPLabel"], 1, 0, 1, 1) w1_other.addWidget(widgets["horusUDPEntry"], 1, 1, 1, 1) d0_other.addWidget(w1_other) # Spectrum Display widgets["spectrumPlot"] = pg.PlotWidget(title="Spectra") widgets["spectrumPlot"].setLabel("left", "Power (dB)") widgets["spectrumPlot"].setLabel("bottom", "Frequency (Hz)") widgets["spectrumPlotData"] = widgets["spectrumPlot"].plot([0]) widgets["spectrumPlot"].setLabel("left", "Power (dBFs)") widgets["spectrumPlot"].setLabel("bottom", "Frequency", units="Hz") widgets["spectrumPlot"].setXRange(100, 4000) widgets["spectrumPlot"].setYRange(-100, -20) widgets["spectrumPlot"].setLimits(xMin=0, xMax=4000, yMin=-120, yMax=0) # Frequency Estiator Outputs widgets["estimatorLines"] = [ pg.InfiniteLine( pos=-1000, pen=pg.mkPen(color="w", width=2, style=QtCore.Qt.DashLine), label="F1", ), pg.InfiniteLine( pos=-1000, pen=pg.mkPen(color="w", width=2, style=QtCore.Qt.DashLine), label="F2", ), pg.InfiniteLine( pos=-1000, pen=pg.mkPen(color="w", width=2, style=QtCore.Qt.DashLine), label="F3", ), pg.InfiniteLine( pos=-1000, pen=pg.mkPen(color="w", width=2, style=QtCore.Qt.DashLine), label="F4", ), ] for _line in widgets["estimatorLines"]: widgets["spectrumPlot"].addItem(_line) d1.addWidget(widgets["spectrumPlot"]) widgets["spectrumPlotRange"] = [-100, -20] # Waterfall - TBD w3 = pg.LayoutWidget() widgets["snrPlot"] = pg.PlotWidget(title="SNR") widgets["snrPlot"].setLabel("left", "SNR (dB)") widgets["snrPlot"].setLabel("bottom", "Time (s)") widgets["snrPlot"].setXRange(-60, 0) widgets["snrPlot"].setYRange(-10, 30) widgets["snrPlot"].setLimits(xMin=0, xMax=60, yMin=-100, yMax=40) widgets["snrPlotRange"] = [-10, 30] widgets["eyeDiagramPlot"] = pg.PlotWidget(title="Eye Diagram") w3.addWidget(widgets["snrPlot"], 0, 0) w3.addWidget(widgets["eyeDiagramPlot"], 0, 1) d2.addWidget(w3) # Telemetry Data w4 = pg.LayoutWidget() widgets["latestSentenceLabel"] = QtGui.QLabel("Latest Sentence:") widgets["latestSentenceData"] = QtGui.QLabel("NO DATA") widgets["latestSentenceData"].setFont(QtGui.QFont("Courier New", 18, QtGui.QFont.Bold)) w4.addWidget(widgets["latestSentenceLabel"], 0, 0, 1, 1) w4.addWidget(widgets["latestSentenceData"], 0, 1, 1, 6) d3.addWidget(w4) w5 = pg.LayoutWidget() widgets["console"] = QtWidgets.QPlainTextEdit() widgets["console"].setReadOnly(True) w5.addWidget(widgets["console"]) d4.addWidget(w5) # Resize window to final resolution, and display. logging.info("Starting GUI.") win.resize(1500, 800) win.show() # Audio Initialization audio_devices = init_audio(widgets) def update_audio_sample_rates(): """ Update the sample-rate dropdown when a different audio device is selected. """ global widgets # Pass widgets straight on to function from .audio populate_sample_rates(widgets) widgets["audioDeviceSelector"].currentIndexChanged.connect(update_audio_sample_rates) # Initialize modem list. init_horus_modem(widgets) def update_modem_settings(): """ Update the modem setting widgets when a different modem is selected """ global widgets populate_modem_settings(widgets) widgets["horusModemSelector"].currentIndexChanged.connect(update_modem_settings) # Read in configuration file settings read_config(widgets) def handle_fft_update(data): """ Handle a new FFT update """ global widgets _scale = data["scale"] _data = data["fft"] widgets["spectrumPlotData"].setData(_scale, _data) # Really basic IIR to smoothly adjust scale _old_max = widgets["spectrumPlotRange"][1] _tc = 0.1 _new_max = float((_old_max * (1 - _tc)) + (np.max(_data) * _tc)) # Store new max widgets["spectrumPlotRange"][1] = _new_max widgets["spectrumPlot"].setYRange( widgets["spectrumPlotRange"][0], min(0, _new_max) + 20 ) def add_fft_update(data): """ Try and insert a new set of FFT data into the update queue """ global fft_update_queue try: fft_update_queue.put_nowait(data) except: logging.error("FFT Update Queue Full!") def start_decoding(): global widgets, audio_stream, fft_process, horus_modem, audio_devices, running, fft_update_queue, status_update_queue if not running: # Grab settings off widgets _dev_name = widgets["audioDeviceSelector"].currentText() _sample_rate = int(widgets["audioSampleRateSelector"].currentText()) _dev_index = audio_devices[_dev_name]["index"] # TODO: Grab horus data here. # Init FFT Processor fft_process = FFTProcess( nfft=8192, stride=4096, fs=_sample_rate, callback=add_fft_update ) # TODO: Setup modem here # Setup Audio audio_stream = AudioStream( _dev_index, fs=_sample_rate, block_size=fft_process.stride, fft_input=fft_process.add_samples, modem=None, ) widgets["startDecodeButton"].setText("Stop") running = True logging.info("Started Audio Processing.") else: try: audio_stream.stop() except Exception as e: logging.exception("Could not stop audio stream.", exc_info=e) try: fft_process.stop() except Exception as e: logging.exception("Could not stop fft processing.", exc_info=e) fft_update_queue = Queue(256) status_update_queue = Queue(256) widgets["startDecodeButton"].setText("Start") running = False logging.info("Stopped Audio Processing.") widgets["startDecodeButton"].clicked.connect(start_decoding) # GUI Update Loop def processQueues(): """ Read in data from the queues, this decouples the GUI and async inputs somewhat. """ global fft_update_queue, status_update_queue while fft_update_queue.qsize() > 0: _data = fft_update_queue.get() handle_fft_update(_data) while status_update_queue.qsize() > 0: _status = status_update_queue.get() # Handle Status updates here. gui_update_timer = QtCore.QTimer() gui_update_timer.timeout.connect(processQueues) gui_update_timer.start(100) class ConsoleHandler(logging.Handler): """ Logging handler to write to the GUI console """ def __init__(self, consolewidget): logging.Handler.__init__(self) self.consolewidget = consolewidget def emit(self, record): _time = datetime.datetime.now() _text = f"{record.levelname} {_time.strftime('%H:%M:%S')}:\t{record.msg}" self.consolewidget.appendPlainText(_text) # Add console handler to top level logger. console_handler = ConsoleHandler(widgets["console"]) logging.getLogger().addHandler(console_handler) logging.info("Started GUI.") # Main def main(): # Start the Qt Loop if (sys.flags.interactive != 1) or not hasattr(QtCore, "PYQT_VERSION"): QtGui.QApplication.instance().exec_() save_config(widgets) try: audio_stream.stop() except Exception as e: pass try: fft_process.stop() except Exception as e: pass if __name__ == "__main__": main()