Merge pull request #46 from ke5gdb/pyqt6

Update Horus GUI to PyQt6
pull/53/head v0.4.0
Mark Jessop 2025-01-31 09:49:37 +10:30 zatwierdzone przez GitHub
commit 6fec273eaf
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
13 zmienionych plików z 1547 dodań i 1673 usunięć

Wyświetl plik

@ -56,6 +56,7 @@ $ cd horusdemodlib && mkdir build && cd build
$ cmake .. $ cmake ..
$ make $ make
$ sudo make install $ sudo make install
$ sudo ldconfig
``` ```
### Grab this Repo ### Grab this Repo

Wyświetl plik

@ -1 +1 @@
__version__ = "0.3.19" __version__ = "0.4.0"

Wyświetl plik

@ -1,6 +1,7 @@
# Audio Interfacing # Audio Interfacing
import logging import logging
import pyaudio import pyaudio
import time
# Global PyAudio object # Global PyAudio object
@ -125,8 +126,14 @@ class AudioStream(object):
self.modem = modem self.modem = modem
self.stats_callback = stats_callback self.stats_callback = stats_callback
# Start audio stream self.audio_thread_running = True
self.audio = pyaudio.PyAudio() self.audio = pyaudio.PyAudio()
def start_stream(self, info_callback=None):
if info_callback:
self.stats_callback = info_callback
self.stream = self.audio.open( self.stream = self.audio.open(
format=pyaudio.paInt16, format=pyaudio.paInt16,
channels=1, channels=1,
@ -138,6 +145,11 @@ class AudioStream(object):
stream_callback=self.handle_samples, 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=""): def handle_samples(self, data, frame_count, time_info="", status_flags=""):
""" Handle incoming samples from pyaudio """ """ Handle incoming samples from pyaudio """
@ -151,10 +163,11 @@ class AudioStream(object):
# Send any stats data back to the stats callback # Send any stats data back to the stats callback
if _stats: if _stats:
if self.stats_callback: if self.stats_callback:
self.stats_callback(_stats) self.stats_callback.emit(_stats)
return (None, pyaudio.paContinue) return (None, pyaudio.paContinue)
def stop(self): def stop(self):
""" Halt stream """ """ Halt stream """
self.stream.close() self.audio_thread_running = False
self.stream.close()

Wyświetl plik

@ -34,9 +34,11 @@ default_config = {
"rotator_type": "rotctld", "rotator_type": "rotctld",
"rotator_host": "localhost", "rotator_host": "localhost",
"rotator_port": 4533, "rotator_port": 4533,
"rotator_rangeinhibit": True,
"logging_enabled": False, "logging_enabled": False,
"log_format": "CSV", "log_format": "CSV",
"log_directory": "", "log_directory": "",
"fft_smoothing": False,
"payload_list": json.dumps(horusdemodlib.payloads.HORUS_PAYLOAD_LIST), "payload_list": json.dumps(horusdemodlib.payloads.HORUS_PAYLOAD_LIST),
"custom_field_list": json.dumps({}) "custom_field_list": json.dumps({})
} }
@ -76,7 +78,7 @@ def read_config(widgets):
global qt_settings, default_config global qt_settings, default_config
# This is getting a bit ridiculous, need to re-think this approach. # 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 # Try and read in the version parameter from QSettings
if qt_settings.value("version") not in OK_VERSIONS: 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["rotatorTypeSelector"].setCurrentText(default_config["rotator_type"])
widgets["rotatorHostEntry"].setText(str(default_config["rotator_host"])) widgets["rotatorHostEntry"].setText(str(default_config["rotator_host"]))
widgets["rotatorPortEntry"].setText(str(default_config["rotator_port"])) widgets["rotatorPortEntry"].setText(str(default_config["rotator_port"]))
widgets["rotatorRangeInhibit"].setChecked(ValueToBool(default_config["rotator_rangeinhibit"]))
# Logging Settings # Logging Settings
widgets["loggingPathEntry"].setText(str(default_config["log_directory"])) widgets["loggingPathEntry"].setText(str(default_config["log_directory"]))
widgets["loggingFormatSelector"].setCurrentText(default_config["log_format"]) widgets["loggingFormatSelector"].setCurrentText(default_config["log_format"])
widgets["enableLoggingSelector"].setChecked(ValueToBool(default_config["logging_enabled"])) widgets["enableLoggingSelector"].setChecked(ValueToBool(default_config["logging_enabled"]))
widgets["fftSmoothingSelector"].setChecked(ValueToBool(default_config["fft_smoothing"]))
if default_config['baud_rate'] != -1: if default_config['baud_rate'] != -1:
widgets["horusModemRateSelector"].setCurrentText(str(default_config['baud_rate'])) 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_type"] = widgets["rotatorTypeSelector"].currentText()
default_config["rotator_host"] = widgets["rotatorHostEntry"].text() default_config["rotator_host"] = widgets["rotatorHostEntry"].text()
default_config["rotator_port"] = int(widgets["rotatorPortEntry"].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["logging_enabled"] = widgets["enableLoggingSelector"].isChecked()
default_config["log_directory"] = widgets["loggingPathEntry"].text() default_config["log_directory"] = widgets["loggingPathEntry"].text()
default_config["log_format"] = widgets["loggingFormatSelector"].currentText() 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["payload_list"] = json.dumps(horusdemodlib.payloads.HORUS_PAYLOAD_LIST)
default_config["custom_field_list"] = json.dumps(horusdemodlib.payloads.HORUS_CUSTOM_FIELDS) default_config["custom_field_list"] = json.dumps(horusdemodlib.payloads.HORUS_CUSTOM_FIELDS)

Wyświetl plik

@ -3,7 +3,7 @@ import logging
import time import time
import numpy as np import numpy as np
from queue import Queue from queue import Queue
from threading import Thread #from threading import Thread
class FFTProcess(object): class FFTProcess(object):
@ -37,8 +37,8 @@ class FFTProcess(object):
self.processing_thread_running = True self.processing_thread_running = True
self.t = Thread(target=self.processing_thread) #self.t = Thread(target=self.processing_thread)
self.t.start() #self.t.start()
def init_window(self): def init_window(self):
""" Initialise Window functions and FFT scales. """ """ Initialise Window functions and FFT scales. """
@ -74,7 +74,7 @@ class FFTProcess(object):
if self.callback != None: if self.callback != None:
if self.update_counter % self.update_decimation == 0: 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 self.update_counter += 1
@ -86,7 +86,9 @@ class FFTProcess(object):
while len(self.sample_buffer) > self.nfft * self.sample_width: while len(self.sample_buffer) > self.nfft * self.sample_width:
self.perform_fft() 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: while self.processing_thread_running:
if self.input_queue.qsize() > 0: if self.input_queue.qsize() > 0:
@ -95,6 +97,8 @@ class FFTProcess(object):
else: else:
time.sleep(0.01) time.sleep(0.01)
logging.debug("Stopped FFT processing thread")
def add_samples(self, samples): def add_samples(self, samples):
""" Add a block of samples to the input queue """ """ Add a block of samples to the input queue """
try: try:

Plik diff jest za duży Load Diff

Wyświetl plik

@ -1,353 +0,0 @@
#!/usr/bin/env python
#
# Horus Telemetry GUI - Habitat Uploader
#
# Mark Jessop <vk5qi@rfhead.net>
#
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()

Plik binarny nie jest wyświetlany.

Wyświetl plik

@ -9,7 +9,7 @@ import socket
import time import time
import logging import logging
import traceback import traceback
from threading import Thread # from threading import Thread
class ROTCTLD(object): class ROTCTLD(object):
""" rotctld (hamlib) communication class """ """ rotctld (hamlib) communication class """
@ -112,11 +112,11 @@ class PSTRotator(object):
self.poll_rate = poll_rate self.poll_rate = poll_rate
self.azel_thread_running = True self.azel_thread_running = True
self.t_rx = Thread(target=self.azel_rx_loop) # self.t_rx = Thread(target=self.azel_rx_loop)
self.t_rx.start() # self.t_rx.start()
self.t_poll = Thread(target=self.azel_poll_loop) # self.t_poll = Thread(target=self.azel_poll_loop)
self.t_poll.start() # self.t_poll.start()
def close(self): def close(self):
@ -157,13 +157,13 @@ class PSTRotator(object):
except: except:
pass pass
def azel_poll_loop(self): def azel_poll_loop(self, info_callback=None):
while self.azel_thread_running: while self.azel_thread_running:
self.poll_azel() self.poll_azel()
logging.debug("Poll sent to PSTRotator.") logging.debug("Poll sent to PSTRotator.")
time.sleep(self.poll_rate) 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""" """ Listen for Azimuth and Elevation reports from PSTRotator"""
s = socket.socket(socket.AF_INET,socket.SOCK_DGRAM) s = socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
s.settimeout(1) s.settimeout(1)

Wyświetl plik

@ -1,7 +1,8 @@
# UDP Audio Source (Obtaining audio from GQRX) # UDP Audio Source (Obtaining audio from GQRX)
import socket import socket
import traceback import traceback
from threading import Thread #from threading import Thread
class UDPStream(object): class UDPStream(object):
""" Listen for UDP Audio data from GQRX (s16, 48kHz), and pass data around to different callbacks """ """ Listen for UDP Audio data from GQRX (s16, 48kHz), and pass data around to different callbacks """
@ -19,13 +20,16 @@ class UDPStream(object):
# Start audio stream # Start audio stream
self.listen_thread_running = True self.listen_thread_running = True
self.listen_thread = Thread(target=self.udp_listen_thread) #self.listen_thread = Thread(target=self.udp_listen_thread)
self.listen_thread.start() #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 """ """ 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 = socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
self.s.settimeout(1) self.s.settimeout(1)
self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@ -50,7 +54,6 @@ class UDPStream(object):
self.s.close() self.s.close()
def handle_samples(self, data, frame_count, time_info="", status_flags=""): def handle_samples(self, data, frame_count, time_info="", status_flags=""):
""" Handle incoming samples from pyaudio """ """ Handle incoming samples from pyaudio """
@ -64,7 +67,7 @@ class UDPStream(object):
# Send any stats data back to the stats callback # Send any stats data back to the stats callback
if _stats: if _stats:
if self.stats_callback: if self.stats_callback:
self.stats_callback(_stats) self.stats_callback.emit(_stats)
return (None, None) return (None, None)

Wyświetl plik

@ -1,9 +1,9 @@
# Useful widgets # Useful widgets
from PyQt5 import QtWidgets from PyQt6 import QtWidgets
# Useful class for adding horizontal lines. # Useful class for adding horizontal lines.
class QHLine(QtWidgets.QFrame): class QHLine(QtWidgets.QFrame):
def __init__(self): def __init__(self):
super(QHLine, self).__init__() super(QHLine, self).__init__()
self.setFrameShape(QtWidgets.QFrame.HLine) self.setFrameShape(QtWidgets.QFrame.Shape.HLine)
self.setFrameShadow(QtWidgets.QFrame.Sunken) self.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken)

BIN
libhorus.dll 100644

Plik binarny nie jest wyświetlany.

Wyświetl plik

@ -1,7 +1,8 @@
numpy numpy
pyaudio pyaudio
crcmod crcmod
PyQt5 PyQt6
pyqtgraph pyqtgraph
requests requests
horusdemodlib>=0.3.12 horusdemodlib>=0.3.12
audioop-lts; python_version>='3.13'