From f7138e0d21ff3a15201a88a101c17ab5efd22258 Mon Sep 17 00:00:00 2001 From: Andrew Koenig Date: Wed, 15 Jan 2025 23:50:46 -0600 Subject: [PATCH] Migrate to PyQt6 --- horusgui/__init__.py | 2 +- horusgui/gui.py | 2686 +++++++++++++++++++++--------------------- horusgui/widgets.py | 6 +- requirements.txt | 3 +- 4 files changed, 1357 insertions(+), 1340 deletions(-) diff --git a/horusgui/__init__.py b/horusgui/__init__.py index 08aad71..151610c 100755 --- a/horusgui/__init__.py +++ b/horusgui/__init__.py @@ -1 +1 @@ -__version__ = "0.3.19" +__version__ = "0.4.0-beta0" diff --git a/horusgui/gui.py b/horusgui/gui.py index fa5d1e0..d768f4d 100644 --- a/horusgui/gui.py +++ b/horusgui/gui.py @@ -22,9 +22,11 @@ import time import pyqtgraph as pg import numpy as np from queue import Queue -#from pyqtgraph.Qt import QtCore, QtGui, QtWidgets -from PyQt5 import QtWidgets, QtGui +from PyQt6.QtWidgets import * +from PyQt6.QtGui import * +from PyQt6.QtCore import * from pyqtgraph.dockarea import * +import qdarktheme from threading import Thread from .widgets import * @@ -44,44 +46,6 @@ from horusdemodlib.horusudp import send_payload_summary, send_ozimux_message from horusdemodlib.sondehubamateur import * from . import __version__ - -# A few hardcoded defaults -DEFAULT_ESTIMATOR_MIN = 100 -DEFAULT_ESTIMATOR_MAX = 4000 - - -# Global widget store -widgets = {} - -# Queues for handling updates to image / status indications. -fft_update_queue = Queue(1024) -status_update_queue = Queue(1024) -log_update_queue = Queue(2048) - -# List of audio devices and their info -audio_devices = {} - -# Processor objects -audio_stream = None -fft_process = None -horus_modem = None -sondehub_uploader = None -telemetry_logger = None - -decoder_init = False - -last_packet_time = None - - -# Rotator object -rotator = None -rotator_current_az = 0.0 -rotator_current_el = 0.0 - - -# Global running indicator -running = False - # Read command-line arguments parser = argparse.ArgumentParser(description="Project Horus GUI", formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument("--payload-id-list", type=str, default=None, help="Use supplied Payload ID List instead of downloading a new one.") @@ -101,1282 +65,1365 @@ logging.basicConfig( format="%(asctime)s %(levelname)s: %(message)s", level=_log_level ) -# -# GUI Creation - The Bad way. -# - -# Create a Qt App. -pg.mkQApp() - -# GUI LAYOUT - Gtk Style! -win = QtWidgets.QMainWindow() -area = DockArea() -win.setCentralWidget(area) -win.setWindowTitle(f"Horus Telemetry GUI - v{__version__}") -win.setWindowIcon(getHorusIcon()) - -# Create multiple dock areas, for displaying our data. -d0 = Dock("Audio", size=(300, 50)) -d0_modem = Dock("Modem", size=(300, 80)) -d0_habitat = Dock("SondeHub", size=(300, 200)) -d0_other = Dock("Other", size=(300, 100)) -d0_rotator = Dock("Rotator", size=(300, 100)) -d1 = Dock("Spectrum", size=(800, 350)) -d2_stats = Dock("SNR (dB)", size=(50, 300)) -d2_snr = Dock("SNR Plot", size=(750, 300)) -d3_data = Dock("Data", size=(800, 50)) -d3_position = Dock("Position", 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(d0_rotator, "below", d0_other) -area.addDock(d2_stats, "bottom", d1) -area.addDock(d3_data, "bottom", d2_stats) -area.addDock(d3_position, "bottom", d3_data) -area.addDock(d4, "bottom", d3_position) -area.addDock(d2_snr, "right", d2_stats) -d0_habitat.raiseDock() - - -# Controls -w1_audio = pg.LayoutWidget() -# TNC Connection -widgets["audioDeviceLabel"] = QtWidgets.QLabel("Audio Device:") -widgets["audioDeviceSelector"] = QtWidgets.QComboBox() - -widgets["audioSampleRateLabel"] = QtWidgets.QLabel("Sample Rate (Hz):") -widgets["audioSampleRateSelector"] = QtWidgets.QComboBox() - -widgets["audioDbfsLabel"] = QtWidgets.QLabel("Input Level (dBFS):") -widgets["audioDbfsValue"] = QtWidgets.QLabel("--") -widgets["audioDbfsValue_float"] = 0.0 - -w1_audio.addWidget(widgets["audioDeviceLabel"], 0, 0, 1, 1) -w1_audio.addWidget(widgets["audioDeviceSelector"], 0, 1, 1, 2) -w1_audio.addWidget(widgets["audioSampleRateLabel"], 1, 0, 1, 1) -w1_audio.addWidget(widgets["audioSampleRateSelector"], 1, 1, 1, 2) -w1_audio.addWidget(widgets["audioDbfsLabel"], 2, 0, 1, 1) -w1_audio.addWidget(widgets["audioDbfsValue"], 2, 1, 1, 2) -d0.addWidget(w1_audio) - -w1_modem = pg.LayoutWidget() - - -# Modem Parameters -widgets["horusModemLabel"] = QtWidgets.QLabel("Mode:") -widgets["horusModemSelector"] = QtWidgets.QComboBox() - -widgets["horusModemRateLabel"] = QtWidgets.QLabel("Baudrate:") -widgets["horusModemRateSelector"] = QtWidgets.QComboBox() - -widgets["horusMaskEstimatorLabel"] = QtWidgets.QLabel("Enable Mask Estim.:") -widgets["horusMaskEstimatorSelector"] = QtWidgets.QCheckBox() -widgets["horusMaskEstimatorSelector"].setToolTip( - "Enable the mask frequency estimator, which makes uses of the \n"\ - "tone spacing value entered below as extra input to the frequency\n"\ - "estimator. This can help decode performance in very weak signal conditions." -) - -widgets["horusMaskSpacingLabel"] = QtWidgets.QLabel("Tone Spacing (Hz):") -widgets["horusMaskSpacingEntry"] = QtWidgets.QLineEdit("270") -widgets["horusMaskSpacingEntry"].setToolTip( - "If the tone spacing of the transmitter is known, it can be entered here,\n"\ - "and used with the mask estimator option above. The default tone spacing for\n"\ - "a RS41-based transmitter is 270 Hz." -) -widgets["horusManualEstimatorLabel"] = QtWidgets.QLabel("Manual Estim. Limits:") -widgets["horusManualEstimatorSelector"] = QtWidgets.QCheckBox() -widgets["horusManualEstimatorSelector"].setToolTip( - "Enables manual selection of the frequency estimator limits. This will enable\n"\ - "a slidable area on the spectrum display, which can be used to select the frequency\n"\ - "range of interest, and help stop in-band CW interference from biasing the frequency\n"\ - "estimator. You can either click-and-drag the entire area, or click-and-drag the edges\n"\ - "to change the estimator frequency range." -) - -# Start/Stop -widgets["startDecodeButton"] = QtWidgets.QPushButton("Start") -widgets["startDecodeButton"].setEnabled(False) - -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["horusManualEstimatorLabel"], 4, 0, 1, 1) -w1_modem.addWidget(widgets["horusManualEstimatorSelector"], 4, 1, 1, 1) -w1_modem.addWidget(widgets["startDecodeButton"], 5, 0, 2, 2) - -d0_modem.addWidget(w1_modem) - - -w1_habitat = pg.LayoutWidget() -# Listener Information -widgets["habitatHeading"] = QtWidgets.QLabel("SondeHub Settings") -widgets["sondehubUploadLabel"] = QtWidgets.QLabel("Enable SondeHub-Ham Upload:") -widgets["sondehubUploadSelector"] = QtWidgets.QCheckBox() -widgets["sondehubUploadSelector"].setChecked(True) -widgets["userCallLabel"] = QtWidgets.QLabel("Callsign:") -widgets["userCallEntry"] = QtWidgets.QLineEdit("N0CALL") -widgets["userCallEntry"].setMaxLength(20) -widgets["userCallEntry"].setToolTip( - "Your station callsign, which doesn't necessarily need to be an\n"\ - "amateur radio callsign, just something unique!" -) -widgets["userLocationLabel"] = QtWidgets.QLabel("Latitude / Longitude:") -widgets["userLatEntry"] = QtWidgets.QLineEdit("0.0") -widgets["userLatEntry"].setToolTip("Station Latitude in Decimal Degrees, e.g. -34.123456") -widgets["userLonEntry"] = QtWidgets.QLineEdit("0.0") -widgets["userLonEntry"].setToolTip("Station Longitude in Decimal Degrees, e.g. 138.123456") -widgets["userAltitudeLabel"] = QtWidgets.QLabel("Altitude:") -widgets["userAltEntry"] = QtWidgets.QLineEdit("0.0") -widgets["userAltEntry"].setToolTip("Station Altitude in Metres Above Sea Level.") -widgets["userAntennaLabel"] = QtWidgets.QLabel("Antenna:") -widgets["userAntennaEntry"] = QtWidgets.QLineEdit("") -widgets["userAntennaEntry"].setToolTip("A text description of your station's antenna.") -widgets["userRadioLabel"] = QtWidgets.QLabel("Radio:") -widgets["userRadioEntry"] = QtWidgets.QLineEdit("Horus-GUI " + __version__) -widgets["userRadioEntry"].setToolTip( - "A text description of your station's radio setup.\n"\ - "This field will be automatically prefixed with Horus-GUI\n"\ - "and the Horus-GUI software version." -) -widgets["habitatUploadPosition"] = QtWidgets.QPushButton("Re-upload Station Info") -widgets["habitatUploadPosition"].setToolTip( - "Manually re-upload your station information to SondeHub-Amateur.\n"\ -) -widgets["dialFreqLabel"] = QtWidgets.QLabel("Radio Dial Freq (MHz):") -widgets["dialFreqEntry"] = QtWidgets.QLineEdit("") -widgets["dialFreqEntry"].setToolTip( - "Optional entry of your radio's dial frequency in MHz (e.g. 437.600).\n"\ - "Used to provide frequency information on SondeHub-Amateur."\ -) -widgets["sondehubPositionNotesLabel"] = QtWidgets.QLabel("") - -widgets["saveSettingsButton"] = QtWidgets.QPushButton("Save Settings") - -w1_habitat.addWidget(widgets["sondehubUploadLabel"], 0, 0, 1, 1) -w1_habitat.addWidget(widgets["sondehubUploadSelector"], 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["userAltitudeLabel"], 3, 0, 1, 1) -w1_habitat.addWidget(widgets["userAltEntry"], 3, 1, 1, 2) -w1_habitat.addWidget(widgets["userAntennaLabel"], 4, 0, 1, 1) -w1_habitat.addWidget(widgets["userAntennaEntry"], 4, 1, 1, 2) -w1_habitat.addWidget(widgets["userRadioLabel"], 5, 0, 1, 1) -w1_habitat.addWidget(widgets["userRadioEntry"], 5, 1, 1, 2) -w1_habitat.addWidget(widgets["dialFreqLabel"], 6, 0, 1, 1) -w1_habitat.addWidget(widgets["dialFreqEntry"], 6, 1, 1, 2) -w1_habitat.addWidget(widgets["habitatUploadPosition"], 7, 0, 1, 3) -w1_habitat.addWidget(widgets["sondehubPositionNotesLabel"], 8, 0, 1, 3) -w1_habitat.layout.setRowStretch(9, 1) -w1_habitat.addWidget(widgets["saveSettingsButton"], 10, 0, 1, 3) - -d0_habitat.addWidget(w1_habitat) - -w1_other = pg.LayoutWidget() -widgets["horusHeaderLabel"] = QtWidgets.QLabel("Telemetry Forwarding") -widgets["horusUploadLabel"] = QtWidgets.QLabel("Enable Horus UDP Output:") -widgets["horusUploadSelector"] = QtWidgets.QCheckBox() -widgets["horusUploadSelector"].setChecked(True) -widgets["horusUploadSelector"].setToolTip( - "Enable output of 'Horus UDP' JSON messages. These are emitted as a JSON object\n"\ - "and contain the fields: callsign, time, latitude, longitude, altitude, snr"\ -) -widgets["horusUDPLabel"] = QtWidgets.QLabel("Horus UDP Port:") -widgets["horusUDPEntry"] = QtWidgets.QLineEdit("55672") -widgets["horusUDPEntry"].setMaxLength(5) -widgets["horusUDPEntry"].setToolTip( - "UDP Port to output 'Horus UDP' JSON messages to." -) -widgets["ozimuxUploadLabel"] = QtWidgets.QLabel("Enable OziMux UDP Output:") -widgets["ozimuxUploadSelector"] = QtWidgets.QCheckBox() -widgets["ozimuxUploadSelector"].setChecked(False) -widgets["ozimuxUploadSelector"].setToolTip( - "Output OziMux UDP messages. These are of the form:\n"\ - "'TELEMETRY,HH:MM:SS,lat,lon,alt\\n'" -) -widgets["ozimuxUDPLabel"] = QtWidgets.QLabel("Ozimux UDP Port:") -widgets["ozimuxUDPEntry"] = QtWidgets.QLineEdit("55683") -widgets["ozimuxUDPEntry"].setMaxLength(5) -widgets["ozimuxUDPEntry"].setToolTip( - "UDP Port to output 'OziMux' UDP messages to." -) -widgets["loggingHeaderLabel"] = QtWidgets.QLabel("Logging") -widgets["enableLoggingLabel"] = QtWidgets.QLabel("Enable Logging:") -widgets["enableLoggingSelector"] = QtWidgets.QCheckBox() -widgets["enableLoggingSelector"].setChecked(False) -widgets["enableLoggingSelector"].setToolTip( - "Enable logging of received telemetry to disk (JSON)" -) -widgets["loggingFormatLabel"] = QtWidgets.QLabel("Log Format:") -widgets["loggingFormatSelector"] = QtWidgets.QComboBox() -widgets["loggingFormatSelector"].addItem("CSV") -widgets["loggingFormatSelector"].addItem("JSON") -widgets["loggingPathLabel"] = QtWidgets.QLabel("Log Directory:") -widgets["loggingPathEntry"] = QtWidgets.QLineEdit("") -widgets["loggingPathEntry"].setToolTip( - "Logging Directory" -) -widgets["selectLogDirButton"] = QtWidgets.QPushButton("Select Directory") - -widgets["otherHeaderLabel"] = QtWidgets.QLabel("Other Settings") -widgets["inhibitCRCLabel"] = QtWidgets.QLabel("Hide Failed CRC Errors:") -widgets["inhibitCRCSelector"] = QtWidgets.QCheckBox() -widgets["inhibitCRCSelector"].setChecked(True) -widgets["inhibitCRCSelector"].setToolTip( - "Hide CRC Failed error messages." -) - -w1_other.addWidget(widgets["horusHeaderLabel"], 0, 0, 1, 2) -w1_other.addWidget(widgets["horusUploadLabel"], 1, 0, 1, 1) -w1_other.addWidget(widgets["horusUploadSelector"], 1, 1, 1, 1) -w1_other.addWidget(widgets["horusUDPLabel"], 2, 0, 1, 1) -w1_other.addWidget(widgets["horusUDPEntry"], 2, 1, 1, 1) -w1_other.addWidget(widgets["ozimuxUploadLabel"], 3, 0, 1, 1) -w1_other.addWidget(widgets["ozimuxUploadSelector"], 3, 1, 1, 1) -w1_other.addWidget(widgets["ozimuxUDPLabel"], 4, 0, 1, 1) -w1_other.addWidget(widgets["ozimuxUDPEntry"], 4, 1, 1, 1) -w1_other.addWidget(widgets["loggingHeaderLabel"], 5, 0, 1, 2) -w1_other.addWidget(widgets["enableLoggingLabel"], 6, 0, 1, 1) -w1_other.addWidget(widgets["enableLoggingSelector"], 6, 1, 1, 1) -w1_other.addWidget(widgets["loggingFormatLabel"], 7, 0, 1, 1) -w1_other.addWidget(widgets["loggingFormatSelector"], 7, 1, 1, 1) -w1_other.addWidget(widgets["loggingPathLabel"], 8, 0, 1, 1) -w1_other.addWidget(widgets["loggingPathEntry"], 8, 1, 1, 1) -w1_other.addWidget(widgets["selectLogDirButton"], 9, 0, 1, 2) -w1_other.addWidget(widgets["otherHeaderLabel"], 10, 0, 1, 2) -w1_other.addWidget(widgets["inhibitCRCLabel"], 11, 0, 1, 1) -w1_other.addWidget(widgets["inhibitCRCSelector"], 11, 1, 1, 1) -w1_other.layout.setRowStretch(12, 1) - -d0_other.addWidget(w1_other) - - -w1_rotator = pg.LayoutWidget() -widgets["rotatorHeaderLabel"] = QtWidgets.QLabel("Rotator Control") - -widgets["rotatorTypeLabel"] = QtWidgets.QLabel("Rotator Type:") -widgets["rotatorTypeSelector"] = QtWidgets.QComboBox() -widgets["rotatorTypeSelector"].addItem("rotctld") -widgets["rotatorTypeSelector"].addItem("PSTRotator") - -widgets["rotatorHostLabel"] = QtWidgets.QLabel("Rotator Hostname:") -widgets["rotatorHostEntry"] = QtWidgets.QLineEdit("localhost") -widgets["rotatorHostEntry"].setToolTip( - "Hostname of the rotctld or PSTRotator Server.\n"\ -) - -widgets["rotatorPortLabel"] = QtWidgets.QLabel("Rotator TCP/UDP Port:") -widgets["rotatorPortEntry"] = QtWidgets.QLineEdit("4533") -widgets["rotatorPortEntry"].setMaxLength(5) -widgets["rotatorPortEntry"].setToolTip( - "TCP (rotctld) or UDP (PSTRotator) port to connect to.\n"\ - "Default for rotctld: 4533\n"\ - "Default for PSTRotator: 12000" -) -widgets["rotatorThresholdLabel"] = QtWidgets.QLabel("Rotator Movement Threshold:") -widgets["rotatorThresholdEntry"] = QtWidgets.QLineEdit("5.0") -widgets["rotatorThresholdEntry"].setToolTip( - "Only move if the angle between the payload position and \n"\ - "the current rotator position is more than this, in degrees." -) - -widgets["rotatorConnectButton"] = QtWidgets.QPushButton("Start") - -widgets["rotatorCurrentStatusLabel"] = QtWidgets.QLabel("Status:") -widgets["rotatorCurrentStatusValue"] = QtWidgets.QLabel("Not Started.") - -widgets["rotatorCurrentPositionLabel"] = QtWidgets.QLabel("Commanded Az/El:") -widgets["rotatorCurrentPositionValue"] = QtWidgets.QLabel("---˚, --˚") - - - -w1_rotator.addWidget(widgets["rotatorHeaderLabel"], 0, 0, 1, 2) -w1_rotator.addWidget(widgets["rotatorTypeLabel"], 1, 0, 1, 1) -w1_rotator.addWidget(widgets["rotatorTypeSelector"], 1, 1, 1, 1) -w1_rotator.addWidget(widgets["rotatorHostLabel"], 2, 0, 1, 1) -w1_rotator.addWidget(widgets["rotatorHostEntry"], 2, 1, 1, 1) -w1_rotator.addWidget(widgets["rotatorPortLabel"], 3, 0, 1, 1) -w1_rotator.addWidget(widgets["rotatorPortEntry"], 3, 1, 1, 1) -#w1_rotator.addWidget(widgets["rotatorThresholdLabel"], 4, 0, 1, 1) -#w1_rotator.addWidget(widgets["rotatorThresholdEntry"], 4, 1, 1, 1) -w1_rotator.addWidget(widgets["rotatorConnectButton"], 4, 0, 1, 2) -w1_rotator.addWidget(widgets["rotatorCurrentStatusLabel"], 5, 0, 1, 1) -w1_rotator.addWidget(widgets["rotatorCurrentStatusValue"], 5, 1, 1, 1) -w1_rotator.addWidget(widgets["rotatorCurrentPositionLabel"], 6, 0, 1, 1) -w1_rotator.addWidget(widgets["rotatorCurrentPositionValue"], 6, 1, 1, 1) - -w1_rotator.layout.setRowStretch(7, 1) - -d0_rotator.addWidget(w1_rotator) - - -# 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]) - -# Frequency Estiator Outputs -widgets["estimatorLines"] = [ - pg.InfiniteLine( - pos=-1000, - pen=pg.mkPen(color="w", width=2, style=QtCore.Qt.PenStyle.DashLine), - label="F1", - labelOpts={'position':0.9} - ), - pg.InfiniteLine( - pos=-1000, - pen=pg.mkPen(color="w", width=2, style=QtCore.Qt.PenStyle.DashLine), - label="F2", - labelOpts={'position':0.9} - ), - pg.InfiniteLine( - pos=-1000, - pen=pg.mkPen(color="w", width=2, style=QtCore.Qt.PenStyle.DashLine), - label="F3", - labelOpts={'position':0.9} - ), - pg.InfiniteLine( - pos=-1000, - pen=pg.mkPen(color="w", width=2, style=QtCore.Qt.PenStyle.DashLine), - label="F4", - labelOpts={'position':0.9} - ), -] -for _line in widgets["estimatorLines"]: - widgets["spectrumPlot"].addItem(_line) - -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=100, xMax=4000, yMin=-120, yMax=0) -widgets["spectrumPlot"].showGrid(True, True) - -widgets["estimatorRange"] = pg.LinearRegionItem([100,3000]) -widgets["estimatorRange"].setBounds([100,4000]) - -d1.addWidget(widgets["spectrumPlot"]) - -widgets["spectrumPlotRange"] = [-100, -20] - - -w3_stats = pg.LayoutWidget() -widgets["snrBar"] = QtWidgets.QProgressBar() -widgets["snrBar"].setOrientation(QtCore.Qt.Orientation.Vertical) -widgets["snrBar"].setRange(-10, 15) -widgets["snrBar"].setValue(-10) -widgets["snrBar"].setTextVisible(False) -widgets["snrBar"].setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) -widgets["snrLabel"] = QtWidgets.QLabel("--.-") -widgets["snrLabel"].setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter); -widgets["snrLabel"].setFont(QtGui.QFont("Courier New", 14)) -w3_stats.addWidget(widgets["snrBar"], 0, 1, 1, 1) -w3_stats.addWidget(widgets["snrLabel"], 1, 0, 1, 3) -w3_stats.layout.setColumnStretch(0, 2) -w3_stats.layout.setColumnStretch(2, 2) - -d2_stats.addWidget(w3_stats) - -# SNR Plot -w3_snr = 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=-60, xMax=0, yMin=-10, yMax=40) -widgets["snrPlot"].showGrid(True, True) -widgets["snrPlotRange"] = [-10, 30] -widgets["snrPlotTime"] = np.array([]) -widgets["snrPlotSNR"] = np.array([]) -widgets["snrPlotData"] = widgets["snrPlot"].plot(widgets["snrPlotTime"], widgets["snrPlotSNR"]) - -# TODO: Look into eye diagram more -# widgets["eyeDiagramPlot"] = pg.PlotWidget(title="Eye Diagram") -# widgets["eyeDiagramData"] = widgets["eyeDiagramPlot"].plot([0]) - -#w3_snr.addWidget(widgets["snrPlot"], 0, 1, 2, 1) - -#w3.addWidget(widgets["eyeDiagramPlot"], 0, 1) - -d2_snr.addWidget(widgets["snrPlot"]) - -# Telemetry Data -w4_data = pg.LayoutWidget() -widgets["latestRawSentenceLabel"] = QtWidgets.QLabel("Latest Packet (Raw):") -widgets["latestRawSentenceData"] = QtWidgets.QLineEdit("NO DATA") -widgets["latestRawSentenceData"].setReadOnly(True) -widgets["latestDecodedSentenceLabel"] = QtWidgets.QLabel("Latest Packet (Decoded):") -widgets["latestDecodedSentenceData"] = QtWidgets.QLineEdit("NO DATA") -widgets["latestDecodedSentenceData"].setReadOnly(True) -widgets["latestDecodedAgeLabel"] = QtWidgets.QLabel("Last Packet Age:") -widgets["latestDecodedAgeData"] = QtWidgets.QLabel("No packet yet!") -w4_data.addWidget(widgets["latestRawSentenceLabel"], 0, 0, 1, 1) -w4_data.addWidget(widgets["latestRawSentenceData"], 0, 1, 1, 6) -w4_data.addWidget(widgets["latestDecodedSentenceLabel"], 1, 0, 1, 1) -w4_data.addWidget(widgets["latestDecodedSentenceData"], 1, 1, 1, 6) -w4_data.addWidget(widgets["latestDecodedAgeLabel"], 2, 0, 1, 1) -w4_data.addWidget(widgets["latestDecodedAgeData"], 2, 1, 1, 2) -d3_data.addWidget(w4_data) - -w4_position = pg.LayoutWidget() -# This font seems to look bigger in Windows... not sure why. -if 'Windows' in platform.system(): - POSITION_LABEL_FONT_SIZE = 14 -else: - POSITION_LABEL_FONT_SIZE = 16 - -widgets["latestPacketCallsignLabel"] = QtWidgets.QLabel("Callsign") -widgets["latestPacketCallsignValue"] = QtWidgets.QLabel("---") -widgets["latestPacketCallsignValue"].setFont(QtGui.QFont("Courier New", POSITION_LABEL_FONT_SIZE, QtGui.QFont.Weight.Bold)) -widgets["latestPacketTimeLabel"] = QtWidgets.QLabel("Time") -widgets["latestPacketTimeValue"] = QtWidgets.QLabel("---") -widgets["latestPacketTimeValue"].setFont(QtGui.QFont("Courier New", POSITION_LABEL_FONT_SIZE, QtGui.QFont.Weight.Bold)) -widgets["latestPacketLatitudeLabel"] = QtWidgets.QLabel("Latitude") -widgets["latestPacketLatitudeValue"] = QtWidgets.QLabel("---") -widgets["latestPacketLatitudeValue"].setFont(QtGui.QFont("Courier New", POSITION_LABEL_FONT_SIZE, QtGui.QFont.Weight.Bold)) -widgets["latestPacketLongitudeLabel"] = QtWidgets.QLabel("Longitude") -widgets["latestPacketLongitudeValue"] = QtWidgets.QLabel("---") -widgets["latestPacketLongitudeValue"].setFont(QtGui.QFont("Courier New", POSITION_LABEL_FONT_SIZE, QtGui.QFont.Weight.Bold)) -widgets["latestPacketAltitudeLabel"] = QtWidgets.QLabel("Altitude") -widgets["latestPacketAltitudeValue"] = QtWidgets.QLabel("---") -widgets["latestPacketAltitudeValue"].setFont(QtGui.QFont("Courier New", POSITION_LABEL_FONT_SIZE, QtGui.QFont.Weight.Bold)) -widgets["latestPacketBearingLabel"] = QtWidgets.QLabel("Bearing") -widgets["latestPacketBearingValue"] = QtWidgets.QLabel("---") -widgets["latestPacketBearingValue"].setFont(QtGui.QFont("Courier New", POSITION_LABEL_FONT_SIZE, QtGui.QFont.Weight.Bold)) -widgets["latestPacketElevationLabel"] = QtWidgets.QLabel("Elevation") -widgets["latestPacketElevationValue"] = QtWidgets.QLabel("---") -widgets["latestPacketElevationValue"].setFont(QtGui.QFont("Courier New", POSITION_LABEL_FONT_SIZE, QtGui.QFont.Weight.Bold)) -widgets["latestPacketRangeLabel"] = QtWidgets.QLabel("Range (km)") -widgets["latestPacketRangeValue"] = QtWidgets.QLabel("---") -widgets["latestPacketRangeValue"].setFont(QtGui.QFont("Courier New", POSITION_LABEL_FONT_SIZE, QtGui.QFont.Weight.Bold)) - -w4_position.addWidget(widgets["latestPacketCallsignLabel"], 0, 0, 1, 2) -w4_position.addWidget(widgets["latestPacketCallsignValue"], 1, 0, 1, 2) -w4_position.addWidget(widgets["latestPacketTimeLabel"], 0, 2, 1, 1) -w4_position.addWidget(widgets["latestPacketTimeValue"], 1, 2, 1, 1) -w4_position.addWidget(widgets["latestPacketLatitudeLabel"], 0, 3, 1, 1) -w4_position.addWidget(widgets["latestPacketLatitudeValue"], 1, 3, 1, 1) -w4_position.addWidget(widgets["latestPacketLongitudeLabel"], 0, 4, 1, 1) -w4_position.addWidget(widgets["latestPacketLongitudeValue"], 1, 4, 1, 1) -w4_position.addWidget(widgets["latestPacketAltitudeLabel"], 0, 5, 1, 1) -w4_position.addWidget(widgets["latestPacketAltitudeValue"], 1, 5, 1, 1) -w4_position.addWidget(widgets["latestPacketBearingLabel"], 0, 7, 1, 1) -w4_position.addWidget(widgets["latestPacketBearingValue"], 1, 7, 1, 1) -w4_position.addWidget(widgets["latestPacketElevationLabel"], 0, 8, 1, 1) -w4_position.addWidget(widgets["latestPacketElevationValue"], 1, 8, 1, 1) -w4_position.addWidget(widgets["latestPacketRangeLabel"], 0, 9, 1, 1) -w4_position.addWidget(widgets["latestPacketRangeValue"], 1, 9, 1, 1) -w4_position.layout.setRowStretch(1, 6) -d3_position.addWidget(w4_position) - -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) - - -def select_log_directory(): - global widgets - - folder = str(QtWidgets.QFileDialog.getExistingDirectory(None, "Select Directory")) - - if folder is None: - logging.info("No log directory selected.") - return False - else: - if folder == "": - logging.info("No log directory selected.") - return False - else: - widgets["loggingPathEntry"].setText(folder) - widgets["enableLoggingSelector"].setChecked(False) - if telemetry_logger: - widgets["enableLoggingSelector"].setChecked(True) - telemetry_logger.update_log_directory(widgets["loggingPathEntry"].text()) - telemetry_logger.enabled = True - - return True - -widgets["selectLogDirButton"].clicked.connect(select_log_directory) - - -def set_logging_state(): - global widgets - - logging_enabled = widgets["enableLoggingSelector"].isChecked() - - if logging_enabled: - if widgets["loggingPathEntry"].text() == "": - # No logging directory set, prompt user to select one. - _success = select_log_directory() - if not _success: - # User didn't select a directory, set checkbox to false again. - logging.error("No log directory selected, logging disabled.") - widgets["enableLoggingSelector"].setChecked(False) - # Disable logging. - if telemetry_logger: - telemetry_logger.enabled = False - - return - - # Enable logging - if telemetry_logger: - telemetry_logger.enabled = True - telemetry_logger.update_log_directory(widgets["loggingPathEntry"].text()) - - else: - # Disable logging - if telemetry_logger: - telemetry_logger.enabled = False - -widgets["enableLoggingSelector"].clicked.connect(set_logging_state) - -def set_logging_format(): - if telemetry_logger: - telemetry_logger.log_format = widgets["loggingFormatSelector"].currentText() - -widgets["loggingFormatSelector"].currentIndexChanged.connect(set_logging_format) - -# Clear the configuration if we have been asked to, otherwise read it in from Qt stores -if args.reset: - logging.info("Clearing configuration.") - write_config() -else: - read_config(widgets) - - -try: - if float(widgets["userLatEntry"].text()) == 0.0 and float(widgets["userLonEntry"].text()) == 0.0: - _sondehub_user_pos = None - else: - _sondehub_user_pos = [float(widgets["userLatEntry"].text()), float(widgets["userLonEntry"].text()), 0.0] -except: - _sondehub_user_pos = None - -sondehub_uploader = SondehubAmateurUploader( - upload_rate = 2, - user_callsign = widgets["userCallEntry"].text(), - user_position = _sondehub_user_pos, - user_radio = "Horus-GUI v" + __version__ + " " + widgets["userRadioEntry"].text(), - user_antenna = widgets["userAntennaEntry"].text(), - software_name = "Horus-GUI", - software_version = __version__, -) - -telemetry_logger = TelemetryLogger( - log_directory = widgets["loggingPathEntry"].text(), - log_format = widgets["loggingFormatSelector"].currentText(), - enabled = widgets["enableLoggingSelector"].isChecked() -) - -# Handlers for various checkboxes and push-buttons -def habitat_position_reupload(dummy_arg, upload=True): - """ - Trigger a re-upload of user position information - Note that this requires a dummy argument, as the Qt - 'connect' callback supplies an argument which we don't want. - """ - global widgets, sondehub_uploader - - sondehub_uploader.user_callsign = widgets["userCallEntry"].text() - sondehub_uploader.user_radio = "Horus-GUI v" + __version__ + " " + widgets["userRadioEntry"].text() - sondehub_uploader.user_antenna = widgets["userAntennaEntry"].text() - try: - if float(widgets["userLatEntry"].text()) == 0.0 and float(widgets["userLonEntry"].text()) == 0.0: - sondehub_uploader.user_position = None - else: - sondehub_uploader.user_position = [ - float(widgets["userLatEntry"].text()), - float(widgets["userLonEntry"].text()), - float(widgets["userAltEntry"].text())] - except Exception as e: - logging.error(f"Error parsing station location - {str(e)}") - sondehub_uploader.user_position = None - - if upload: - sondehub_uploader.last_user_position_upload = 0 - widgets["sondehubPositionNotesLabel"].setText("") - logging.info("Triggered user position re-upload.") - -# Connect the 'Re-upload Position' button to the above function. -widgets["habitatUploadPosition"].clicked.connect(habitat_position_reupload) - - -# Update uploader info as soon as it's edited, to ensure we upload with the latest user callsign -def update_uploader_details(): - """ - Wrapper function for position re-upload, called when the user callsign entry is changed. - """ - #habitat_position_reupload("unused arg",upload=False) - widgets["sondehubPositionNotesLabel"].setText("
Station Info out of date - click Re-Upload!
") - -# Connect all the station information fields to this function, so that when the user -# changes any of them they get a prompt to click the re-upload button. -widgets["userCallEntry"].textEdited.connect(update_uploader_details) -widgets["userRadioEntry"].textEdited.connect(update_uploader_details) -widgets["userAntennaEntry"].textEdited.connect(update_uploader_details) -widgets["userLatEntry"].textEdited.connect(update_uploader_details) -widgets["userLonEntry"].textEdited.connect(update_uploader_details) -widgets["userAltEntry"].textEdited.connect(update_uploader_details) - - -def habitat_inhibit(): - """ Update the Habitat inhibit flag """ - global widgets, sondehub_uploader - sondehub_uploader.inhibit = not widgets["sondehubUploadSelector"].isChecked() - logging.debug(f"Updated Sondebub Inhibit state: {sondehub_uploader.inhibit}") - -widgets["sondehubUploadSelector"].clicked.connect(habitat_inhibit) - - -def update_manual_estimator(): - """ Push a change to the manually defined estimator limits into the modem """ - global widgets, horus_modem - - _limits = widgets["estimatorRange"].getRegion() - - _lower = _limits[0] - _upper = _limits[1] - - if horus_modem != None: - horus_modem.set_estimator_limits(_lower, _upper) - -widgets["estimatorRange"].sigRegionChangeFinished.connect(update_manual_estimator) - - -def set_manual_estimator(): - """ Show or hide the manual estimator limit region """ - global widgets - if widgets["horusManualEstimatorSelector"].isChecked(): - widgets["spectrumPlot"].addItem(widgets["estimatorRange"]) - update_manual_estimator() - else: +# Establish signals and worker for multi-threaded use +class WorkerSignals(QObject): + finished = pyqtSignal() + error = pyqtSignal(tuple) + result = pyqtSignal(object) + info = pyqtSignal(object) + +class Worker(QRunnable): + def __init__(self, fn, *args, **kwargs): + super(Worker, self).__init__() + + self.fn = fn + self.args = args + self.kwargs = kwargs + self.signals = WorkerSignals() + + self.kwargs['info_callback'] = self.signals.info + + @pyqtSlot() + def run(self): try: - widgets["spectrumPlot"].removeItem(widgets["estimatorRange"]) - # Reset modem estimator limits to their defaults. - if horus_modem != None: - horus_modem.set_estimator_limits(DEFAULT_ESTIMATOR_MIN, DEFAULT_ESTIMATOR_MAX) + result = self.fn(*self.args, **self.kwargs) + except: + traceback.print_exc() + exctype, value = sys.exc_info()[:2] + self.signals.error.emit((exctype, value, traceback.format_exc())) + else: + self.signals.result.emit(result) + finally: + self.signals.finished.emit() + +def resource_path(relative_path): + try: + base_path = sys._MEIPASS + except Exception: + base_path = os.path.abspath(".") + + return os.path.join(base_path, relative_path) + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.resize(1500, 800) + + self.threadpool = QThreadPool() + self.stop_signal = False + + # A few hardcoded defaults + self.DEFAULT_ESTIMATOR_MIN = 100 + self.DEFAULT_ESTIMATOR_MAX = 4000 + + + # Global widget store + self.widgets = {} + + # Queues for handling updates to image / status indications. + self.fft_update_queue = Queue(1024) + self.status_update_queue = Queue(1024) + self.log_update_queue = Queue(2048) + + # List of audio devices and their info + self.audio_devices = {} + + # Processor objects + self.audio_stream = None + self.fft_process = None + self.horus_modem = None + self.sondehub_uploader = None + self.telemetry_logger = None + + self.decoder_init = False + + self.last_packet_time = None + + # Rotator object + self.rotator = None + self.rotator_current_az = 0.0 + self.rotator_current_el = 0.0 + + + # Global running indicator + self.running = False + + self.initialize() + + def initialize(self): + # + # GUI Creation - The Bad way. + # + + # Create a Qt App. + pg.mkQApp() + + # GUI LAYOUT - Gtk Style! + area = DockArea() + self.setCentralWidget(area) + self.setWindowTitle(f"Horus Telemetry GUI - v{__version__}") + self.setWindowIcon(getHorusIcon()) + + # Create multiple dock areas, for displaying our data. + d0 = Dock("Audio", size=(300, 50)) + d0_modem = Dock("Modem", size=(300, 80)) + d0_habitat = Dock("SondeHub", size=(300, 200)) + d0_other = Dock("Other", size=(300, 100)) + d0_rotator = Dock("Rotator", size=(300, 100)) + d1 = Dock("Spectrum", size=(800, 350)) + d2_stats = Dock("SNR (dB)", size=(50, 300)) + d2_snr = Dock("SNR Plot", size=(750, 300)) + d3_data = Dock("Data", size=(800, 50)) + d3_position = Dock("Position", 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(d0_rotator, "below", d0_other) + area.addDock(d2_stats, "bottom", d1) + area.addDock(d3_data, "bottom", d2_stats) + area.addDock(d3_position, "bottom", d3_data) + area.addDock(d4, "bottom", d3_position) + area.addDock(d2_snr, "right", d2_stats) + d0_habitat.raiseDock() + + + # Controls + w1_audio = pg.LayoutWidget() + # TNC Connection + self.widgets["audioDeviceLabel"] = QLabel("Audio Device:") + self.widgets["audioDeviceSelector"] = QComboBox() + self.widgets["audioDeviceSelector"].currentIndexChanged.connect(self.update_audio_sample_rates) + + self.widgets["audioSampleRateLabel"] = QLabel("Sample Rate (Hz):") + self.widgets["audioSampleRateSelector"] = QComboBox() + + self.widgets["audioDbfsLabel"] = QLabel("Input Level (dBFS):") + self.widgets["audioDbfsValue"] = QLabel("--") + self.widgets["audioDbfsValue_float"] = 0.0 + + w1_audio.addWidget(self.widgets["audioDeviceLabel"], 0, 0, 1, 1) + w1_audio.addWidget(self.widgets["audioDeviceSelector"], 0, 1, 1, 2) + w1_audio.addWidget(self.widgets["audioSampleRateLabel"], 1, 0, 1, 1) + w1_audio.addWidget(self.widgets["audioSampleRateSelector"], 1, 1, 1, 2) + w1_audio.addWidget(self.widgets["audioDbfsLabel"], 2, 0, 1, 1) + w1_audio.addWidget(self.widgets["audioDbfsValue"], 2, 1, 1, 2) + d0.addWidget(w1_audio) + + w1_modem = pg.LayoutWidget() + + + # Modem Parameters + self.widgets["horusModemLabel"] = QLabel("Mode:") + self.widgets["horusModemSelector"] = QComboBox() + self.widgets["horusModemSelector"].currentIndexChanged.connect(self.update_modem_settings) + + self.widgets["horusModemRateLabel"] = QLabel("Baudrate:") + self.widgets["horusModemRateSelector"] = QComboBox() + + self.widgets["horusMaskEstimatorLabel"] = QLabel("Enable Mask Estim.:") + self.widgets["horusMaskEstimatorSelector"] = QCheckBox() + self.widgets["horusMaskEstimatorSelector"].setToolTip( + "Enable the mask frequency estimator, which makes uses of the \n"\ + "tone spacing value entered below as extra input to the frequency\n"\ + "estimator. This can help decode performance in very weak signal conditions." + ) + + self.widgets["horusMaskSpacingLabel"] = QLabel("Tone Spacing (Hz):") + self.widgets["horusMaskSpacingEntry"] = QLineEdit("270") + self.widgets["horusMaskSpacingEntry"].setToolTip( + "If the tone spacing of the transmitter is known, it can be entered here,\n"\ + "and used with the mask estimator option above. The default tone spacing for\n"\ + "a RS41-based transmitter is 270 Hz." + ) + self.widgets["horusManualEstimatorLabel"] = QLabel("Manual Estim. Limits:") + self.widgets["horusManualEstimatorSelector"] = QCheckBox() + self.widgets["horusManualEstimatorSelector"].setToolTip( + "Enables manual selection of the frequency estimator limits. This will enable\n"\ + "a slidable area on the spectrum display, which can be used to select the frequency\n"\ + "range of interest, and help stop in-band CW interference from biasing the frequency\n"\ + "estimator. You can either click-and-drag the entire area, or click-and-drag the edges\n"\ + "to change the estimator frequency range." + ) + self.widgets["horusManualEstimatorSelector"].clicked.connect(self.set_manual_estimator) + + # Start/Stop + self.widgets["startDecodeButton"] = QPushButton("Start") + self.widgets["startDecodeButton"].setEnabled(False) + self.widgets["startDecodeButton"].clicked.connect(self.start_decoding) + + w1_modem.addWidget(self.widgets["horusModemLabel"], 0, 0, 1, 1) + w1_modem.addWidget(self.widgets["horusModemSelector"], 0, 1, 1, 1) + w1_modem.addWidget(self.widgets["horusModemRateLabel"], 1, 0, 1, 1) + w1_modem.addWidget(self.widgets["horusModemRateSelector"], 1, 1, 1, 1) + w1_modem.addWidget(self.widgets["horusMaskEstimatorLabel"], 2, 0, 1, 1) + w1_modem.addWidget(self.widgets["horusMaskEstimatorSelector"], 2, 1, 1, 1) + w1_modem.addWidget(self.widgets["horusMaskSpacingLabel"], 3, 0, 1, 1) + w1_modem.addWidget(self.widgets["horusMaskSpacingEntry"], 3, 1, 1, 1) + w1_modem.addWidget(self.widgets["horusManualEstimatorLabel"], 4, 0, 1, 1) + w1_modem.addWidget(self.widgets["horusManualEstimatorSelector"], 4, 1, 1, 1) + w1_modem.addWidget(self.widgets["startDecodeButton"], 5, 0, 2, 2) + + d0_modem.addWidget(w1_modem) + + + w1_habitat = pg.LayoutWidget() + # Listener Information + self.widgets["habitatHeading"] = QLabel("SondeHub Settings") + self.widgets["sondehubUploadLabel"] = QLabel("Enable SondeHub-Ham Upload:") + self.widgets["sondehubUploadSelector"] = QCheckBox() + self.widgets["sondehubUploadSelector"].setChecked(True) + self.widgets["sondehubUploadSelector"].clicked.connect(self.habitat_inhibit) + self.widgets["userCallLabel"] = QLabel("Callsign:") + self.widgets["userCallEntry"] = QLineEdit("N0CALL") + self.widgets["userCallEntry"].setMaxLength(20) + self.widgets["userCallEntry"].setToolTip( + "Your station callsign, which doesn't necessarily need to be an\n"\ + "amateur radio callsign, just something unique!" + ) + self.widgets["userCallEntry"].textEdited.connect(self.update_uploader_details) + self.widgets["userLocationLabel"] = QLabel("Latitude / Longitude:") + self.widgets["userLatEntry"] = QLineEdit("0.0") + self.widgets["userLatEntry"].setToolTip("Station Latitude in Decimal Degrees, e.g. -34.123456") + self.widgets["userLatEntry"].textEdited.connect(self.update_uploader_details) + self.widgets["userLonEntry"] = QLineEdit("0.0") + self.widgets["userLonEntry"].setToolTip("Station Longitude in Decimal Degrees, e.g. 138.123456") + self.widgets["userLonEntry"].textEdited.connect(self.update_uploader_details) + self.widgets["userAltitudeLabel"] = QLabel("Altitude:") + self.widgets["userAltEntry"] = QLineEdit("0.0") + self.widgets["userAltEntry"].setToolTip("Station Altitude in Metres Above Sea Level.") + self.widgets["userAltEntry"].textEdited.connect(self.update_uploader_details) + self.widgets["userAntennaLabel"] = QLabel("Antenna:") + self.widgets["userAntennaEntry"] = QLineEdit("") + self.widgets["userAntennaEntry"].setToolTip("A text description of your station's antenna.") + self.widgets["userAntennaEntry"].textEdited.connect(self.update_uploader_details) + self.widgets["userRadioLabel"] = QLabel("Radio:") + self.widgets["userRadioEntry"] = QLineEdit("Horus-GUI " + __version__) + self.widgets["userRadioEntry"].setToolTip( + "A text description of your station's radio setup.\n"\ + "This field will be automatically prefixed with Horus-GUI\n"\ + "and the Horus-GUI software version." + ) + self.widgets["userRadioEntry"].textEdited.connect(self.update_uploader_details) + self.widgets["habitatUploadPosition"] = QPushButton("Re-upload Station Info") + self.widgets["habitatUploadPosition"].setToolTip( + "Manually re-upload your station information to SondeHub-Amateur.\n"\ + ) + # Connect the 'Re-upload Position' button to the above function. + self.widgets["habitatUploadPosition"].clicked.connect(self.habitat_position_reupload) + self.widgets["dialFreqLabel"] = QLabel("Radio Dial Freq (MHz):") + self.widgets["dialFreqEntry"] = QLineEdit("") + self.widgets["dialFreqEntry"].setToolTip( + "Optional entry of your radio's dial frequency in MHz (e.g. 437.600).\n"\ + "Used to provide frequency information on SondeHub-Amateur."\ + ) + self.widgets["sondehubPositionNotesLabel"] = QLabel("") + + self.widgets["saveSettingsButton"] = QPushButton("Save Settings") + self.widgets["saveSettingsButton"].clicked.connect(self.save_settings) + + w1_habitat.addWidget(self.widgets["sondehubUploadLabel"], 0, 0, 1, 1) + w1_habitat.addWidget(self.widgets["sondehubUploadSelector"], 0, 1, 1, 1) + w1_habitat.addWidget(self.widgets["userCallLabel"], 1, 0, 1, 1) + w1_habitat.addWidget(self.widgets["userCallEntry"], 1, 1, 1, 2) + w1_habitat.addWidget(self.widgets["userLocationLabel"], 2, 0, 1, 1) + w1_habitat.addWidget(self.widgets["userLatEntry"], 2, 1, 1, 1) + w1_habitat.addWidget(self.widgets["userLonEntry"], 2, 2, 1, 1) + w1_habitat.addWidget(self.widgets["userAltitudeLabel"], 3, 0, 1, 1) + w1_habitat.addWidget(self.widgets["userAltEntry"], 3, 1, 1, 2) + w1_habitat.addWidget(self.widgets["userAntennaLabel"], 4, 0, 1, 1) + w1_habitat.addWidget(self.widgets["userAntennaEntry"], 4, 1, 1, 2) + w1_habitat.addWidget(self.widgets["userRadioLabel"], 5, 0, 1, 1) + w1_habitat.addWidget(self.widgets["userRadioEntry"], 5, 1, 1, 2) + w1_habitat.addWidget(self.widgets["dialFreqLabel"], 6, 0, 1, 1) + w1_habitat.addWidget(self.widgets["dialFreqEntry"], 6, 1, 1, 2) + w1_habitat.addWidget(self.widgets["habitatUploadPosition"], 7, 0, 1, 3) + w1_habitat.addWidget(self.widgets["sondehubPositionNotesLabel"], 8, 0, 1, 3) + w1_habitat.layout.setRowStretch(9, 1) + w1_habitat.addWidget(self.widgets["saveSettingsButton"], 10, 0, 1, 3) + + d0_habitat.addWidget(w1_habitat) + + w1_other = pg.LayoutWidget() + self.widgets["horusHeaderLabel"] = QLabel("Telemetry Forwarding") + self.widgets["horusUploadLabel"] = QLabel("Enable Horus UDP Output:") + self.widgets["horusUploadSelector"] = QCheckBox() + self.widgets["horusUploadSelector"].setChecked(True) + self.widgets["horusUploadSelector"].setToolTip( + "Enable output of 'Horus UDP' JSON messages. These are emitted as a JSON object\n"\ + "and contain the fields: callsign, time, latitude, longitude, altitude, snr"\ + ) + self.widgets["horusUDPLabel"] = QLabel("Horus UDP Port:") + self.widgets["horusUDPEntry"] = QLineEdit("55672") + self.widgets["horusUDPEntry"].setMaxLength(5) + self.widgets["horusUDPEntry"].setToolTip( + "UDP Port to output 'Horus UDP' JSON messages to." + ) + self.widgets["ozimuxUploadLabel"] = QLabel("Enable OziMux UDP Output:") + self.widgets["ozimuxUploadSelector"] = QCheckBox() + self.widgets["ozimuxUploadSelector"].setChecked(False) + self.widgets["ozimuxUploadSelector"].setToolTip( + "Output OziMux UDP messages. These are of the form:\n"\ + "'TELEMETRY,HH:MM:SS,lat,lon,alt\\n'" + ) + self.widgets["ozimuxUDPLabel"] = QLabel("Ozimux UDP Port:") + self.widgets["ozimuxUDPEntry"] = QLineEdit("55683") + self.widgets["ozimuxUDPEntry"].setMaxLength(5) + self.widgets["ozimuxUDPEntry"].setToolTip( + "UDP Port to output 'OziMux' UDP messages to." + ) + self.widgets["loggingHeaderLabel"] = QLabel("Logging") + self.widgets["enableLoggingLabel"] = QLabel("Enable Logging:") + self.widgets["enableLoggingSelector"] = QCheckBox() + self.widgets["enableLoggingSelector"].setChecked(False) + self.widgets["enableLoggingSelector"].setToolTip( + "Enable logging of received telemetry to disk (JSON)" + ) + self.widgets["enableLoggingSelector"].clicked.connect(self.set_logging_state) + self.widgets["loggingFormatLabel"] = QLabel("Log Format:") + self.widgets["loggingFormatSelector"] = QComboBox() + self.widgets["loggingFormatSelector"].addItem("CSV") + self.widgets["loggingFormatSelector"].addItem("JSON") + self.widgets["loggingFormatSelector"].currentIndexChanged.connect(self.set_logging_format) + self.widgets["loggingPathLabel"] = QLabel("Log Directory:") + self.widgets["loggingPathEntry"] = QLineEdit("") + self.widgets["loggingPathEntry"].setToolTip( + "Logging Directory" + ) + self.widgets["selectLogDirButton"] = QPushButton("Select Directory") + self.widgets["selectLogDirButton"].clicked.connect(self.select_log_directory) + + self.widgets["otherHeaderLabel"] = QLabel("Other Settings") + self.widgets["inhibitCRCLabel"] = QLabel("Hide Failed CRC Errors:") + self.widgets["inhibitCRCSelector"] = QCheckBox() + self.widgets["inhibitCRCSelector"].setChecked(True) + self.widgets["inhibitCRCSelector"].setToolTip( + "Hide CRC Failed error messages." + ) + + w1_other.addWidget(self.widgets["horusHeaderLabel"], 0, 0, 1, 2) + w1_other.addWidget(self.widgets["horusUploadLabel"], 1, 0, 1, 1) + w1_other.addWidget(self.widgets["horusUploadSelector"], 1, 1, 1, 1) + w1_other.addWidget(self.widgets["horusUDPLabel"], 2, 0, 1, 1) + w1_other.addWidget(self.widgets["horusUDPEntry"], 2, 1, 1, 1) + w1_other.addWidget(self.widgets["ozimuxUploadLabel"], 3, 0, 1, 1) + w1_other.addWidget(self.widgets["ozimuxUploadSelector"], 3, 1, 1, 1) + w1_other.addWidget(self.widgets["ozimuxUDPLabel"], 4, 0, 1, 1) + w1_other.addWidget(self.widgets["ozimuxUDPEntry"], 4, 1, 1, 1) + w1_other.addWidget(self.widgets["loggingHeaderLabel"], 5, 0, 1, 2) + w1_other.addWidget(self.widgets["enableLoggingLabel"], 6, 0, 1, 1) + w1_other.addWidget(self.widgets["enableLoggingSelector"], 6, 1, 1, 1) + w1_other.addWidget(self.widgets["loggingFormatLabel"], 7, 0, 1, 1) + w1_other.addWidget(self.widgets["loggingFormatSelector"], 7, 1, 1, 1) + w1_other.addWidget(self.widgets["loggingPathLabel"], 8, 0, 1, 1) + w1_other.addWidget(self.widgets["loggingPathEntry"], 8, 1, 1, 1) + w1_other.addWidget(self.widgets["selectLogDirButton"], 9, 0, 1, 2) + w1_other.addWidget(self.widgets["otherHeaderLabel"], 10, 0, 1, 2) + w1_other.addWidget(self.widgets["inhibitCRCLabel"], 11, 0, 1, 1) + w1_other.addWidget(self.widgets["inhibitCRCSelector"], 11, 1, 1, 1) + w1_other.layout.setRowStretch(12, 1) + + d0_other.addWidget(w1_other) + + + w1_rotator = pg.LayoutWidget() + self.widgets["rotatorHeaderLabel"] = QLabel("Rotator Control") + + self.widgets["rotatorTypeLabel"] = QLabel("Rotator Type:") + self.widgets["rotatorTypeSelector"] = QComboBox() + self.widgets["rotatorTypeSelector"].addItem("rotctld") + self.widgets["rotatorTypeSelector"].addItem("PSTRotator") + + self.widgets["rotatorHostLabel"] = QLabel("Rotator Hostname:") + self.widgets["rotatorHostEntry"] = QLineEdit("localhost") + self.widgets["rotatorHostEntry"].setToolTip( + "Hostname of the rotctld or PSTRotator Server.\n"\ + ) + + self.widgets["rotatorPortLabel"] = QLabel("Rotator TCP/UDP Port:") + self.widgets["rotatorPortEntry"] = QLineEdit("4533") + self.widgets["rotatorPortEntry"].setMaxLength(5) + self.widgets["rotatorPortEntry"].setToolTip( + "TCP (rotctld) or UDP (PSTRotator) port to connect to.\n"\ + "Default for rotctld: 4533\n"\ + "Default for PSTRotator: 12000" + ) + self.widgets["rotatorThresholdLabel"] = QLabel("Rotator Movement Threshold:") + self.widgets["rotatorThresholdEntry"] = QLineEdit("5.0") + self.widgets["rotatorThresholdEntry"].setToolTip( + "Only move if the angle between the payload position and \n"\ + "the current rotator position is more than this, in degrees." + ) + + self.widgets["rotatorConnectButton"] = QPushButton("Start") + self.widgets["rotatorConnectButton"].clicked.connect(self.startstop_rotator) + + self.widgets["rotatorCurrentStatusLabel"] = QLabel("Status:") + self.widgets["rotatorCurrentStatusValue"] = QLabel("Not Started.") + + self.widgets["rotatorCurrentPositionLabel"] = QLabel("Commanded Az/El:") + self.widgets["rotatorCurrentPositionValue"] = QLabel("---˚, --˚") + + + + w1_rotator.addWidget(self.widgets["rotatorHeaderLabel"], 0, 0, 1, 2) + w1_rotator.addWidget(self.widgets["rotatorTypeLabel"], 1, 0, 1, 1) + w1_rotator.addWidget(self.widgets["rotatorTypeSelector"], 1, 1, 1, 1) + w1_rotator.addWidget(self.widgets["rotatorHostLabel"], 2, 0, 1, 1) + w1_rotator.addWidget(self.widgets["rotatorHostEntry"], 2, 1, 1, 1) + w1_rotator.addWidget(self.widgets["rotatorPortLabel"], 3, 0, 1, 1) + w1_rotator.addWidget(self.widgets["rotatorPortEntry"], 3, 1, 1, 1) + #w1_rotator.addWidget(self.widgets["rotatorThresholdLabel"], 4, 0, 1, 1) + #w1_rotator.addWidget(self.widgets["rotatorThresholdEntry"], 4, 1, 1, 1) + w1_rotator.addWidget(self.widgets["rotatorConnectButton"], 4, 0, 1, 2) + w1_rotator.addWidget(self.widgets["rotatorCurrentStatusLabel"], 5, 0, 1, 1) + w1_rotator.addWidget(self.widgets["rotatorCurrentStatusValue"], 5, 1, 1, 1) + w1_rotator.addWidget(self.widgets["rotatorCurrentPositionLabel"], 6, 0, 1, 1) + w1_rotator.addWidget(self.widgets["rotatorCurrentPositionValue"], 6, 1, 1, 1) + + w1_rotator.layout.setRowStretch(7, 1) + + d0_rotator.addWidget(w1_rotator) + + + # Spectrum Display + self.widgets["spectrumPlot"] = pg.PlotWidget(title="Spectra") + self.widgets["spectrumPlot"].setLabel("left", "Power (dB)") + self.widgets["spectrumPlot"].setLabel("bottom", "Frequency (Hz)") + self.widgets["spectrumPlotData"] = self.widgets["spectrumPlot"].plot([0]) + + # Frequency Estiator Outputs + self.widgets["estimatorLines"] = [ + pg.InfiniteLine( + pos=-1000, + pen=pg.mkPen(color="w", width=2, style=QtCore.Qt.PenStyle.DashLine), + label="F1", + labelOpts={'position':0.9} + ), + pg.InfiniteLine( + pos=-1000, + pen=pg.mkPen(color="w", width=2, style=QtCore.Qt.PenStyle.DashLine), + label="F2", + labelOpts={'position':0.9} + ), + pg.InfiniteLine( + pos=-1000, + pen=pg.mkPen(color="w", width=2, style=QtCore.Qt.PenStyle.DashLine), + label="F3", + labelOpts={'position':0.9} + ), + pg.InfiniteLine( + pos=-1000, + pen=pg.mkPen(color="w", width=2, style=QtCore.Qt.PenStyle.DashLine), + label="F4", + labelOpts={'position':0.9} + ), + ] + for _line in self.widgets["estimatorLines"]: + self.widgets["spectrumPlot"].addItem(_line) + + self.widgets["spectrumPlot"].setLabel("left", "Power (dBFs)") + self.widgets["spectrumPlot"].setLabel("bottom", "Frequency", units="Hz") + self.widgets["spectrumPlot"].setXRange(100, 4000) + self.widgets["spectrumPlot"].setYRange(-100, -20) + self.widgets["spectrumPlot"].setLimits(xMin=100, xMax=4000, yMin=-120, yMax=0) + self.widgets["spectrumPlot"].showGrid(True, True) + + self.widgets["estimatorRange"] = pg.LinearRegionItem([100,3000]) + self.widgets["estimatorRange"].setBounds([100,4000]) + self.widgets["estimatorRange"].sigRegionChangeFinished.connect(self.update_manual_estimator) + + d1.addWidget(self.widgets["spectrumPlot"]) + + self.widgets["spectrumPlotRange"] = [-100, -20] + + + w3_stats = pg.LayoutWidget() + self.widgets["snrBar"] = QProgressBar() + self.widgets["snrBar"].setOrientation(QtCore.Qt.Orientation.Vertical) + self.widgets["snrBar"].setRange(-10, 15) + self.widgets["snrBar"].setValue(-10) + self.widgets["snrBar"].setTextVisible(False) + self.widgets["snrBar"].setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.widgets["snrLabel"] = QLabel("--.-") + self.widgets["snrLabel"].setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter); + self.widgets["snrLabel"].setFont(QFont("Courier New", 14)) + w3_stats.addWidget(self.widgets["snrBar"], 0, 1, 1, 1) + w3_stats.addWidget(self.widgets["snrLabel"], 1, 0, 1, 3) + w3_stats.layout.setColumnStretch(0, 2) + w3_stats.layout.setColumnStretch(2, 2) + + d2_stats.addWidget(w3_stats) + + # SNR Plot + w3_snr = pg.LayoutWidget() + self.widgets["snrPlot"] = pg.PlotWidget(title="SNR") + self.widgets["snrPlot"].setLabel("left", "SNR (dB)") + self.widgets["snrPlot"].setLabel("bottom", "Time (s)") + self.widgets["snrPlot"].setXRange(-60, 0) + self.widgets["snrPlot"].setYRange(-10, 30) + self.widgets["snrPlot"].setLimits(xMin=-60, xMax=0, yMin=-10, yMax=40) + self.widgets["snrPlot"].showGrid(True, True) + self.widgets["snrPlotRange"] = [-10, 30] + self.widgets["snrPlotTime"] = np.array([]) + self.widgets["snrPlotSNR"] = np.array([]) + self.widgets["snrPlotData"] = self.widgets["snrPlot"].plot(self.widgets["snrPlotTime"], self.widgets["snrPlotSNR"]) + + # TODO: Look into eye diagram more + # self.widgets["eyeDiagramPlot"] = pg.PlotWidget(title="Eye Diagram") + # self.widgets["eyeDiagramData"] = self.widgets["eyeDiagramPlot"].plot([0]) + + #w3_snr.addWidget(self.widgets["snrPlot"], 0, 1, 2, 1) + + #w3.addWidget(self.widgets["eyeDiagramPlot"], 0, 1) + + d2_snr.addWidget(self.widgets["snrPlot"]) + + # Telemetry Data + w4_data = pg.LayoutWidget() + self.widgets["latestRawSentenceLabel"] = QLabel("Latest Packet (Raw):") + self.widgets["latestRawSentenceData"] = QLineEdit("NO DATA") + self.widgets["latestRawSentenceData"].setReadOnly(True) + self.widgets["latestDecodedSentenceLabel"] = QLabel("Latest Packet (Decoded):") + self.widgets["latestDecodedSentenceData"] = QLineEdit("NO DATA") + self.widgets["latestDecodedSentenceData"].setReadOnly(True) + self.widgets["latestDecodedAgeLabel"] = QLabel("Last Packet Age:") + self.widgets["latestDecodedAgeData"] = QLabel("No packet yet!") + w4_data.addWidget(self.widgets["latestRawSentenceLabel"], 0, 0, 1, 1) + w4_data.addWidget(self.widgets["latestRawSentenceData"], 0, 1, 1, 6) + w4_data.addWidget(self.widgets["latestDecodedSentenceLabel"], 1, 0, 1, 1) + w4_data.addWidget(self.widgets["latestDecodedSentenceData"], 1, 1, 1, 6) + w4_data.addWidget(self.widgets["latestDecodedAgeLabel"], 2, 0, 1, 1) + w4_data.addWidget(self.widgets["latestDecodedAgeData"], 2, 1, 1, 2) + d3_data.addWidget(w4_data) + + w4_position = pg.LayoutWidget() + # This font seems to look bigger in Windows... not sure why. + if 'Windows' in platform.system(): + POSITION_LABEL_FONT_SIZE = 14 + else: + POSITION_LABEL_FONT_SIZE = 16 + + self.widgets["latestPacketCallsignLabel"] = QLabel("Callsign") + self.widgets["latestPacketCallsignValue"] = QLabel("---") + self.widgets["latestPacketCallsignValue"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + self.widgets["latestPacketTimeLabel"] = QLabel("Time") + self.widgets["latestPacketTimeValue"] = QLabel("---") + self.widgets["latestPacketTimeValue"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + self.widgets["latestPacketLatitudeLabel"] = QLabel("Latitude") + self.widgets["latestPacketLatitudeValue"] = QLabel("---") + self.widgets["latestPacketLatitudeValue"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + self.widgets["latestPacketLongitudeLabel"] = QLabel("Longitude") + self.widgets["latestPacketLongitudeValue"] = QLabel("---") + self.widgets["latestPacketLongitudeValue"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + self.widgets["latestPacketAltitudeLabel"] = QLabel("Altitude") + self.widgets["latestPacketAltitudeValue"] = QLabel("---") + self.widgets["latestPacketAltitudeValue"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + self.widgets["latestPacketBearingLabel"] = QLabel("Bearing") + self.widgets["latestPacketBearingValue"] = QLabel("---") + self.widgets["latestPacketBearingValue"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + self.widgets["latestPacketElevationLabel"] = QLabel("Elevation") + self.widgets["latestPacketElevationValue"] = QLabel("---") + self.widgets["latestPacketElevationValue"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + self.widgets["latestPacketRangeLabel"] = QLabel("Range (km)") + self.widgets["latestPacketRangeValue"] = QLabel("---") + self.widgets["latestPacketRangeValue"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + + w4_position.addWidget(self.widgets["latestPacketCallsignLabel"], 0, 0, 1, 2) + w4_position.addWidget(self.widgets["latestPacketCallsignValue"], 1, 0, 1, 2) + w4_position.addWidget(self.widgets["latestPacketTimeLabel"], 0, 2, 1, 1) + w4_position.addWidget(self.widgets["latestPacketTimeValue"], 1, 2, 1, 1) + w4_position.addWidget(self.widgets["latestPacketLatitudeLabel"], 0, 3, 1, 1) + w4_position.addWidget(self.widgets["latestPacketLatitudeValue"], 1, 3, 1, 1) + w4_position.addWidget(self.widgets["latestPacketLongitudeLabel"], 0, 4, 1, 1) + w4_position.addWidget(self.widgets["latestPacketLongitudeValue"], 1, 4, 1, 1) + w4_position.addWidget(self.widgets["latestPacketAltitudeLabel"], 0, 5, 1, 1) + w4_position.addWidget(self.widgets["latestPacketAltitudeValue"], 1, 5, 1, 1) + w4_position.addWidget(self.widgets["latestPacketBearingLabel"], 0, 7, 1, 1) + w4_position.addWidget(self.widgets["latestPacketBearingValue"], 1, 7, 1, 1) + w4_position.addWidget(self.widgets["latestPacketElevationLabel"], 0, 8, 1, 1) + w4_position.addWidget(self.widgets["latestPacketElevationValue"], 1, 8, 1, 1) + w4_position.addWidget(self.widgets["latestPacketRangeLabel"], 0, 9, 1, 1) + w4_position.addWidget(self.widgets["latestPacketRangeValue"], 1, 9, 1, 1) + w4_position.layout.setRowStretch(1, 6) + d3_position.addWidget(w4_position) + + w5 = pg.LayoutWidget() + self.widgets["console"] = QPlainTextEdit() + self.widgets["console"].setReadOnly(True) + w5.addWidget(self.widgets["console"]) + d4.addWidget(w5) + + # Resize window to final resolution, and display. + logging.info("Starting GUI.") + self.resize(1500, 800) + + self.post_initialize() + + + def post_initialize(self): + # Audio Initialization + self.audio_devices = init_audio(self.widgets) + + # Initialize modem list. + init_horus_modem(self.widgets) + + # Clear the configuration if we have been asked to, otherwise read it in from Qt stores + if args.reset: + logging.info("Clearing configuration.") + write_config() + else: + read_config(self.widgets) + + + try: + if float(self.widgets["userLatEntry"].text()) == 0.0 and float(self.widgets["userLonEntry"].text()) == 0.0: + _sondehub_user_pos = None + else: + _sondehub_user_pos = [float(self.widgets["userLatEntry"].text()), float(self.widgets["userLonEntry"].text()), 0.0] + except: + _sondehub_user_pos = None + + self.sondehub_uploader = SondehubAmateurUploader( + upload_rate = 2, + user_callsign = self.widgets["userCallEntry"].text(), + user_position = _sondehub_user_pos, + user_radio = "Horus-GUI v" + __version__ + " " + self.widgets["userRadioEntry"].text(), + user_antenna = self.widgets["userAntennaEntry"].text(), + software_name = "Horus-GUI", + software_version = __version__, + ) + + self.telemetry_logger = TelemetryLogger( + log_directory = self.widgets["loggingPathEntry"].text(), + log_format = self.widgets["loggingFormatSelector"].currentText(), + enabled = self.widgets["enableLoggingSelector"].isChecked() + ) + + self.gui_update_timer = QTimer() + self.gui_update_timer.timeout.connect(self.processQueues) + self.gui_update_timer.start(100) + + # Add console handler to top level logger. + console_handler = ConsoleHandler(self.log_update_queue) + logging.getLogger().addHandler(console_handler) + + logging.info("Started GUI.") + + + def cleanup(self): + try: + self.audio_stream.stop() + except Exception as e: + pass + + try: + self.fft_process.stop() + except Exception as e: + pass + + try: + self.sondehub_uploader.close() except: pass -widgets["horusManualEstimatorSelector"].clicked.connect(set_manual_estimator) - - -def save_settings(): - """ Manually save current settings """ - global widgets - save_config(widgets) - -widgets["saveSettingsButton"].clicked.connect(save_settings) - - -# Handlers for data arriving via queues. - -def handle_fft_update(data): - """ Handle a new FFT update """ - global widgets - - _scale = data["scale"] - _data = data["fft"] - _dbfs = data["dbfs"] - - 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] = max(widgets["spectrumPlotRange"][0], _new_max) - - widgets["spectrumPlot"].setYRange( - widgets["spectrumPlotRange"][0], widgets["spectrumPlotRange"][1] + 20 - ) - - # Ignore NaN values. - if np.isnan(_dbfs) or np.isinf(_dbfs): - return - - - # Use same IIR to smooth out dBFS readings a little. - _new_dbfs = float((widgets["audioDbfsValue_float"] * (1 - _tc)) + (_dbfs * _tc)) - - # Set dBFS value - if (_new_dbfs>-5.0): - _dbfs_ok = "TOO HIGH" - elif (_new_dbfs < -90.0): - _dbfs_ok = "NO AUDIO?" - elif (_new_dbfs < -50.0): - _dbfs_ok = "LOW" - else: - _dbfs_ok = "GOOD" - - widgets["audioDbfsValue"].setText(f"{_new_dbfs:.0f}\t{_dbfs_ok}") - widgets["audioDbfsValue_float"] = _new_dbfs - -def handle_status_update(status): - """ Handle a new status frame """ - global widgets, habitat - - # Update Frequency estimator markers - _fest_average = 0.0 - _fest_count = 0 - for _i in range(len(status.extended_stats.f_est)): - _fest_pos = float(status.extended_stats.f_est[_i]) - if _fest_pos != 0.0: - _fest_average += _fest_pos - _fest_count += 1 - widgets["estimatorLines"][_i].setPos(_fest_pos) - - _fest_average = _fest_average/_fest_count - widgets["fest_float"] = _fest_average - - # Update SNR Plot - _time = time.time() - # Roll Time/SNR - widgets["snrPlotTime"] = np.append(widgets["snrPlotTime"], _time) - widgets["snrPlotSNR"] = np.append(widgets["snrPlotSNR"], float(status.snr)) - if len(widgets["snrPlotTime"]) > 200: - widgets["snrPlotTime"] = widgets["snrPlotTime"][1:] - widgets["snrPlotSNR"] = widgets["snrPlotSNR"][1:] - - # Plot new SNR data - widgets["snrPlotData"].setData((widgets["snrPlotTime"]-_time), widgets["snrPlotSNR"]) - _old_max = widgets["snrPlotRange"][1] - _tc = 0.1 - _new_max = float((_old_max * (1 - _tc)) + (np.max(widgets["snrPlotSNR"]) * _tc)) - widgets["snrPlotRange"][1] = _new_max - widgets["snrPlot"].setYRange( - widgets["snrPlotRange"][0], _new_max+10 - ) - - # Update SNR bar and label - widgets["snrLabel"].setText(f"{float(status.snr):2.1f}") - widgets["snrBar"].setValue(int(status.snr)) - - -def get_latest_snr(): - global widgets - - _current_modem = widgets["horusModemSelector"].currentText() - - _snr_update_rate = 2 # Hz - - if "RTTY" in _current_modem: - # RTTY needs a much longer lookback period to find the peak SNR - # This is because of a very long buffer used in the RTTY demod - _snr_lookback = _snr_update_rate * 15 - else: - # For Horus Binary we can use a smaller lookback time - _snr_lookback = _snr_update_rate * 4 - - if len(widgets["snrPlotSNR"])>_snr_lookback: - return np.max(widgets["snrPlotSNR"][-1*_snr_lookback:]) - else: - return np.max(widgets["snrPlotSNR"]) - - - - -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 add_stats_update(frame): - """ Try and insert modem statistics into the processing queue """ - global status_update_queue - try: - status_update_queue.put_nowait(frame) - except: - logging.error("Status Update Queue Full!") - - - - -def handle_new_packet(frame): - """ Handle receipt of a newly decoded packet """ - global last_packet_time - - if len(frame.data) > 0: - if type(frame.data) == bytes: - # Packets from the binary decoders are provided as raw bytes. - # Conver them to a hexadecimal representation for display in the 'raw' area. - _packet = frame.data.hex().upper() - else: - # RTTY packets are provided as a string, and can be displayed directly - _packet = frame.data - - - - _decoded = None - - # Grab SNR. - _snr = get_latest_snr() - #logging.info(f"Packet SNR: {_snr:.2f}") - - - # Grab other metadata out of the GUI - _radio_dial = None - - if widgets["dialFreqEntry"].text() != "": - try: - _radio_dial = float(widgets["dialFreqEntry"].text())*1e6 - if widgets["fest_float"]: - # Add on the centre frequency estimation onto the dial frequency. - _radio_dial += widgets["fest_float"] - - except: - logging.warning("Could not parse radio dial frequency. This must be in MMM.KKK format e.g. 437.600") - _radio_dial = None - - - _baud_rate = int(widgets["horusModemRateSelector"].currentText()) - _modulation_detail = HORUS_MODEM_LIST[widgets["horusModemSelector"].currentText()]['modulation_detail'] - - if type(frame.data) == str: - # RTTY packet handling. - # Attempt to extract fields from it: - try: - _decoded = parse_ukhas_string(frame.data) - _decoded['snr'] = _snr - _decoded['baud_rate'] = _baud_rate - if _modulation_detail: - _decoded['modulation_detail'] = _modulation_detail - if _radio_dial: - _decoded['f_centre'] = _radio_dial - # If we get here, the string is valid! - widgets["latestRawSentenceData"].setText(f"{_packet} ({_snr:.1f} dB SNR)") - widgets["latestDecodedSentenceData"].setText(f"{_packet}") - last_packet_time = time.time() - - # Upload the string to Sondehub Amateur - if widgets["userCallEntry"].text() == "N0CALL": - logging.warning("Uploader callsign is set as N0CALL. Please change this, otherwise telemetry data may be discarded!") - - sondehub_uploader.add(_decoded) - - except Exception as e: - if "CRC Failure" in str(e) and widgets["inhibitCRCSelector"].isChecked(): - pass - else: - widgets["latestRawSentenceData"].setText(f"{_packet} ({_snr:.1f} dB SNR)") - widgets["latestDecodedSentenceData"].setText("DECODE FAILED") - logging.error(f"Decode Failed: {str(e)}") - - else: - # Handle binary packets - try: - _decoded = decode_packet(frame.data) - _decoded['snr'] = _snr - _decoded['baud_rate'] = _baud_rate - if _modulation_detail: - _decoded['modulation_detail'] = _modulation_detail - if _radio_dial: - _decoded['f_centre'] = _radio_dial - - widgets["latestRawSentenceData"].setText(f"{_packet} ({_snr:.1f} dB SNR)") - widgets["latestDecodedSentenceData"].setText(_decoded['ukhas_str']) - last_packet_time = time.time() - # Upload the string to Sondehub Amateur - if widgets["userCallEntry"].text() == "N0CALL": - logging.warning("Uploader callsign is set as N0CALL. Please change this, otherwise telemetry data may be discarded!") - - sondehub_uploader.add(_decoded) - except Exception as e: - if "CRC Failure" in str(e) and widgets["inhibitCRCSelector"].isChecked(): - pass - else: - widgets["latestRawSentenceData"].setText(f"{_packet} ({_snr:.1f} dB SNR)") - widgets["latestDecodedSentenceData"].setText("DECODE FAILED") - logging.error(f"Decode Failed: {str(e)}") - - # If we have extracted data, update the decoded data display - if _decoded: - widgets["latestPacketCallsignValue"].setText(_decoded['callsign']) - widgets["latestPacketTimeValue"].setText(_decoded['time']) - widgets["latestPacketLatitudeValue"].setText(f"{_decoded['latitude']:.5f}") - widgets["latestPacketLongitudeValue"].setText(f"{_decoded['longitude']:.5f}") - widgets["latestPacketAltitudeValue"].setText(f"{_decoded['altitude']}") - - # Attempt to update the range/elevation/bearing fields. - try: - _station_lat = float(widgets["userLatEntry"].text()) - _station_lon = float(widgets["userLonEntry"].text()) - _station_alt = float(widgets["userAltEntry"].text()) - - if (_station_lat != 0.0) or (_station_lon != 0.0): - _position_info = position_info( - (_station_lat, _station_lon, _station_alt), - (_decoded['latitude'], _decoded['longitude'], _decoded['altitude']) - ) - - widgets['latestPacketBearingValue'].setText(f"{_position_info['bearing']:.1f}") - widgets['latestPacketElevationValue'].setText(f"{_position_info['elevation']:.1f}") - widgets['latestPacketRangeValue'].setText(f"{_position_info['straight_distance']/1000.0:.1f}") - - if rotator and not ( _decoded['latitude'] == 0.0 and _decoded['longitude'] == 0.0 ): - try: - rotator.set_azel(_position_info['bearing'], _position_info['elevation'], check_response=False) - widgets["rotatorCurrentPositionValue"].setText(f"{_position_info['bearing']:3.1f}˚, {_position_info['elevation']:2.1f}˚") - except Exception as e: - logging.error("Rotator - Error setting Position: " + str(e)) - - except Exception as e: - logging.error(f"Could not calculate relative position to payload - {str(e)}") - - # Send data out via Horus UDP - if widgets["horusUploadSelector"].isChecked(): - _udp_port = int(widgets["horusUDPEntry"].text()) - # Add in SNR data - try: - _snr = float(widgets["snrLabel"].text()) - except ValueError as e: - logging.error(e) - _snr = 0 - _decoded['snr'] = _snr - - send_payload_summary(_decoded, port=_udp_port) - - # Send data out via OziMux messaging - if widgets["ozimuxUploadSelector"].isChecked(): - _udp_port = int(widgets["ozimuxUDPEntry"].text()) - send_ozimux_message(_decoded, port=_udp_port) - - # Log telemetry - if telemetry_logger: - telemetry_logger.add(_decoded) - - # Try and force a refresh of the displays. - QtWidgets.QApplication.processEvents() - - - -def start_decoding(): - """ - Read settings from the GUI - Set up all elements of the decode chain - Start decoding! - (Or, stop decoding) - """ - global widgets, audio_stream, fft_process, horus_modem, audio_devices, running, fft_update_queue, status_update_queue, last_packet_time, args - - if not running: - # Reset last packet time - - if widgets["userCallEntry"].text() == "N0CALL": - # We don't allow the decoder to start if the callsign is still at the default. - _error_msgbox = QtWidgets.QMessageBox() - _error_msgbox.setWindowTitle("Uploader Callsign Invalid") - _error_msgbox.setText("Please change your SondeHub uploader callsign before starting!") - _error_msgbox.exec_() - - return - - last_packet_time = None - widgets['latestDecodedAgeData'].setText("No packet yet!") - # Grab settings off widgets - _dev_name = widgets["audioDeviceSelector"].currentText() - if _dev_name != 'UDP Audio (127.0.0.1:7355)': - _sample_rate = int(widgets["audioSampleRateSelector"].currentText()) - _dev_index = audio_devices[_dev_name]["index"] - else: - # Override sample rate for GQRX UDP input. - _sample_rate = 48000 - - # Grab Horus Settings - _modem_name = widgets["horusModemSelector"].currentText() - _modem_id = HORUS_MODEM_LIST[_modem_name]['id'] - _modem_rate = int(widgets["horusModemRateSelector"].currentText()) - _modem_mask_enabled = widgets["horusMaskEstimatorSelector"].isChecked() - if _modem_mask_enabled: - _modem_tone_spacing = int(widgets["horusMaskSpacingEntry"].text()) - else: - _modem_tone_spacing = -1 - - # Reset Frequency Estimator indicators - for _line in widgets["estimatorLines"]: - _line.setPos(-1000) - - # Reset data fields - widgets["latestRawSentenceData"].setText("NO DATA") - widgets["latestDecodedSentenceData"].setText("NO DATA") - widgets["latestPacketCallsignValue"].setText("---") - widgets["latestPacketTimeValue"].setText("---") - widgets["latestPacketLatitudeValue"].setText("---") - widgets["latestPacketLongitudeValue"].setText("---") - widgets["latestPacketAltitudeValue"].setText("---") - widgets["latestPacketElevationValue"].setText("---") - widgets["latestPacketBearingValue"].setText("---") - widgets["latestPacketRangeValue"].setText("---") - - # Ensure the SondeHub upload is set correctly. - sondehub_uploader.inhibit = not widgets["sondehubUploadSelector"].isChecked() - - # Init FFT Processor - NFFT = 2 ** 13 - STRIDE = 2 ** 13 - fft_process = FFTProcess( - nfft=NFFT, - stride=STRIDE, - update_decimation=1, - fs=_sample_rate, - callback=add_fft_update - ) - - # Setup Modem - _libpath = "" - if args.libfix: - _libpath = "./" - - horus_modem = HorusLib( - libpath=_libpath, - mode=_modem_id, - rate=_modem_rate, - tone_spacing=_modem_tone_spacing, - callback=handle_new_packet, - sample_rate=_sample_rate - ) - - # Set manual estimator limits, if enabled - if widgets["horusManualEstimatorSelector"].isChecked(): - update_manual_estimator() - else: - horus_modem.set_estimator_limits(DEFAULT_ESTIMATOR_MIN, DEFAULT_ESTIMATOR_MAX) - - # Setup Audio (or UDP input) - if _dev_name == 'UDP Audio (127.0.0.1:7355)': - audio_stream = UDPStream( - udp_port=7355, - fs=_sample_rate, - block_size=fft_process.stride, - fft_input=fft_process.add_samples, - modem=horus_modem, - stats_callback=add_stats_update - ) - else: - audio_stream = AudioStream( - _dev_index, - fs=_sample_rate, - block_size=fft_process.stride, - fft_input=fft_process.add_samples, - modem=horus_modem, - stats_callback=add_stats_update - ) - - widgets["startDecodeButton"].setText("Stop") - running = True - logging.info("Started Audio Processing.") - - # Grey out some selectors, so the user cannot adjust them while we are decoding. - widgets["audioDeviceSelector"].setEnabled(False) - widgets["audioSampleRateSelector"].setEnabled(False) - widgets["horusModemSelector"].setEnabled(False) - widgets["horusModemRateSelector"].setEnabled(False) - widgets["horusMaskEstimatorSelector"].setEnabled(False) # This should really be editable while running. - widgets["horusMaskSpacingEntry"].setEnabled(False) # This should really be editable while running - - 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) - - try: - horus_modem.close() - except Exception as e: - logging.exception("Could not close horus modem.", exc_info=e) - - horus_modem = None - - fft_update_queue = Queue(256) - status_update_queue = Queue(256) - - widgets["startDecodeButton"].setText("Start") - running = False - - logging.info("Stopped Audio Processing.") - - # Re-Activate selectors. - widgets["audioDeviceSelector"].setEnabled(True) - widgets["audioSampleRateSelector"].setEnabled(True) - widgets["horusModemSelector"].setEnabled(True) - widgets["horusModemRateSelector"].setEnabled(True) - widgets["horusMaskEstimatorSelector"].setEnabled(True) - widgets["horusMaskSpacingEntry"].setEnabled(True) - - -widgets["startDecodeButton"].clicked.connect(start_decoding) - - -def handle_log_update(log_update): - global widgets - - widgets["console"].appendPlainText(log_update) - # Make sure the scroll bar is right at the bottom. - _sb = widgets["console"].verticalScrollBar() - _sb.setValue(_sb.maximum()) - - -# 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, decoder_init, widgets, args, running, last_packet_time - - 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_update(_status) - - while log_update_queue.qsize() > 0: - _log = log_update_queue.get() - - handle_log_update(_log) - - if running: - if last_packet_time != None: - _time_delta = int(time.time() - last_packet_time) - _time_delta_seconds = int(_time_delta%60) - _time_delta_minutes = int((_time_delta/60) % 60) - _time_delta_hours = int((_time_delta/3600)) - widgets['latestDecodedAgeData'].setText(f"{_time_delta_hours:02d}:{_time_delta_minutes:02d}:{_time_delta_seconds:02d}") - - # Try and force a re-draw. - QtWidgets.QApplication.processEvents() - - if not decoder_init: - # Initialise decoders, and other libraries here. - init_payloads(payload_id_list = args.payload_id_list, custom_field_list = args.custom_field_list) - decoder_init = True - # Once initialised, enable the start button - widgets["startDecodeButton"].setEnabled(True) - -gui_update_timer = QtCore.QTimer() -gui_update_timer.timeout.connect(processQueues) -gui_update_timer.start(100) - - - -# Rotator Control - -def startstop_rotator(): - global rotator, widgets - - if rotator is None: - # Start a rotator connection. - - try: - _host = widgets["rotatorHostEntry"].text() - _port = int(widgets["rotatorPortEntry"].text()) - _threshold = float(widgets["rotatorThresholdEntry"].text()) + self.telemetry_logger.close() except: - widgets["rotatorCurrentStatusValue"].setText("Bad Host/Port") - return + pass - if widgets["rotatorTypeSelector"].currentText() == "rotctld": + if self.rotator: try: - rotator = ROTCTLD(hostname=_host, port=_port, threshold=_threshold) - rotator.connect() - except Exception as e: - logging.error("Rotctld Connect Error: " + str(e)) - rotator = None - return - elif widgets["rotatorTypeSelector"].currentText() == "PSTRotator": - rotator = PSTRotator(hostname=_host, port=_port, threshold=_threshold) + self.rotator.close() + except: + pass + + + def update_audio_sample_rates(self): + """ Update the sample-rate dropdown when a different audio device is selected. """ + # Pass widgets straight on to function from .audio + populate_sample_rates(self.widgets) + + + def update_modem_settings(self): + """ Update the modem setting widgets when a different modem is selected """ + populate_modem_settings(self.widgets) + + + def select_log_directory(self): + folder = str(QFileDialog.getExistingDirectory(None, "Select Directory")) + + if folder is None: + logging.info("No log directory selected.") + return False + else: + if folder == "": + logging.info("No log directory selected.") + return False + else: + self.widgets["loggingPathEntry"].setText(folder) + self.widgets["enableLoggingSelector"].setChecked(False) + if self.telemetry_logger: + self.widgets["enableLoggingSelector"].setChecked(True) + self.telemetry_logger.update_log_directory(self.widgets["loggingPathEntry"].text()) + self.telemetry_logger.enabled = True + + return True + + + def set_logging_state(self): + logging_enabled = self.widgets["enableLoggingSelector"].isChecked() + + if logging_enabled: + if self.widgets["loggingPathEntry"].text() == "": + # No logging directory set, prompt user to select one. + _success = self.select_log_directory() + if not _success: + # User didn't select a directory, set checkbox to false again. + logging.error("No log directory selected, logging disabled.") + self.widgets["enableLoggingSelector"].setChecked(False) + # Disable logging. + if self.telemetry_logger: + self.telemetry_logger.enabled = False + + return + + # Enable logging + if self.telemetry_logger: + self.telemetry_logger.enabled = True + self.telemetry_logger.update_log_directory(self.widgets["loggingPathEntry"].text()) else: + # Disable logging + if self.telemetry_logger: + self.telemetry_logger.enabled = False + + + def set_logging_format(self): + if self.telemetry_logger: + self.telemetry_logger.log_format = self.widgets["loggingFormatSelector"].currentText() + + + # Handlers for various checkboxes and push-buttons + def habitat_position_reupload(self, dummy_arg, upload=True): + """ + Trigger a re-upload of user position information + Note that this requires a dummy argument, as the Qt + 'connect' callback supplies an argument which we don't want. + """ + self.sondehub_uploader.user_callsign = self.widgets["userCallEntry"].text() + self.sondehub_uploader.user_radio = "Horus-GUI v" + __version__ + " " + self.widgets["userRadioEntry"].text() + self.sondehub_uploader.user_antenna = self.widgets["userAntennaEntry"].text() + try: + if float(self.widgets["userLatEntry"].text()) == 0.0 and float(self.widgets["userLonEntry"].text()) == 0.0: + self.sondehub_uploader.user_position = None + else: + self.sondehub_uploader.user_position = [ + float(self.widgets["userLatEntry"].text()), + float(self.widgets["userLonEntry"].text()), + float(self.widgets["userAltEntry"].text())] + except Exception as e: + logging.error(f"Error parsing station location - {str(e)}") + self.sondehub_uploader.user_position = None + + if upload: + self.sondehub_uploader.last_user_position_upload = 0 + self.widgets["sondehubPositionNotesLabel"].setText("") + logging.info("Triggered user position re-upload.") + + + # Update uploader info as soon as it's edited, to ensure we upload with the latest user callsign + def update_uploader_details(self): + """ + Wrapper function for position re-upload, called when the user callsign entry is changed. + """ + #habitat_position_reupload("unused arg",upload=False) + self.widgets["sondehubPositionNotesLabel"].setText("
Station Info out of date - click Re-Upload!
") + + + def habitat_inhibit(self): + """ Update the Habitat inhibit flag """ + self.sondehub_uploader.inhibit = not self.widgets["sondehubUploadSelector"].isChecked() + logging.debug(f"Updated Sondebub Inhibit state: {self.sondehub_uploader.inhibit}") + + + def update_manual_estimator(self): + """ Push a change to the manually defined estimator limits into the modem """ + _limits = self.widgets["estimatorRange"].getRegion() + + _lower = _limits[0] + _upper = _limits[1] + + if self.horus_modem != None: + self.horus_modem.set_estimator_limits(_lower, _upper) + + + def set_manual_estimator(self): + """ Show or hide the manual estimator limit region """ + if self.widgets["horusManualEstimatorSelector"].isChecked(): + self.widgets["spectrumPlot"].addItem(self.widgets["estimatorRange"]) + self.update_manual_estimator() + else: + try: + self.widgets["spectrumPlot"].removeItem(self.widgets["estimatorRange"]) + # Reset modem estimator limits to their defaults. + if self.horus_modem != None: + self.horus_modem.set_estimator_limits(self.DEFAULT_ESTIMATOR_MIN, self.DEFAULT_ESTIMATOR_MAX) + except: + pass + + + def save_settings(self): + """ Manually save current settings """ + save_config(self.widgets) + + + # Handlers for data arriving via queues. + def handle_fft_update(self, data): + """ Handle a new FFT update """ + + _scale = data["scale"] + _data = data["fft"] + _dbfs = data["dbfs"] + + self.widgets["spectrumPlotData"].setData(_scale, _data) + + # Really basic IIR to smoothly adjust scale + _old_max = self.widgets["spectrumPlotRange"][1] + _tc = 0.1 + _new_max = float((_old_max * (1 - _tc)) + (np.max(_data) * _tc)) + + # Store new max + self.widgets["spectrumPlotRange"][1] = max(self.widgets["spectrumPlotRange"][0], _new_max) + + self.widgets["spectrumPlot"].setYRange( + self.widgets["spectrumPlotRange"][0], self.widgets["spectrumPlotRange"][1] + 20 + ) + + # Ignore NaN values. + if np.isnan(_dbfs) or np.isinf(_dbfs): return - widgets["rotatorCurrentStatusValue"].setText("Connected") - widgets["rotatorConnectButton"].setText("Stop") - else: - # Stop the rotator - rotator.close() - rotator = None - widgets["rotatorConnectButton"].setText("Start") - widgets["rotatorCurrentStatusValue"].setText("Not Connected") - widgets["rotatorCurrentPositionValue"].setText(f"---˚, --˚") + # Use same IIR to smooth out dBFS readings a little. + _new_dbfs = float((self.widgets["audioDbfsValue_float"] * (1 - _tc)) + (_dbfs * _tc)) + + # Set dBFS value + if (_new_dbfs>-5.0): + _dbfs_ok = "TOO HIGH" + elif (_new_dbfs < -90.0): + _dbfs_ok = "NO AUDIO?" + elif (_new_dbfs < -50.0): + _dbfs_ok = "LOW" + else: + _dbfs_ok = "GOOD" + + self.widgets["audioDbfsValue"].setText(f"{_new_dbfs:.0f}\t{_dbfs_ok}") + self.widgets["audioDbfsValue_float"] = _new_dbfs -widgets["rotatorConnectButton"].clicked.connect(startstop_rotator) + def handle_status_update(self, status): + """ Handle a new status frame """ -# def poll_rotator(): -# global rotator, widgets, rotator_current_az, rotator_current_el + # Update Frequency estimator markers + _fest_average = 0.0 + _fest_count = 0 + for _i in range(len(status.extended_stats.f_est)): + _fest_pos = float(status.extended_stats.f_est[_i]) + if _fest_pos != 0.0: + _fest_average += _fest_pos + _fest_count += 1 + self.widgets["estimatorLines"][_i].setPos(_fest_pos) -# if rotator: -# _az, _el = rotator.get_azel() + _fest_average = _fest_average/_fest_count + self.widgets["fest_float"] = _fest_average -# if _az != None: -# rotator_current_az = _az + # Update SNR Plot + _time = time.time() + # Roll Time/SNR + self.widgets["snrPlotTime"] = np.append(self.widgets["snrPlotTime"], _time) + self.widgets["snrPlotSNR"] = np.append(self.widgets["snrPlotSNR"], float(status.snr)) + if len(self.widgets["snrPlotTime"]) > 200: + self.widgets["snrPlotTime"] = self.widgets["snrPlotTime"][1:] + self.widgets["snrPlotSNR"] = self.widgets["snrPlotSNR"][1:] -# if _el != None: -# rotator_current_el = _el + # Plot new SNR data + self.widgets["snrPlotData"].setData((self.widgets["snrPlotTime"]-_time), self.widgets["snrPlotSNR"]) + _old_max = self.widgets["snrPlotRange"][1] + _tc = 0.1 + _new_max = float((_old_max * (1 - _tc)) + (np.max(self.widgets["snrPlotSNR"]) * _tc)) + self.widgets["snrPlotRange"][1] = _new_max + self.widgets["snrPlot"].setYRange( + self.widgets["snrPlotRange"][0], _new_max+10 + ) -# widgets["rotatorCurrentPositionValue"].setText(f"{rotator_current_az:3.1f}˚, {rotator_current_el:2.1f}˚") + # Update SNR bar and label + self.widgets["snrLabel"].setText(f"{float(status.snr):2.1f}") + self.widgets["snrBar"].setValue(int(status.snr)) -# rotator_poll_timer = QtCore.QTimer() -# rotator_poll_timer.timeout.connect(poll_rotator) -# rotator_poll_timer.start(2000) + + def get_latest_snr(self): + _current_modem = self.widgets["horusModemSelector"].currentText() + + _snr_update_rate = 2 # Hz + + if "RTTY" in _current_modem: + # RTTY needs a much longer lookback period to find the peak SNR + # This is because of a very long buffer used in the RTTY demod + _snr_lookback = _snr_update_rate * 15 + else: + # For Horus Binary we can use a smaller lookback time + _snr_lookback = _snr_update_rate * 4 + + if len(self.widgets["snrPlotSNR"])>_snr_lookback: + return np.max(self.widgets["snrPlotSNR"][-1*_snr_lookback:]) + else: + return np.max(self.widgets["snrPlotSNR"]) + + + 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 add_stats_update(frame): + """ Try and insert modem statistics into the processing queue """ + global status_update_queue + try: + status_update_queue.put_nowait(frame) + except: + logging.error("Status Update Queue Full!") + + + + + def handle_new_packet(self, frame): + """ Handle receipt of a newly decoded packet """ + + if len(frame.data) > 0: + if type(frame.data) == bytes: + # Packets from the binary decoders are provided as raw bytes. + # Conver them to a hexadecimal representation for display in the 'raw' area. + _packet = frame.data.hex().upper() + else: + # RTTY packets are provided as a string, and can be displayed directly + _packet = frame.data + + + + _decoded = None + + # Grab SNR. + _snr = self.get_latest_snr() + #logging.info(f"Packet SNR: {_snr:.2f}") + + + # Grab other metadata out of the GUI + _radio_dial = None + + if self.widgets["dialFreqEntry"].text() != "": + try: + _radio_dial = float(self.widgets["dialFreqEntry"].text())*1e6 + if self.widgets["fest_float"]: + # Add on the centre frequency estimation onto the dial frequency. + _radio_dial += self.widgets["fest_float"] + + except: + logging.warning("Could not parse radio dial frequency. This must be in MMM.KKK format e.g. 437.600") + _radio_dial = None + + + _baud_rate = int(self.widgets["horusModemRateSelector"].currentText()) + _modulation_detail = HORUS_MODEM_LIST[self.widgets["horusModemSelector"].currentText()]['modulation_detail'] + + if type(frame.data) == str: + # RTTY packet handling. + # Attempt to extract fields from it: + try: + _decoded = parse_ukhas_string(frame.data) + _decoded['snr'] = _snr + _decoded['baud_rate'] = _baud_rate + if _modulation_detail: + _decoded['modulation_detail'] = _modulation_detail + if _radio_dial: + _decoded['f_centre'] = _radio_dial + # If we get here, the string is valid! + self.widgets["latestRawSentenceData"].setText(f"{_packet} ({_snr:.1f} dB SNR)") + self.widgets["latestDecodedSentenceData"].setText(f"{_packet}") + self.last_packet_time = time.time() + + # Upload the string to Sondehub Amateur + if self.widgets["userCallEntry"].text() == "N0CALL": + logging.warning("Uploader callsign is set as N0CALL. Please change this, otherwise telemetry data may be discarded!") + + self.sondehub_uploader.add(_decoded) + + except Exception as e: + if "CRC Failure" in str(e) and self.widgets["inhibitCRCSelector"].isChecked(): + pass + else: + self.widgets["latestRawSentenceData"].setText(f"{_packet} ({_snr:.1f} dB SNR)") + self.widgets["latestDecodedSentenceData"].setText("DECODE FAILED") + logging.error(f"Decode Failed: {str(e)}") + + else: + # Handle binary packets + try: + _decoded = decode_packet(frame.data) + _decoded['snr'] = _snr + _decoded['baud_rate'] = _baud_rate + if _modulation_detail: + _decoded['modulation_detail'] = _modulation_detail + if _radio_dial: + _decoded['f_centre'] = _radio_dial + + self.widgets["latestRawSentenceData"].setText(f"{_packet} ({_snr:.1f} dB SNR)") + self.widgets["latestDecodedSentenceData"].setText(_decoded['ukhas_str']) + last_packet_time = time.time() + # Upload the string to Sondehub Amateur + if self.widgets["userCallEntry"].text() == "N0CALL": + logging.warning("Uploader callsign is set as N0CALL. Please change this, otherwise telemetry data may be discarded!") + + self.sondehub_uploader.add(_decoded) + except Exception as e: + if "CRC Failure" in str(e) and self.widgets["inhibitCRCSelector"].isChecked(): + pass + else: + self.widgets["latestRawSentenceData"].setText(f"{_packet} ({_snr:.1f} dB SNR)") + self.widgets["latestDecodedSentenceData"].setText("DECODE FAILED") + logging.error(f"Decode Failed: {str(e)}") + + # If we have extracted data, update the decoded data display + if _decoded: + self.widgets["latestPacketCallsignValue"].setText(_decoded['callsign']) + self.widgets["latestPacketTimeValue"].setText(_decoded['time']) + self.widgets["latestPacketLatitudeValue"].setText(f"{_decoded['latitude']:.5f}") + self.widgets["latestPacketLongitudeValue"].setText(f"{_decoded['longitude']:.5f}") + self.widgets["latestPacketAltitudeValue"].setText(f"{_decoded['altitude']}") + + # Attempt to update the range/elevation/bearing fields. + try: + _station_lat = float(self.widgets["userLatEntry"].text()) + _station_lon = float(self.widgets["userLonEntry"].text()) + _station_alt = float(self.widgets["userAltEntry"].text()) + + if (_station_lat != 0.0) or (_station_lon != 0.0): + _position_info = position_info( + (_station_lat, _station_lon, _station_alt), + (_decoded['latitude'], _decoded['longitude'], _decoded['altitude']) + ) + + self.widgets['latestPacketBearingValue'].setText(f"{_position_info['bearing']:.1f}") + self.widgets['latestPacketElevationValue'].setText(f"{_position_info['elevation']:.1f}") + self.widgets['latestPacketRangeValue'].setText(f"{_position_info['straight_distance']/1000.0:.1f}") + + if self.rotator and not ( _decoded['latitude'] == 0.0 and _decoded['longitude'] == 0.0 ): + try: + self.rotator.set_azel(_position_info['bearing'], _position_info['elevation'], check_response=False) + self.widgets["rotatorCurrentPositionValue"].setText(f"{_position_info['bearing']:3.1f}˚, {_position_info['elevation']:2.1f}˚") + except Exception as e: + logging.error("Rotator - Error setting Position: " + str(e)) + + except Exception as e: + logging.error(f"Could not calculate relative position to payload - {str(e)}") + + # Send data out via Horus UDP + if self.widgets["horusUploadSelector"].isChecked(): + _udp_port = int(self.widgets["horusUDPEntry"].text()) + # Add in SNR data + try: + _snr = float(self.widgets["snrLabel"].text()) + except ValueError as e: + logging.error(e) + _snr = 0 + _decoded['snr'] = _snr + + send_payload_summary(_decoded, port=_udp_port) + + # Send data out via OziMux messaging + if self.widgets["ozimuxUploadSelector"].isChecked(): + _udp_port = int(self.widgets["ozimuxUDPEntry"].text()) + send_ozimux_message(_decoded, port=_udp_port) + + # Log telemetry + if self.telemetry_logger: + self.telemetry_logger.add(_decoded) + + # Try and force a refresh of the displays. + QApplication.processEvents() + + + + def start_decoding(self): + """ + Read settings from the GUI + Set up all elements of the decode chain + Start decoding! + (Or, stop decoding) + """ + global args + + if not self.running: + # Reset last packet time + + if self.widgets["userCallEntry"].text() == "N0CALL": + # We don't allow the decoder to start if the callsign is still at the default. + _error_msgbox = QMessageBox() + _error_msgbox.setWindowTitle("Uploader Callsign Invalid") + _error_msgbox.setText("Please change your SondeHub uploader callsign before starting!") + _error_msgbox.exec() + + return + + self.last_packet_time = None + self.widgets['latestDecodedAgeData'].setText("No packet yet!") + # Grab settings off widgets + _dev_name = self.widgets["audioDeviceSelector"].currentText() + if _dev_name != 'UDP Audio (127.0.0.1:7355)': + _sample_rate = int(self.widgets["audioSampleRateSelector"].currentText()) + _dev_index = self.audio_devices[_dev_name]["index"] + else: + # Override sample rate for GQRX UDP input. + _sample_rate = 48000 + + # Grab Horus Settings + _modem_name = self.widgets["horusModemSelector"].currentText() + _modem_id = HORUS_MODEM_LIST[_modem_name]['id'] + _modem_rate = int(self.widgets["horusModemRateSelector"].currentText()) + _modem_mask_enabled = self.widgets["horusMaskEstimatorSelector"].isChecked() + if _modem_mask_enabled: + _modem_tone_spacing = int(self.widgets["horusMaskSpacingEntry"].text()) + else: + _modem_tone_spacing = -1 + + # Reset Frequency Estimator indicators + for _line in self.widgets["estimatorLines"]: + _line.setPos(-1000) + + # Reset data fields + self.widgets["latestRawSentenceData"].setText("NO DATA") + self.widgets["latestDecodedSentenceData"].setText("NO DATA") + self.widgets["latestPacketCallsignValue"].setText("---") + self.widgets["latestPacketTimeValue"].setText("---") + self.widgets["latestPacketLatitudeValue"].setText("---") + self.widgets["latestPacketLongitudeValue"].setText("---") + self.widgets["latestPacketAltitudeValue"].setText("---") + self.widgets["latestPacketElevationValue"].setText("---") + self.widgets["latestPacketBearingValue"].setText("---") + self.widgets["latestPacketRangeValue"].setText("---") + + # Ensure the SondeHub upload is set correctly. + self.sondehub_uploader.inhibit = not self.widgets["sondehubUploadSelector"].isChecked() + + # Init FFT Processor + NFFT = 2 ** 13 + STRIDE = 2 ** 13 + self.fft_process = FFTProcess( + nfft=NFFT, + stride=STRIDE, + update_decimation=1, + fs=_sample_rate, + callback=self.add_fft_update + ) + + # Setup Modem + _libpath = "" + if args.libfix: + _libpath = "./" + + self.horus_modem = HorusLib( + libpath=_libpath, + mode=_modem_id, + rate=_modem_rate, + tone_spacing=_modem_tone_spacing, + callback=self.handle_new_packet, + sample_rate=_sample_rate + ) + + # Set manual estimator limits, if enabled + if self.widgets["horusManualEstimatorSelector"].isChecked(): + self.update_manual_estimator() + else: + self.horus_modem.set_estimator_limits(self.DEFAULT_ESTIMATOR_MIN, self.DEFAULT_ESTIMATOR_MAX) + + # Setup Audio (or UDP input) + if _dev_name == 'UDP Audio (127.0.0.1:7355)': + self.audio_stream = UDPStream( + udp_port=7355, + fs=_sample_rate, + block_size=self.fft_process.stride, + fft_input=self.fft_process.add_samples, + modem=self.horus_modem, + stats_callback=self.add_stats_update + ) + else: + self.audio_stream = AudioStream( + _dev_index, + fs=_sample_rate, + block_size=self.fft_process.stride, + fft_input=self.fft_process.add_samples, + modem=self.horus_modem, + stats_callback=self.add_stats_update + ) + + self.widgets["startDecodeButton"].setText("Stop") + self.running = True + logging.info("Started Audio Processing.") + + # Grey out some selectors, so the user cannot adjust them while we are decoding. + self.widgets["audioDeviceSelector"].setEnabled(False) + self.widgets["audioSampleRateSelector"].setEnabled(False) + self.widgets["horusModemSelector"].setEnabled(False) + self.widgets["horusModemRateSelector"].setEnabled(False) + self.widgets["horusMaskEstimatorSelector"].setEnabled(False) # This should really be editable while running. + self.widgets["horusMaskSpacingEntry"].setEnabled(False) # This should really be editable while running + + else: + try: + self.audio_stream.stop() + except Exception as e: + logging.exception("Could not stop audio stream.", exc_info=e) + + try: + self.fft_process.stop() + except Exception as e: + logging.exception("Could not stop fft processing.", exc_info=e) + + try: + self.horus_modem.close() + except Exception as e: + logging.exception("Could not close horus modem.", exc_info=e) + + self.horus_modem = None + + self.fft_update_queue = Queue(256) + self.status_update_queue = Queue(256) + + self.widgets["startDecodeButton"].setText("Start") + self.running = False + + logging.info("Stopped Audio Processing.") + + # Re-Activate selectors. + self.widgets["audioDeviceSelector"].setEnabled(True) + self.widgets["audioSampleRateSelector"].setEnabled(True) + self.widgets["horusModemSelector"].setEnabled(True) + self.widgets["horusModemRateSelector"].setEnabled(True) + self.widgets["horusMaskEstimatorSelector"].setEnabled(True) + self.widgets["horusMaskSpacingEntry"].setEnabled(True) + + + def handle_log_update(self, log_update): + self.widgets["console"].appendPlainText(log_update) + # Make sure the scroll bar is right at the bottom. + _sb = self.widgets["console"].verticalScrollBar() + _sb.setValue(_sb.maximum()) + + + # GUI Update Loop + def processQueues(self): + """ Read in data from the queues, this decouples the GUI and async inputs somewhat. """ + global args + + while self.fft_update_queue.qsize() > 0: + _data = self.fft_update_queue.get() + + self.handle_fft_update(_data) + + while self.status_update_queue.qsize() > 0: + _status = self.status_update_queue.get() + + self.handle_status_update(_status) + + while self.log_update_queue.qsize() > 0: + _log = self.log_update_queue.get() + + self.handle_log_update(_log) + + if self.running: + if self.last_packet_time != None: + _time_delta = int(time.time() - self.last_packet_time) + _time_delta_seconds = int(_time_delta%60) + _time_delta_minutes = int((_time_delta/60) % 60) + _time_delta_hours = int((_time_delta/3600)) + self.widgets['latestDecodedAgeData'].setText(f"{_time_delta_hours:02d}:{_time_delta_minutes:02d}:{_time_delta_seconds:02d}") + + # Try and force a re-draw. + QApplication.processEvents() + + if not self.decoder_init: + # Initialise decoders, and other libraries here. + init_payloads(payload_id_list = args.payload_id_list, custom_field_list = args.custom_field_list) + self.decoder_init = True + # Once initialised, enable the start button + self.widgets["startDecodeButton"].setEnabled(True) + + + # Rotator Control + def startstop_rotator(self): + if self.rotator is None: + # Start a rotator connection. + + try: + _host = self.widgets["rotatorHostEntry"].text() + _port = int(self.widgets["rotatorPortEntry"].text()) + _threshold = float(self.widgets["rotatorThresholdEntry"].text()) + except: + self.widgets["rotatorCurrentStatusValue"].setText("Bad Host/Port") + return + + if self.widgets["rotatorTypeSelector"].currentText() == "rotctld": + try: + self.rotator = ROTCTLD(hostname=_host, port=_port, threshold=_threshold) + self.rotator.connect() + except Exception as e: + logging.error("Rotctld Connect Error: " + str(e)) + self.rotator = None + return + elif self.widgets["rotatorTypeSelector"].currentText() == "PSTRotator": + self.rotator = PSTRotator(hostname=_host, port=_port, threshold=_threshold) + + else: + return + + + self.widgets["rotatorCurrentStatusValue"].setText("Connected") + self.widgets["rotatorConnectButton"].setText("Stop") + else: + # Stop the rotator + self.rotator.close() + self.rotator = None + self.widgets["rotatorConnectButton"].setText("Start") + self.widgets["rotatorCurrentStatusValue"].setText("Not Connected") + self.widgets["rotatorCurrentPositionValue"].setText(f"---˚, --˚") + + + + # def poll_rotator(): + # global rotator, widgets, rotator_current_az, rotator_current_el + + # if rotator: + # _az, _el = rotator.get_azel() + + # if _az != None: + # rotator_current_az = _az + + # if _el != None: + # rotator_current_el = _el + + # self.widgets["rotatorCurrentPositionValue"].setText(f"{rotator_current_az:3.1f}˚, {rotator_current_el:2.1f}˚") + + # rotator_poll_timer = QtCore.QTimer() + # rotator_poll_timer.timeout.connect(poll_rotator) + # rotator_poll_timer.start(2000) class ConsoleHandler(logging.Handler): @@ -1395,50 +1442,19 @@ class ConsoleHandler(logging.Handler): except: print("Console Log Queue full!") - - -# Add console handler to top level logger. -console_handler = ConsoleHandler(log_update_queue) -logging.getLogger().addHandler(console_handler) - - -logging.info("Started GUI.") - - - # Main def main(): + app = QApplication(sys.argv) + app.setStyleSheet(qdarktheme.load_stylesheet()) + window = MainWindow() + app.aboutToQuit.connect(window.cleanup) + window.show() + sys.exit(app.exec()) + # Start the Qt Loop if (sys.flags.interactive != 1) or not hasattr(QtCore, "PYQT_VERSION"): - QtWidgets.QApplication.instance().exec() + QApplication.instance().exec() save_config(widgets) - try: - audio_stream.stop() - except Exception as e: - pass - - try: - fft_process.stop() - except Exception as e: - pass - - try: - sondehub_uploader.close() - except: - pass - - try: - telemetry_logger.close() - except: - pass - - if rotator: - try: - rotator.close() - except: - pass - - if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/horusgui/widgets.py b/horusgui/widgets.py index 781d1a6..3a2576c 100644 --- a/horusgui/widgets.py +++ b/horusgui/widgets.py @@ -1,9 +1,9 @@ # Useful widgets -from PyQt5 import QtWidgets +from PyQt6 import QtWidgets # Useful class for adding horizontal lines. class QHLine(QtWidgets.QFrame): def __init__(self): super(QHLine, self).__init__() - self.setFrameShape(QtWidgets.QFrame.HLine) - self.setFrameShadow(QtWidgets.QFrame.Sunken) + self.setFrameShape(QtWidgets.QFrame.Shape.HLine) + self.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) diff --git a/requirements.txt b/requirements.txt index 024b07a..d9528ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ numpy pyaudio crcmod -PyQt5 +PyQt6 pyqtgraph requests horusdemodlib>=0.3.12 +audioop-lts; python_version>='3.13' \ No newline at end of file