diff --git a/README.md b/README.md index 27dbe88..3997966 100755 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ $ cd horusdemodlib && mkdir build && cd build $ cmake .. $ make $ sudo make install +$ sudo ldconfig ``` ### Grab this Repo diff --git a/horusgui/__init__.py b/horusgui/__init__.py index 08aad71..6a9beea 100755 --- a/horusgui/__init__.py +++ b/horusgui/__init__.py @@ -1 +1 @@ -__version__ = "0.3.19" +__version__ = "0.4.0" diff --git a/horusgui/audio.py b/horusgui/audio.py index 3aaf07b..043b489 100755 --- a/horusgui/audio.py +++ b/horusgui/audio.py @@ -1,6 +1,7 @@ # Audio Interfacing import logging import pyaudio +import time # Global PyAudio object @@ -125,8 +126,14 @@ def __init__(self, audio_device, fs, block_size=8192, fft_input=None, modem=None self.modem = modem self.stats_callback = stats_callback - # Start audio stream + self.audio_thread_running = True + self.audio = pyaudio.PyAudio() + + def start_stream(self, info_callback=None): + if info_callback: + self.stats_callback = info_callback + self.stream = self.audio.open( format=pyaudio.paInt16, channels=1, @@ -138,6 +145,11 @@ def __init__(self, audio_device, fs, block_size=8192, fft_input=None, modem=None stream_callback=self.handle_samples, ) + while self.audio_thread_running: + time.sleep(0.5) + + logging.debug("Stopped audio stream thread") + def handle_samples(self, data, frame_count, time_info="", status_flags=""): """ Handle incoming samples from pyaudio """ @@ -151,10 +163,11 @@ def handle_samples(self, data, frame_count, time_info="", status_flags=""): # Send any stats data back to the stats callback if _stats: if self.stats_callback: - self.stats_callback(_stats) + self.stats_callback.emit(_stats) return (None, pyaudio.paContinue) def stop(self): """ Halt stream """ - self.stream.close() + self.audio_thread_running = False + self.stream.close() \ No newline at end of file diff --git a/horusgui/config.py b/horusgui/config.py index 126ade6..7054be7 100644 --- a/horusgui/config.py +++ b/horusgui/config.py @@ -34,9 +34,11 @@ "rotator_type": "rotctld", "rotator_host": "localhost", "rotator_port": 4533, + "rotator_rangeinhibit": True, "logging_enabled": False, "log_format": "CSV", "log_directory": "", + "fft_smoothing": False, "payload_list": json.dumps(horusdemodlib.payloads.HORUS_PAYLOAD_LIST), "custom_field_list": json.dumps({}) } @@ -76,7 +78,7 @@ def read_config(widgets): global qt_settings, default_config # This is getting a bit ridiculous, need to re-think this approach. - OK_VERSIONS = [__version__, '0.3.18', '0.3.17', '0.3.16', '0.3.15', '0.3.14', '0.3.13', '0.3.12', '0.3.11', '0.3.10', '0.3.9', '0.3.8', '0.3.7', '0.3.6', '0.3.5', '0.3.4', '0.3.1', '0.2.1'] + OK_VERSIONS = [__version__,'0.3.19', '0.3.18', '0.3.17', '0.3.16', '0.3.15', '0.3.14', '0.3.13', '0.3.12', '0.3.11', '0.3.10', '0.3.9', '0.3.8', '0.3.7', '0.3.6', '0.3.5', '0.3.4', '0.3.1', '0.2.1'] # Try and read in the version parameter from QSettings if qt_settings.value("version") not in OK_VERSIONS: @@ -124,12 +126,15 @@ def read_config(widgets): widgets["rotatorTypeSelector"].setCurrentText(default_config["rotator_type"]) widgets["rotatorHostEntry"].setText(str(default_config["rotator_host"])) widgets["rotatorPortEntry"].setText(str(default_config["rotator_port"])) + widgets["rotatorRangeInhibit"].setChecked(ValueToBool(default_config["rotator_rangeinhibit"])) # Logging Settings widgets["loggingPathEntry"].setText(str(default_config["log_directory"])) widgets["loggingFormatSelector"].setCurrentText(default_config["log_format"]) widgets["enableLoggingSelector"].setChecked(ValueToBool(default_config["logging_enabled"])) + widgets["fftSmoothingSelector"].setChecked(ValueToBool(default_config["fft_smoothing"])) + if default_config['baud_rate'] != -1: widgets["horusModemRateSelector"].setCurrentText(str(default_config['baud_rate'])) @@ -173,9 +178,11 @@ def save_config(widgets): default_config["rotator_type"] = widgets["rotatorTypeSelector"].currentText() default_config["rotator_host"] = widgets["rotatorHostEntry"].text() default_config["rotator_port"] = int(widgets["rotatorPortEntry"].text()) + default_config["rotator_rangeinhibit"] = widgets["rotatorRangeInhibit"].isChecked() default_config["logging_enabled"] = widgets["enableLoggingSelector"].isChecked() default_config["log_directory"] = widgets["loggingPathEntry"].text() default_config["log_format"] = widgets["loggingFormatSelector"].currentText() + default_config["fft_smoothing"] = widgets["fftSmoothingSelector"].isChecked() default_config["payload_list"] = json.dumps(horusdemodlib.payloads.HORUS_PAYLOAD_LIST) default_config["custom_field_list"] = json.dumps(horusdemodlib.payloads.HORUS_CUSTOM_FIELDS) diff --git a/horusgui/fft.py b/horusgui/fft.py index 319f1b4..cf0cb70 100644 --- a/horusgui/fft.py +++ b/horusgui/fft.py @@ -3,7 +3,7 @@ import time import numpy as np from queue import Queue -from threading import Thread +#from threading import Thread class FFTProcess(object): @@ -37,8 +37,8 @@ def __init__( self.processing_thread_running = True - self.t = Thread(target=self.processing_thread) - self.t.start() + #self.t = Thread(target=self.processing_thread) + #self.t.start() def init_window(self): """ Initialise Window functions and FFT scales. """ @@ -74,7 +74,7 @@ def perform_fft(self): if self.callback != None: if self.update_counter % self.update_decimation == 0: - self.callback({"fft": _fft[self.mask], "scale": self.fft_scale[self.mask], 'dbfs': _dbfs}) + self.callback.emit({"fft": _fft[self.mask], "scale": self.fft_scale[self.mask], 'dbfs': _dbfs}) self.update_counter += 1 @@ -86,7 +86,9 @@ def process_block(self, samples): while len(self.sample_buffer) > self.nfft * self.sample_width: self.perform_fft() - def processing_thread(self): + def processing_thread(self, info_callback=None): + if info_callback: + self.callback = info_callback while self.processing_thread_running: if self.input_queue.qsize() > 0: @@ -95,6 +97,8 @@ def processing_thread(self): else: time.sleep(0.01) + logging.debug("Stopped FFT processing thread") + def add_samples(self, samples): """ Add a block of samples to the input queue """ try: diff --git a/horusgui/gui.py b/horusgui/gui.py index fa5d1e0..7a2819b 100644 --- a/horusgui/gui.py +++ b/horusgui/gui.py @@ -15,17 +15,16 @@ import argparse import datetime -import glob +# import glob import logging import platform 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 * -from threading import Thread from .widgets import * from .audio import * @@ -44,44 +43,6 @@ 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,1344 +62,1581 @@ 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 +PEN_WIDTH=1 -widgets["selectLogDirButton"].clicked.connect(select_log_directory) +# 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__() -def set_logging_state(): - global widgets + self.fn = fn + self.args = args + self.kwargs = kwargs + self.signals = WorkerSignals() - logging_enabled = widgets["enableLoggingSelector"].isChecked() + self.kwargs['info_callback'] = self.signals.info - 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 + @pyqtSlot() + def run(self): + try: + 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 = {} + + # 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.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 + + # Decoded packet signal + self.new_packet_signal = WorkerSignals() + self.new_packet_signal.info.connect(self.handle_new_packet) + + self.initialize() + + def initialize(self): + # GUI LAYOUT - Gtk Style! + self.setWindowTitle(f"Horus Telemetry GUI - v{__version__}") + self.setWindowIcon(getHorusIcon()) + + # Left Column VBox + left_column = QVBoxLayout() + + # Controls + w1_audio_groupbox = QGroupBox('Audio') + w1_audio_groupbox.setObjectName("b1") + w1_audio_groupbox.setStyleSheet('QWidget#b1 { font-size: 15px; font-weight: bold}') + w1_audio = QGridLayout(w1_audio_groupbox) + + # Audio Parameters + self.widgets["audioDeviceLabel"] = QLabel("Audio Device:") + self.widgets["audioDeviceSelector"] = QComboBox() + # self.widgets["audioDeviceSelector"].setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContentsOnFirstShow) + # self.widgets["audioDeviceSelector"].setFixedWidth(275) # Dirty, but it needed to be done + 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, 3) + w1_audio.addWidget(self.widgets["audioDeviceSelector"], 1, 0, 1, 3) + w1_audio.addWidget(self.widgets["audioSampleRateLabel"], 2, 0, 1, 1) + w1_audio.addWidget(self.widgets["audioSampleRateSelector"], 2, 1, 1, 2) + w1_audio.addWidget(self.widgets["audioDbfsLabel"], 3, 0, 1, 1) + w1_audio.addWidget(self.widgets["audioDbfsValue"], 3, 1, 1, 2) + w1_audio_groupbox.setLayout(w1_audio) + + # Modem Parameters + w1_modem_groupbox = QGroupBox('Modem') + w1_modem_groupbox.setObjectName("b1") + w1_modem_groupbox.setStyleSheet('QWidget#b1 { font-size: 15px; font-weight: bold}') + w1_modem = QGridLayout(w1_modem_groupbox) + + 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." + ) - return + 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) + w1_modem_groupbox.setLayout(w1_modem) + + + w1_habitat_widget = QWidget() + w1_habitat = QGridLayout(w1_habitat_widget) + # Listener Information + self.widgets["habitatHeading"] = QLabel("SondeHub Settings") + self.widgets["sondehubUploadLabel"] = QLabel("Enable SondeHub 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.setRowStretch(9, 1) + w1_habitat.addWidget(self.widgets["saveSettingsButton"], 10, 0, 1, 3) + w1_habitat_widget.setLayout(w1_habitat) + + w1_other_widget = QWidget() + w1_other = QGridLayout(w1_other_widget) + 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." + ) + self.widgets["fftSmoothingLabel"] = QLabel("Enable FFT smoothing:") + self.widgets["fftSmoothingSelector"] = QCheckBox() + self.widgets["fftSmoothingSelector"].setChecked(False) + self.widgets["fftSmoothingSelector"].setToolTip( + "Enable IIR filter on FFT with tc=0.25." + ) - # Enable logging - if telemetry_logger: - telemetry_logger.enabled = True - telemetry_logger.update_log_directory(widgets["loggingPathEntry"].text()) + 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.addWidget(self.widgets["fftSmoothingLabel"], 12, 0, 1, 1) + w1_other.addWidget(self.widgets["fftSmoothingSelector"], 12, 1, 1, 1) + w1_other.setRowStretch(13, 1) + w1_other_widget.setLayout(w1_other) + + + w1_rotator_widget = QWidget() + w1_rotator = QGridLayout(w1_rotator_widget) + 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"\ + ) - else: - # Disable logging - if telemetry_logger: - telemetry_logger.enabled = False + 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["rotatorRangeInhibitLabel"] = QLabel("Inhibit Local Movement:") + self.widgets["rotatorRangeInhibit"] = QCheckBox() + self.widgets["rotatorRangeInhibit"].setChecked(True) + self.widgets["rotatorRangeInhibit"].setToolTip( + "Inhibit Horus GUI from sending rotator position updates\n"\ + "if range is less than 250 meters. This is useful if testing\n"\ + "transmitter in close vicinity of receiver." + ) + self.widgets["rotatorThresholdLabel"] = QLabel("Rotator Movement Threshold:") + self.widgets["rotatorThresholdEntry"] = QLineEdit("2.5") + 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." + ) -widgets["enableLoggingSelector"].clicked.connect(set_logging_state) + 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["rotatorRangeInhibitLabel"], 5, 0, 1, 1) + w1_rotator.addWidget(self.widgets["rotatorRangeInhibit"], 5, 1, 1, 1) + w1_rotator.addWidget(self.widgets["rotatorConnectButton"], 6, 0, 1, 2) + w1_rotator.addWidget(self.widgets["rotatorCurrentStatusLabel"], 7, 0, 1, 1) + w1_rotator.addWidget(self.widgets["rotatorCurrentStatusValue"], 7, 1, 1, 1) + w1_rotator.addWidget(self.widgets["rotatorCurrentPositionLabel"], 8, 0, 1, 1) + w1_rotator.addWidget(self.widgets["rotatorCurrentPositionValue"], 8, 1, 1, 1) + w1_rotator.setRowStretch(9, 1) + + w1_rotator_widget.setLayout(w1_rotator) + + w1_tab_widget = QTabWidget() + w1_tab_widget.setTabPosition(QTabWidget.TabPosition.North) + w1_tab_widget.tabBar().setExpanding(True) + w1_tab_widget.addTab(w1_habitat_widget, "SondeHub") + w1_tab_widget.addTab(w1_other_widget, "Other") + w1_tab_widget.addTab(w1_rotator_widget, "Rotator") + w1_tab_widget.setStyleSheet("QTabBar {font: bold 14px;}") + + # Add widgets to left column + left_column.addWidget(w1_audio_groupbox) #, 0, 0, 1, 1) + left_column.addWidget(w1_modem_groupbox) #, 1, 0, 1, 1) + left_column.addWidget(w1_tab_widget) #, 2, 0, 1, 1) + + # Right Column QGrid (Grid for merged cells) + right_column = QGridLayout() + + # 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], pen=pg.mkPen(width=PEN_WIDTH)) + + # Frequency Estiator Outputs + self.widgets["estimatorLines"] = [ + pg.InfiniteLine( + pos=-1000, + pen=pg.mkPen(color="grey", width=(PEN_WIDTH + 1), style=QtCore.Qt.PenStyle.DashLine), + label="F1", + labelOpts={'position':0.9} + ), + pg.InfiniteLine( + pos=-1000, + pen=pg.mkPen(color="grey", width=(PEN_WIDTH + 1), style=QtCore.Qt.PenStyle.DashLine), + label="F2", + labelOpts={'position':0.9} + ), + pg.InfiniteLine( + pos=-1000, + pen=pg.mkPen(color="grey", width=(PEN_WIDTH + 1), style=QtCore.Qt.PenStyle.DashLine), + label="F3", + labelOpts={'position':0.9} + ), + pg.InfiniteLine( + pos=-1000, + pen=pg.mkPen(color="grey", width=(PEN_WIDTH + 1), 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) + + w2_spectrum_groupbox = QGroupBox("Spectrum") + w2_spectrum_groupbox.setObjectName("b1") + w2_spectrum_groupbox.setStyleSheet('QWidget#b1 { font-size: 15px; font-weight: bold}') + spectrum = QGridLayout(w2_spectrum_groupbox) + spectrum.addWidget(self.widgets["spectrumPlot"]) + + self.widgets["spectrumPlotRange"] = [-100, -20] + + w3_stats_groupbox = QGroupBox("SNR (dB)") + w3_stats_groupbox.setObjectName("b1") + w3_stats_groupbox.setStyleSheet('QWidget#b1 { font-size: 15px; font-weight: bold}') + w3_stats = QGridLayout(w3_stats_groupbox) + 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.setColumnStretch(0, 2) + w3_stats.setColumnStretch(2, 2) + + w3_stats_groupbox.setLayout(w3_stats) + + + # SNR Plot + w3_snr_groupbox = QGroupBox("SNR Plot") + w3_snr_groupbox.setObjectName("b1") + w3_snr_groupbox.setStyleSheet('QWidget#b1 { font-size: 15px; font-weight: bold}') + w3_snr = QGridLayout(w3_snr_groupbox) + 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"], pen=pg.mkPen(width=PEN_WIDTH)) + w3_snr.addWidget(self.widgets["snrPlot"]) + + w3_snr_groupbox.setLayout(w3_snr) + + # 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) + + # Telemetry Data + w4_data_groupbox = QGroupBox("Data") + w4_data_groupbox.setObjectName("b1") + w4_data_groupbox.setStyleSheet('QWidget#b1 { font-size: 15px; font-weight: bold}') + w4_data = QGridLayout(w4_data_groupbox) + 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) + + w4_data_groupbox.setLayout(w4_data) + + w4_position_groupbox = QGroupBox("Position") + w4_position_groupbox.setObjectName("b1") + w4_position_groupbox.setStyleSheet('QWidget#b1 { font-size: 15px; font-weight: bold}') + w4_position = QGridLayout(w4_position_groupbox) + + 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.setRowStretch(1, 6) + + w4_position_groupbox.setLayout(w4_position) + + w5_telemetry_groupbox = QGroupBox("Telemetry") + w5_telemetry_groupbox.setObjectName("b1") + w5_telemetry_groupbox.setStyleSheet('QWidget#b1 { font-size: 15px; font-weight: bold}') + self.w5_telemetry = QGridLayout(w5_telemetry_groupbox) + w5_telemetry_groupbox.setLayout(self.w5_telemetry) + + # These are placeholders and will be updated when telemetry is received. + self.widgets["latestTelemBattVoltageLabel"] = QLabel("Batt Voltage") + self.widgets["latestTelemBattVoltageValue"] = QLabel("---") + self.widgets["latestTelemBattVoltageValue"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + self.widgets["latestTelemSatellitesLabel"] = QLabel("Satellites") + self.widgets["latestTelemSatellitesValue"] = QLabel("---") + self.widgets["latestTelemSatellitesValue"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + self.widgets["latestTelemTemperatureLabel"] = QLabel("Temperature") + self.widgets["latestTelemTemperatureValue"] = QLabel("---") + self.widgets["latestTelemTemperatureValue"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + + self.w5_telemetry.addWidget(self.widgets[f"latestTelemBattVoltageLabel"], 0, 0, 1, 1) + self.w5_telemetry.addWidget(self.widgets[f"latestTelemBattVoltageValue"], 1, 0, 1, 1) + self.w5_telemetry.addWidget(self.widgets[f"latestTelemSatellitesLabel"], 0, 1, 1, 1) + self.w5_telemetry.addWidget(self.widgets[f"latestTelemSatellitesValue"], 1, 1, 1, 1) + self.w5_telemetry.addWidget(self.widgets[f"latestTelemTemperatureLabel"], 0, 2, 1, 1) + self.w5_telemetry.addWidget(self.widgets[f"latestTelemTemperatureValue"], 1, 2, 1, 1) + + self.widgets["latestTelem0Label"] = QLabel("Ascent Rate") + self.widgets["latestTelem0Value"] = QLabel("---") + self.widgets["latestTelem0Value"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + self.widgets["latestTelem1Label"] = QLabel("External Temperature") + self.widgets["latestTelem1Value"] = QLabel("---") + self.widgets["latestTelem1Value"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + self.widgets["latestTelem2Label"] = QLabel("External Humidity") + self.widgets["latestTelem2Value"] = QLabel("---") + self.widgets["latestTelem2Value"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + self.widgets["latestTelem3Label"] = QLabel("External Pressure") + self.widgets["latestTelem3Value"] = QLabel("---") + self.widgets["latestTelem3Value"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + for i in range(4,9): + self.widgets[f"latestTelem{i}Label"] = QLabel("") + self.widgets[f"latestTelem{i}Value"] = QLabel("") + self.widgets[f"latestTelem{i}Label"].hide() + self.widgets[f"latestTelem{i}Value"].hide() + self.widgets[f"latestTelem{i}Value"].setFont(QFont("Courier New", POSITION_LABEL_FONT_SIZE, QFont.Weight.Bold)) + + for i in range(0,9): + self.w5_telemetry.addWidget(self.widgets[f"latestTelem{i}Label"], 0, i+3, 1, 1) + self.w5_telemetry.addWidget(self.widgets[f"latestTelem{i}Value"], 1, i+3, 1, 1) + + for i in range(0,7): + self.w5_telemetry.setColumnStretch(i, 10) + + for i in range(7, 12): + self.w5_telemetry.setColumnStretch(i, 1) + + #self.w5_telemetry.setRowStretch(1, 6) + + w6_groupbox = QGroupBox("Log") + w6_groupbox.setObjectName("b1") + w6_groupbox.setStyleSheet('QWidget#b1 { font-size: 15px; font-weight: bold}') + w6 = QGridLayout(w6_groupbox) + self.widgets["console"] = QPlainTextEdit() + self.widgets["console"].setReadOnly(True) + self.widgets["console"].setMaximumBlockCount(1000) + w6.addWidget(self.widgets["console"]) + w6.setRowStretch(0, 1) + + w6_groupbox.setLayout(w6) -def set_logging_format(): - if telemetry_logger: - telemetry_logger.log_format = widgets["loggingFormatSelector"].currentText() + right_column.addWidget(w2_spectrum_groupbox, 0, 0, 1, 1) + right_column.addWidget(w3_snr_groupbox, 0, 1, 1, 1) + right_column.addWidget(w3_stats_groupbox, 0, 2, 1, 1) + right_column.addWidget(w4_data_groupbox, 1, 0, 1, 3) + right_column.addWidget(w4_position_groupbox, 2, 0, 1, 3) + right_column.addWidget(w5_telemetry_groupbox, 3, 0, 1, 3) + right_column.addWidget(w6_groupbox, 4, 0, 1, 3) -widgets["loggingFormatSelector"].currentIndexChanged.connect(set_logging_format) + right_column.setColumnStretch(0, 10) + right_column.setColumnStretch(1, 6) + right_column.setColumnStretch(2, 1) -# 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__, -) + left_column_widget = QWidget() + left_column_widget.setLayout(left_column) -telemetry_logger = TelemetryLogger( - log_directory = widgets["loggingPathEntry"].text(), - log_format = widgets["loggingFormatSelector"].currentText(), - enabled = widgets["enableLoggingSelector"].isChecked() -) + right_column_widget = QWidget() + right_column_widget.setLayout(right_column) -# 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 + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.addWidget(left_column_widget) + splitter.addWidget(right_column_widget) - if upload: - sondehub_uploader.last_user_position_upload = 0 - widgets["sondehubPositionNotesLabel"].setText("") - logging.info("Triggered user position re-upload.") + self.mainWidget = QWidget() + self.setCentralWidget(self.mainWidget) + self.mainLayout = QHBoxLayout() + self.mainWidget.setLayout(self.mainLayout) -# Connect the 'Re-upload Position' button to the above function. -widgets["habitatUploadPosition"].clicked.connect(habitat_position_reupload) + self.mainLayout.addWidget(splitter) + # self.mainLayout = QGridLayout() + # self.mainWidget.setLayout(self.mainLayout)? -# 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!
") + # # Grid: (Row, Column, RowSpan, ColumnSpan) + # self.mainLayout.addLayout(left_column, 0, 0, 1, 1) + # self.mainLayout.addLayout(right_column, 0, 1, 1, 1) -# 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) + # self.mainLayout.setColumnStretch(0, 1) + # self.mainLayout.setColumnStretch(1, 10) + # Resize window to final resolution, and display. + logging.info("Starting GUI.") + self.resize(1500, self.minimumSize().height()) -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}") + self.post_initialize() -widgets["sondehubUploadSelector"].clicked.connect(habitat_inhibit) + def post_initialize(self): + # Audio Initialization + self.audio_devices = init_audio(self.widgets) -def update_manual_estimator(): - """ Push a change to the manually defined estimator limits into the modem """ - global widgets, horus_modem + # Initialize modem list. + init_horus_modem(self.widgets) - _limits = widgets["estimatorRange"].getRegion() + # 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) - _lower = _limits[0] - _upper = _limits[1] - if horus_modem != None: - horus_modem.set_estimator_limits(_lower, _upper) + 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__, + ) -widgets["estimatorRange"].sigRegionChangeFinished.connect(update_manual_estimator) + self.telemetry_logger = TelemetryLogger( + log_directory = self.widgets["loggingPathEntry"].text(), + log_format = self.widgets["loggingFormatSelector"].currentText(), + enabled = self.widgets["enableLoggingSelector"].isChecked() + ) + # Init payload IDs and such in singleShot timer + self.payload_init_timer = QTimer() + self.payload_init_timer.singleShot(100, self.payload_init) -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: - 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) - except: - pass + # Add console handler to top level logger. + console_handler = ConsoleHandler(self.handle_log_update) + logging.getLogger().addHandler(console_handler) -widgets["horusManualEstimatorSelector"].clicked.connect(set_manual_estimator) + logging.info("Started GUI.") -def save_settings(): - """ Manually save current settings """ - global widgets - save_config(widgets) + def cleanup(self): + self.running = False -widgets["saveSettingsButton"].clicked.connect(save_settings) + try: + if self.horus_modem: + self.horus_modem.close() + self.horus_modem = None + except Exception as e: + pass + + try: + self.audio_stream.stop() + except Exception as e: + pass + try: + self.fft_process.stop() + except Exception as e: + pass -# Handlers for data arriving via queues. + try: + self.sondehub_uploader.close() + except: + pass -def handle_fft_update(data): - """ Handle a new FFT update """ - global widgets + if self.rotator: + try: + self.rotator.close() + except: + pass - _scale = data["scale"] - _data = data["fft"] - _dbfs = data["dbfs"] + try: + self.telemetry_logger.close() + except: + pass - 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)) + 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) - # Store new max - widgets["spectrumPlotRange"][1] = max(widgets["spectrumPlotRange"][0], _new_max) - widgets["spectrumPlot"].setYRange( - widgets["spectrumPlotRange"][0], widgets["spectrumPlotRange"][1] + 20 - ) + def update_modem_settings(self): + """ Update the modem setting widgets when a different modem is selected """ + populate_modem_settings(self.widgets) - # Ignore NaN values. - if np.isnan(_dbfs) or np.isinf(_dbfs): - return + def select_log_directory(self): + folder = str(QFileDialog.getExistingDirectory(None, "Select Directory")) - # 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!") + 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 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 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 -def handle_new_packet(frame): - """ Handle receipt of a newly decoded packet """ - global last_packet_time + # Enable logging + if self.telemetry_logger: + self.telemetry_logger.enabled = True + self.telemetry_logger.update_log_directory(self.widgets["loggingPathEntry"].text()) - 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 - + # 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.") - _decoded = None - # Grab SNR. - _snr = get_latest_snr() - #logging.info(f"Packet SNR: {_snr:.2f}") + # 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!
") - # Grab other metadata out of the GUI - _radio_dial = None + 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}") - 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 - + def update_manual_estimator(self): + """ Push a change to the manually defined estimator limits into the modem """ + _limits = self.widgets["estimatorRange"].getRegion() - _baud_rate = int(widgets["horusModemRateSelector"].currentText()) - _modulation_detail = HORUS_MODEM_LIST[widgets["horusModemSelector"].currentText()]['modulation_detail'] + _lower = _limits[0] + _upper = _limits[1] - 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) + if self.horus_modem != None: + self.horus_modem.set_estimator_limits(_lower, _upper) - 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)}") - + + 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: - # 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 + 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 - 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) + def save_settings(self): + """ Manually save current settings """ + save_config(self.widgets) - # Try and force a refresh of the displays. - QtWidgets.QApplication.processEvents() + # Handlers for data arriving via callbacks + def handle_fft_update(self, data): + """ Handle a new FFT update """ + _scale = data["scale"] + _data = data["fft"] + _dbfs = data["dbfs"] -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 self.widgets["fftSmoothingSelector"].isChecked(): + _tc = 0.25 + _plot_data = (self.widgets["spectrumPlotData"].getData()[1] * (1 - _tc) + (_data * _tc)) + self.widgets["spectrumPlotData"].setData(_scale, _plot_data) + else: + self.widgets["spectrumPlotData"].setData(_scale, _data) - if not running: - # Reset last packet time + # 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)) - 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_() + # Store new max + self.widgets["spectrumPlotRange"][1] = max(self.widgets["spectrumPlotRange"][0], _new_max) - 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 + self.widgets["spectrumPlot"].setYRange( + self.widgets["spectrumPlotRange"][0], self.widgets["spectrumPlotRange"][1] + 20 ) - # 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 - ) + # Ignore NaN values. + if np.isnan(_dbfs) or np.isinf(_dbfs): + return - # 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.") + # Use same IIR to smooth out dBFS readings a little. + _new_dbfs = float((self.widgets["audioDbfsValue_float"] * (1 - _tc)) + (_dbfs * _tc)) - # 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 + # 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 + + + def handle_status_update(self, status): + """ Handle a new status frame """ + + # 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) + + _fest_average = _fest_average/_fest_count + self.widgets["fest_float"] = _fest_average + + # 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:] + + # 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 + ) - else: - try: - audio_stream.stop() - except Exception as e: - logging.exception("Could not stop audio stream.", exc_info=e) + # Update SNR bar and label + self.widgets["snrLabel"].setText(f"{float(status.snr):2.1f}") + self.widgets["snrBar"].setValue(int(status.snr)) - 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) + def get_latest_snr(self): + _current_modem = self.widgets["horusModemSelector"].currentText() - horus_modem = None + _snr_update_rate = 2 # Hz - fft_update_queue = Queue(256) - status_update_queue = Queue(256) + 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 handle_new_packet_emit(self, frame): + self.new_packet_signal.info.emit(frame) + + 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 - widgets["startDecodeButton"].setText("Start") - running = False + # Grab SNR. + _snr = self.get_latest_snr() + #logging.info(f"Packet SNR: {_snr:.2f}") - 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) + # 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 + -widgets["startDecodeButton"].clicked.connect(start_decoding) + _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']) + 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)}") + + # 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']}") + + # Update telemetry fields + if 'battery_voltage' in _decoded: + self.widgets["latestTelemBattVoltageValue"].setText(f"{_decoded['battery_voltage']:.2f}") + else: + self.widgets["latestTelemBattVoltageValue"].setText("---") -def handle_log_update(log_update): - global widgets + if 'satellites' in _decoded: + self.widgets["latestTelemSatellitesValue"].setText(f"{_decoded['satellites']}") + else: + self.widgets["latestTelemSatellitesValue"].setText("---") - widgets["console"].appendPlainText(log_update) - # Make sure the scroll bar is right at the bottom. - _sb = widgets["console"].verticalScrollBar() - _sb.setValue(_sb.maximum()) + if 'temperature' in _decoded: + self.widgets["latestTelemTemperatureValue"].setText(f"{_decoded['temperature']:.1f}") + else: + self.widgets["latestTelemTemperatureValue"].setText("---") + if len(_decoded['custom_field_names']) > 0: + column = 0 + for field in _decoded['custom_field_names']: + field_nice = field.replace('_', ' ').title() + self.widgets[f"latestTelem{column}Label"].setText(f"{field_nice}") + self.widgets[f"latestTelem{column}Value"].setText(f"{_decoded[field]}") + self.widgets[f"latestTelem{column}Label"].show() + self.widgets[f"latestTelem{column}Value"].show() -# 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 + self.w5_telemetry.setColumnStretch((column + 3), 10) - while fft_update_queue.qsize() > 0: - _data = fft_update_queue.get() + column += 1 - handle_fft_update(_data) + # Hide remaining columns + if column < 8: + for i in range(column, 9): + self.widgets[f"latestTelem{column}Label"].hide() + self.widgets[f"latestTelem{column}Value"].hide() + self.w5_telemetry.setColumnStretch((i + 3), 1) - while status_update_queue.qsize() > 0: - _status = status_update_queue.get() + # 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}") + + _range_inhibit = False + if self.widgets["rotatorRangeInhibit"].isChecked() and (_position_info['straight_distance'] < 250): + logging.debug("Rotator - Not moving due to Range Inhibit (less than 250m)") + _range_inhibit = True + + if self.rotator and not ( _decoded['latitude'] == 0.0 and _decoded['longitude'] == 0.0 ) and not _range_inhibit: + 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) - handle_status_update(_status) + # Log telemetry + if self.telemetry_logger: + self.telemetry_logger.add(_decoded) - while log_update_queue.qsize() > 0: - _log = log_update_queue.get() - - handle_log_update(_log) + # Try and force a refresh of the displays. + QApplication.processEvents() - 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) + 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 -gui_update_timer = QtCore.QTimer() -gui_update_timer.timeout.connect(processQueues) -gui_update_timer.start(100) + 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("---") + + self.widgets["latestTelemBattVoltageValue"].setText("---") + self.widgets["latestTelemSatellitesValue"].setText("---") + self.widgets["latestTelemTemperatureValue"].setText("---") + + for column in range(0,9): + self.widgets[f"latestTelem{column}Value"].setText(f"---") + + # 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, + ) + # Create FFT Processor worker thread + worker = Worker(self.fft_process.processing_thread) + # worker.signals.result.connect(self.null_thread_complete) + # worker.signals.finished.connect(self.null_thread_complete) + worker.signals.info.connect(self.handle_fft_update) -# Rotator Control + self.threadpool.start(worker) -def startstop_rotator(): - global rotator, widgets + # 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_emit, + sample_rate=_sample_rate + ) - if rotator is None: - # Start a rotator connection. + # 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, + ) + + # Create UDP stream worker thread + worker = Worker(self.audio_stream.udp_listen_thread) + worker.signals.info.connect(self.handle_status_update) + self.threadpool.start(worker) + - try: - _host = widgets["rotatorHostEntry"].text() - _port = int(widgets["rotatorPortEntry"].text()) - _threshold = float(widgets["rotatorThresholdEntry"].text()) - except: - widgets["rotatorCurrentStatusValue"].setText("Bad Host/Port") - return + 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, + ) + + # Create AudioStream worker thred + worker = Worker(self.audio_stream.start_stream) + worker.signals.info.connect(self.handle_status_update) + self.threadpool.start(worker) + + self.widgets["startDecodeButton"].setText("Stop") + self.running = True + logging.info("Started Audio Processing.") + + # Start thread to update the last packet age + worker = Worker(self.decoded_age_thread) + # worker.signals.result.connect(self.null_thread_complete) + # worker.signals.finished.connect(self.null_thread_complete) + worker.signals.info.connect(self.handle_decoded_age_update) + + self.threadpool.start(worker) + + # 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 - if widgets["rotatorTypeSelector"].currentText() == "rotctld": + else: try: - rotator = ROTCTLD(hostname=_host, port=_port, threshold=_threshold) - rotator.connect() + self.audio_stream.stop() 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) + logging.exception("Could not stop audio stream.", exc_info=e) - else: - return + 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) - 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"---˚, --˚") + self.horus_modem = None + self.widgets["startDecodeButton"].setText("Start") + self.running = False -widgets["rotatorConnectButton"].clicked.connect(startstop_rotator) + 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 poll_rotator(): -# global rotator, widgets, rotator_current_az, rotator_current_el + 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()) -# if rotator: -# _az, _el = rotator.get_azel() -# if _az != None: -# rotator_current_az = _az + # Payload init + def payload_init(self): + global args -# if _el != None: -# rotator_current_el = _el + # Initialise decoders, and other libraries here. + init_payloads(payload_id_list = args.payload_id_list, custom_field_list = args.custom_field_list) + # Once initialised, enable the start button + self.widgets["startDecodeButton"].setEnabled(True) -# widgets["rotatorCurrentPositionValue"].setText(f"{rotator_current_az:3.1f}˚, {rotator_current_el:2.1f}˚") + # Thread to update last packet age + def decoded_age_thread(self, info_callback): + while 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)) + info_callback.emit(f"{_time_delta_hours:02d}:{_time_delta_minutes:02d}:{_time_delta_seconds:02d}") -# rotator_poll_timer = QtCore.QTimer() -# rotator_poll_timer.timeout.connect(poll_rotator) -# rotator_poll_timer.start(2000) + time.sleep(0.5) + def handle_decoded_age_update(self, text): + self.widgets['latestDecodedAgeData'].setText(text) -class ConsoleHandler(logging.Handler): - """ Logging handler to write to the GUI console """ + # Rotator Control + def startstop_rotator(self): + if self.rotator is None: + # Start a rotator connection. - def __init__(self, log_queue): - logging.Handler.__init__(self) - self.log_queue = log_queue + 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 - def emit(self, record): - _time = datetime.datetime.now() - _text = f"{_time.strftime('%H:%M:%S')} [{record.levelname}] {record.msg}" + 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) + + # Create worker thread for commanding rotator + worker = Worker(self.rotator.azel_rx_loop) + worker.signals.info.connect(self.info_callback) + self.threadpool.start(worker) + + # Create worker thread for receiving info from rotator + worker = Worker(self.rotator.azel_poll_loop) + worker.signals.info.connect(self.info_callback) + self.threadpool.start(worker) + else: + return - try: - self.log_queue.put_nowait(_text) - except: - print("Console Log Queue full!") + 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"---˚, --˚") -# Add console handler to top level logger. -console_handler = ConsoleHandler(log_update_queue) -logging.getLogger().addHandler(console_handler) + # def poll_rotator(): + # global rotator, widgets, rotator_current_az, rotator_current_el -logging.info("Started GUI.") + # if rotator: + # _az, _el = rotator.get_azel() + # if _az != None: + # rotator_current_az = _az + # if _el != None: + # rotator_current_el = _el -# Main -def main(): - # Start the Qt Loop - if (sys.flags.interactive != 1) or not hasattr(QtCore, "PYQT_VERSION"): - QtWidgets.QApplication.instance().exec() - save_config(widgets) + # self.widgets["rotatorCurrentPositionValue"].setText(f"{rotator_current_az:3.1f}˚, {rotator_current_el:2.1f}˚") - try: - audio_stream.stop() - except Exception as e: - pass + # rotator_poll_timer = QtCore.QTimer() + # rotator_poll_timer.timeout.connect(poll_rotator) + # rotator_poll_timer.start(2000) - try: - fft_process.stop() - except Exception as e: - pass + # Dummy function to call from worker threads + def null_thread_complete(self): + logging.debug("Thread exit!!!") + return - try: - sondehub_uploader.close() - except: - pass +class ConsoleHandler(logging.Handler): + """ Logging handler to write to the GUI console """ - try: - telemetry_logger.close() - except: - pass + def __init__(self, callback): + logging.Handler.__init__(self) + self.signaller = WorkerSignals() + self.signaller.info.connect(callback) - if rotator: + def emit(self, record): + _time = datetime.datetime.now() + _text = f"{_time.strftime('%H:%M:%S')} [{record.levelname}] {record.msg}" + + # TODO -- create gentle dismount when exiting try: - rotator.close() + self.signaller.info.emit(_text) except: pass +# Main +def main(): + app = QApplication(sys.argv) + 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"): + QApplication.instance().exec() + save_config(widgets) if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/horusgui/habitat.py b/horusgui/habitat.py deleted file mode 100644 index e450389..0000000 --- a/horusgui/habitat.py +++ /dev/null @@ -1,353 +0,0 @@ -#!/usr/bin/env python -# -# Horus Telemetry GUI - Habitat Uploader -# -# Mark Jessop -# - -import datetime -import json -import logging -import random -import requests -import time -from base64 import b64encode -from hashlib import sha256 -from queue import Queue -from threading import Thread - - -class HabitatUploader(object): - """ - Queued Habitat Telemetry Uploader class - - Packets to be uploaded to Habitat are added to a queue for uploading. - If an upload attempt times out, the packet is discarded. - If the queue fills up (probably indicating no network connection, and a fast packet downlink rate), - it is immediately emptied, to avoid upload of out-of-date packets. - """ - - HABITAT_URL = "http://habitat.habhub.org/" - HABITAT_DB = "habitat" - HABITAT_UUIDS = HABITAT_URL + "_uuids?count=%d" - HABITAT_DB_URL = HABITAT_URL + HABITAT_DB + "/" - - def __init__( - self, - user_callsign="FSK_DEMOD", - listener_lat=0.0, - listener_lon=0.0, - listener_radio="", - listener_antenna="", - queue_size=64, - upload_timeout=10, - upload_retries=5, - upload_retry_interval=0.25, - inhibit=False, - ): - """ Create a Habitat Uploader object. """ - - self.upload_timeout = upload_timeout - self.upload_retries = upload_retries - self.upload_retry_interval = upload_retry_interval - self.queue_size = queue_size - self.habitat_upload_queue = Queue(queue_size) - self.inhibit = inhibit - - # Listener information - self.user_callsign = user_callsign - self.listener_lat = listener_lat - self.listener_lon = listener_lon - self.listener_radio = listener_radio - self.listener_antenna = listener_antenna - self.position_uploaded = False - - self.last_freq_hz = None - - self.callsign_init = False - self.uuids = [] - - # Start the uploader thread. - self.habitat_uploader_running = True - self.uploadthread = Thread(target=self.habitat_upload_thread) - self.uploadthread.start() - - def habitat_upload(self, sentence): - """ Upload a UKHAS-standard telemetry sentence to Habitat """ - - # Generate payload to be uploaded - # b64encode accepts and returns bytes objects. - _sentence_b64 = b64encode(sentence.encode("ascii")) - _date = datetime.datetime.utcnow().isoformat("T") + "Z" - _user_call = self.user_callsign - - _data = { - "type": "payload_telemetry", - "data": { - "_raw": _sentence_b64.decode( - "ascii" - ) # Convert back to a string to be serialisable - }, - "receivers": { - _user_call: {"time_created": _date, "time_uploaded": _date,}, - }, - } - - if self.last_freq_hz: - # Add in frequency information if we have it. - _data["receivers"][_user_call]["rig_info"] = {"frequency": self.last_freq_hz} - - # The URl to upload to. - _url = f"{self.HABITAT_URL}{self.HABITAT_DB}/_design/payload_telemetry/_update/add_listener/{sha256(_sentence_b64).hexdigest()}" - - # Delay for a random amount of time between 0 and upload_retry_interval*2 seconds. - time.sleep(random.random() * self.upload_retry_interval * 2.0) - - _retries = 0 - - # When uploading, we have three possible outcomes: - # - Can't connect. No point re-trying in this situation. - # - The packet is uploaded successfult (201 / 403) - # - There is a upload conflict on the Habitat DB end (409). We can retry and it might work. - while _retries < self.upload_retries: - # Run the request. - try: - _req = requests.put( - _url, data=json.dumps(_data), timeout=(self.upload_timeout, 6.1) - ) - except Exception as e: - logging.error("Habitat - Upload Failed: %s" % str(e)) - break - - if _req.status_code == 201 or _req.status_code == 403: - # 201 = Success, 403 = Success, sentence has already seen by others. - logging.info(f"Habitat - Uploaded sentence: {sentence.strip()}") - _upload_success = True - break - elif _req.status_code == 409: - # 409 = Upload conflict (server busy). Sleep for a moment, then retry. - logging.debug("Habitat - Upload conflict.. retrying.") - time.sleep(random.random() * self.upload_retry_interval) - _retries += 1 - else: - logging.error( - "Habitat - Error uploading to Habitat. Status Code: %d." - % _req.status_code - ) - break - - if _retries == self.upload_retries: - logging.error( - "Habitat - Upload conflict not resolved with %d retries." - % self.upload_retries - ) - - return - - def habitat_upload_thread(self): - """ Handle uploading of packets to Habitat """ - - logging.info("Started Habitat Uploader Thread.") - - while self.habitat_uploader_running: - - if self.habitat_upload_queue.qsize() > 0: - # If the queue is completely full, jump to the most recent telemetry sentence. - if self.habitat_upload_queue.qsize() == self.queue_size: - while not self.habitat_upload_queue.empty(): - sentence = self.habitat_upload_queue.get() - - logging.warning( - "Habitat uploader queue was full - possible connectivity issue." - ) - else: - # Otherwise, get the first item in the queue. - sentence = self.habitat_upload_queue.get() - - # Attempt to upload it. - self.habitat_upload(sentence) - - else: - # Wait for a short time before checking the queue again. - time.sleep(0.5) - - # - # Habitat listener position update disabled 2022-09, due to Habitat going away... - # - # if not self.position_uploaded: - # # Validate the lat/lon entries. - # try: - # _lat = float(self.listener_lat) - # _lon = float(self.listener_lon) - - # if (_lat != 0.0) or (_lon != 0.0): - # _success = self.uploadListenerPosition( - # self.user_callsign, - # _lat, - # _lon, - # self.listener_radio, - # self.listener_antenna, - # ) - # else: - # logging.warning("Listener position set to 0.0/0.0 - not uploading.") - - # except Exception as e: - # logging.error("Error uploading listener position: %s" % str(e)) - - # # Set this flag regardless if the upload worked. - # # The user can trigger a re-upload. - # self.position_uploaded = True - - - logging.info("Stopped Habitat Uploader Thread.") - - def add(self, sentence): - """ Add a sentence to the upload queue """ - - if self.inhibit: - # We have upload inhibited. Return. - return - - # Handling of arbitrary numbers of $$'s at the start of a sentence: - # Extract the data part of the sentence (i.e. everything after the $$'s') - sentence = sentence.split("$")[-1] - # Now add the *correct* number of $$s back on. - sentence = "$$" + sentence - - if not (sentence[-1] == "\n"): - sentence += "\n" - - try: - self.habitat_upload_queue.put_nowait(sentence) - except Exception as e: - logging.error("Error adding sentence to queue: %s" % str(e)) - - def close(self): - """ Shutdown uploader thread. """ - self.habitat_uploader_running = False - - def ISOStringNow(self): - return "%sZ" % datetime.datetime.utcnow().isoformat() - - def postListenerData(self, doc, timeout=10): - - # do we have at least one uuid, if not go get more - if len(self.uuids) < 1: - self.fetchUuids() - - # Attempt to add UUID and time data to document. - try: - doc["_id"] = self.uuids.pop() - except IndexError: - logging.error( - "Habitat - Unable to post listener data - no UUIDs available." - ) - return False - - doc["time_uploaded"] = self.ISOStringNow() - - try: - _r = requests.post( - f"{self.HABITAT_URL}{self.HABITAT_DB}/", json=doc, timeout=timeout - ) - return True - except Exception as e: - logging.error("Habitat - Could not post listener data - %s" % str(e)) - return False - - def fetchUuids(self, timeout=10): - - _retries = 5 - - while _retries > 0: - try: - _r = requests.get(self.HABITAT_UUIDS % 10, timeout=timeout) - self.uuids.extend(_r.json()["uuids"]) - logging.debug("Habitat - Got UUIDs") - return - except Exception as e: - logging.error( - "Habitat - Unable to fetch UUIDs, retrying in 2 seconds - %s" - % str(e) - ) - time.sleep(2) - _retries = _retries - 1 - continue - - logging.error("Habitat - Gave up trying to get UUIDs.") - return - - def initListenerCallsign(self, callsign, radio="", antenna=""): - doc = { - "type": "listener_information", - "time_created": self.ISOStringNow(), - "data": {"callsign": callsign, "antenna": antenna, "radio": radio,}, - } - - resp = self.postListenerData(doc) - - if resp is True: - logging.debug("Habitat - Listener Callsign Initialized.") - return True - else: - logging.error("Habitat - Unable to initialize callsign.") - return False - - def uploadListenerPosition(self, callsign, lat, lon, radio="", antenna=""): - """ Initializer Listener Callsign, and upload Listener Position """ - - # Attempt to initialize the listeners callsign - resp = self.initListenerCallsign(callsign, radio=radio, antenna=antenna) - # If this fails, it means we can't contact the Habitat server, - # so there is no point continuing. - if resp is False: - return False - - doc = { - "type": "listener_telemetry", - "time_created": self.ISOStringNow(), - "data": { - "callsign": callsign, - "chase": False, - "latitude": lat, - "longitude": lon, - "altitude": 0, - "speed": 0, - }, - } - - # post position to habitat - resp = self.postListenerData(doc) - if resp is True: - logging.info("Habitat - Listener information uploaded.") - return True - else: - logging.error("Habitat - Unable to upload listener information.") - return False - - def trigger_position_upload(self): - """ Trigger a re-upload of the listener position """ - self.position_uploaded = False - - -if __name__ == "__main__": - - # Setup Logging - logging.basicConfig( - format="%(asctime)s %(levelname)s: %(message)s", level=logging.INFO - ) - - habitat = HabitatUploader( - user_callsign="HORUSGUI_TEST", - listener_lat=-34.0, - listener_lon=138.0, - listener_radio="Testing Habitat Uploader", - listener_antenna="Wet Noodle", - ) - - habitat.add("$$DUMMY,0,0.0,0.0*F000") - - time.sleep(10) - habitat.trigger_position_upload() - time.sleep(5) - habitat.close() diff --git a/horusgui/libhorus.dll b/horusgui/libhorus.dll new file mode 100644 index 0000000..ccc36cc Binary files /dev/null and b/horusgui/libhorus.dll differ diff --git a/horusgui/rotators.py b/horusgui/rotators.py index 20dd76b..6c8e591 100644 --- a/horusgui/rotators.py +++ b/horusgui/rotators.py @@ -9,7 +9,7 @@ import time import logging import traceback -from threading import Thread +# from threading import Thread class ROTCTLD(object): """ rotctld (hamlib) communication class """ @@ -112,11 +112,11 @@ def __init__(self, hostname='localhost', port=12000, poll_rate=1, threshold=5.0) self.poll_rate = poll_rate self.azel_thread_running = True - self.t_rx = Thread(target=self.azel_rx_loop) - self.t_rx.start() + # self.t_rx = Thread(target=self.azel_rx_loop) + # self.t_rx.start() - self.t_poll = Thread(target=self.azel_poll_loop) - self.t_poll.start() + # self.t_poll = Thread(target=self.azel_poll_loop) + # self.t_poll.start() def close(self): @@ -157,13 +157,13 @@ def poll_azel(self): except: pass - def azel_poll_loop(self): + def azel_poll_loop(self, info_callback=None): while self.azel_thread_running: self.poll_azel() logging.debug("Poll sent to PSTRotator.") time.sleep(self.poll_rate) - def azel_rx_loop(self): + def azel_rx_loop(self, info_callback=None): """ Listen for Azimuth and Elevation reports from PSTRotator""" s = socket.socket(socket.AF_INET,socket.SOCK_DGRAM) s.settimeout(1) diff --git a/horusgui/udpaudio.py b/horusgui/udpaudio.py index 3fb9132..b245678 100644 --- a/horusgui/udpaudio.py +++ b/horusgui/udpaudio.py @@ -1,7 +1,8 @@ # UDP Audio Source (Obtaining audio from GQRX) import socket import traceback -from threading import Thread +#from threading import Thread + class UDPStream(object): """ Listen for UDP Audio data from GQRX (s16, 48kHz), and pass data around to different callbacks """ @@ -19,13 +20,16 @@ def __init__(self, udp_port=7355, fs=48000, block_size=8192, fft_input=None, mod # Start audio stream self.listen_thread_running = True - self.listen_thread = Thread(target=self.udp_listen_thread) - self.listen_thread.start() + #self.listen_thread = Thread(target=self.udp_listen_thread) + #self.listen_thread.start() - def udp_listen_thread(self): + def udp_listen_thread(self, info_callback=None): """ Open a UDP socket and listen for incoming data """ + if info_callback: + self.stats_callback = info_callback + self.s = socket.socket(socket.AF_INET,socket.SOCK_DGRAM) self.s.settimeout(1) self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -50,7 +54,6 @@ def udp_listen_thread(self): self.s.close() - def handle_samples(self, data, frame_count, time_info="", status_flags=""): """ Handle incoming samples from pyaudio """ @@ -64,7 +67,7 @@ def handle_samples(self, data, frame_count, time_info="", status_flags=""): # Send any stats data back to the stats callback if _stats: if self.stats_callback: - self.stats_callback(_stats) + self.stats_callback.emit(_stats) return (None, None) 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/libhorus.dll b/libhorus.dll new file mode 100644 index 0000000..ccc36cc Binary files /dev/null and b/libhorus.dll differ 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