diff --git a/README.md b/README.md index 6020139..82eda6b 100755 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Telemetry demodulator for the following modems in use by Project Horus * Horus Binary Modes * v1 - Legacy 22 byte mode, Golay FEC * v2 - 16/32-byte modes, LDPC FEC (Still in development) -* RTTY (7N2 only) +* RTTY (7N2 only, for now) Written by Mark Jessop @@ -13,18 +13,14 @@ Written by Mark Jessop ### TODO LIST - Important Stuff -* Audio input via pyAudio and spectrum display. - DONE -* Integrate Horus Modems (need help from @xssfox!) - First pass DONE -* Basic display of decoded data (RTTY or HEX data for binary) - DONE -* Save/Reload settings to file - Initial pass done. -* Decode horus binary data (move horusbinary.py into a library?) -* Upload telemetry to Habitat, with upload status - DONE -* Better build system (build horuslib as part of package build?) +* Stop decoded data pane from resizing on bad/long decodes - TODO +* Export of telemetry via Horus UDP +* Better build system * Windows binary ### TODO LIST - Extras * UDP input from GQRX -* Waterfall Display +* Waterfall Display (? Need something GPU accelerated if possible...) ## Usage @@ -38,8 +34,6 @@ $ make $ make install ``` -TODO: Make a separate horusdemodlib python package, which handles building of the library. - ### Create a Virtual Environment Create a virtual environment and install dependencies. diff --git a/horusgui/fft.py b/horusgui/fft.py index e9388f6..d105177 100644 --- a/horusgui/fft.py +++ b/horusgui/fft.py @@ -13,6 +13,7 @@ class FFTProcess(object): self, nfft=8192, stride=4096, + update_decimation=1, fs=48000, sample_width=2, range=[100, 4000], @@ -20,6 +21,8 @@ class FFTProcess(object): ): self.nfft = nfft self.stride = stride + self.update_decimation = update_decimation + self.update_counter = 0 self.fs = fs self.sample_width = sample_width self.range = range @@ -61,7 +64,10 @@ class FFTProcess(object): ) - 20 * np.log10(self.nfft) if self.callback != None: - self.callback({"fft": _fft[self.mask], "scale": self.fft_scale[self.mask]}) + if self.update_counter % self.update_decimation == 0: + self.callback({"fft": _fft[self.mask], "scale": self.fft_scale[self.mask]}) + + self.update_counter += 1 def process_block(self, samples): """ Add a block of samples to the input buffer. Calculate and process FFTs if the buffer is big enough """ diff --git a/horusgui/gui.py b/horusgui/gui.py index 94090fb..10120e0 100644 --- a/horusgui/gui.py +++ b/horusgui/gui.py @@ -29,8 +29,9 @@ from .fft import * from .modem import * from .config import * from .habitat import * +from .utils import position_info from horusdemodlib.demod import HorusLib, Mode -from horusdemodlib.decoder import decode_packet +from horusdemodlib.decoder import decode_packet, parse_ukhas_string from horusdemodlib.payloads import * from . import __version__ @@ -76,10 +77,11 @@ d0 = Dock("Audio", size=(300, 50)) d0_modem = Dock("Modem", size=(300, 80)) d0_habitat = Dock("Habitat", size=(300, 200)) d0_other = Dock("Other", size=(300, 100)) -d1 = Dock("Spectrum", size=(800, 400)) +d1 = Dock("Spectrum", size=(800, 350)) d2_stats = Dock("Modem Stats", size=(70, 300)) d2_snr = Dock("SNR", size=(730, 300)) -d3 = Dock("Data", size=(800, 50)) +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) @@ -88,8 +90,9 @@ area.addDock(d0_modem, "bottom", d0) area.addDock(d0_habitat, "bottom", d0_modem) area.addDock(d0_other, "below", d0_habitat) area.addDock(d2_stats, "bottom", d1) -area.addDock(d3, "bottom", d2_stats) -area.addDock(d4, "bottom", d3) +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() @@ -273,18 +276,65 @@ w3_snr.addWidget(widgets["snrPlot"], 0, 1, 2, 1) d2_snr.addWidget(w3_snr) # Telemetry Data -w4 = pg.LayoutWidget() +w4_data = pg.LayoutWidget() widgets["latestRawSentenceLabel"] = QtGui.QLabel("Latest Packet (Raw):") widgets["latestRawSentenceData"] = QtGui.QLabel("NO DATA") widgets["latestRawSentenceData"].setFont(QtGui.QFont("Courier New", 18, QtGui.QFont.Bold)) +widgets["latestRawSentenceData"].setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) widgets["latestDecodedSentenceLabel"] = QtGui.QLabel("Latest Packet (Decoded):") widgets["latestDecodedSentenceData"] = QtGui.QLabel("NO DATA") widgets["latestDecodedSentenceData"].setFont(QtGui.QFont("Courier New", 18, QtGui.QFont.Bold)) -w4.addWidget(widgets["latestRawSentenceLabel"], 0, 0, 1, 1) -w4.addWidget(widgets["latestRawSentenceData"], 0, 1, 1, 6) -w4.addWidget(widgets["latestDecodedSentenceLabel"], 1, 0, 1, 1) -w4.addWidget(widgets["latestDecodedSentenceData"], 1, 1, 1, 6) -d3.addWidget(w4) +widgets["latestDecodedSentenceData"].setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) +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) +d3_data.addWidget(w4_data) + +w4_position = pg.LayoutWidget() +widgets["latestPacketCallsignLabel"] = QtGui.QLabel("Callsign") +widgets["latestPacketCallsignValue"] = QtGui.QLabel("---") +widgets["latestPacketCallsignValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.Bold)) +widgets["latestPacketTimeLabel"] = QtGui.QLabel("Time") +widgets["latestPacketTimeValue"] = QtGui.QLabel("---") +widgets["latestPacketTimeValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.Bold)) +widgets["latestPacketLatitudeLabel"] = QtGui.QLabel("Latitude") +widgets["latestPacketLatitudeValue"] = QtGui.QLabel("---") +widgets["latestPacketLatitudeValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.Bold)) +widgets["latestPacketLongitudeLabel"] = QtGui.QLabel("Longitude") +widgets["latestPacketLongitudeValue"] = QtGui.QLabel("---") +widgets["latestPacketLongitudeValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.Bold)) +widgets["latestPacketAltitudeLabel"] = QtGui.QLabel("Altitude") +widgets["latestPacketAltitudeValue"] = QtGui.QLabel("---") +widgets["latestPacketAltitudeValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.Bold)) +widgets["latestPacketBearingLabel"] = QtGui.QLabel("Bearing") +widgets["latestPacketBearingValue"] = QtGui.QLabel("---") +widgets["latestPacketBearingValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.Bold)) +widgets["latestPacketElevationLabel"] = QtGui.QLabel("Elevation") +widgets["latestPacketElevationValue"] = QtGui.QLabel("---") +widgets["latestPacketElevationValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.Bold)) +widgets["latestPacketRangeLabel"] = QtGui.QLabel("Range (km)") +widgets["latestPacketRangeValue"] = QtGui.QLabel("---") +widgets["latestPacketRangeValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.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() @@ -442,20 +492,37 @@ def handle_new_packet(frame): if len(frame.data) > 0: if type(frame.data) == bytes: - _packet = frame.data.hex() + # 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 + # Update the raw display. widgets["latestRawSentenceData"].setText(f"{_packet}") - # Immediately upload RTTY packets. - if _packet.startswith('$$$$$'): - # TODO: Check CRC!!! - habitat_uploader.add(_packet[3:]+'\n') - widgets["latestDecodedSentenceData"].setText(f"{_packet}") - else: - # TODO: Handle binary packets. + _decoded = None + + if type(frame.data) == str: + # RTTY packet handling. + # Attempt to extract fields from it: + try: + _decoded = parse_ukhas_string(frame.data) + # If we get here, the string is valid! + widgets["latestDecodedSentenceData"].setText(f"{_packet}") + + # Upload the string to Habitat + _decoded_str = "$$" + frame.data.split('$')[-1] + '\n' + habitat_uploader.add(_decoded_str) + + except Exception as e: + widgets["latestDecodedSentenceData"].setText("DECODE FAILED") + logging.error(f"Decode Failed: {str(e)}") + + else: + # Handle binary packets try: _decoded = decode_packet(frame.data) widgets["latestDecodedSentenceData"].setText(_decoded['ukhas_str']) @@ -463,10 +530,38 @@ def handle_new_packet(frame): except Exception as e: 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 = 0.0 + + 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}") + except Exception as e: + logging.error(f"Could not calculate relative position to payload - {str(e)}") + + def start_decoding(): - global widgets, audio_stream, fft_process, horus_modem, audio_devices, running, fft_update_queue, status_update_queue + global widgets, audio_stream, fft_process, horus_modem, habitat_uploader, audio_devices, running, fft_update_queue, status_update_queue if not running: # Grab settings off widgets @@ -491,13 +586,27 @@ def start_decoding(): # 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 Habitat upload is set correctly. + habitat_uploader.inhibit = not widgets["habitatUploadSelector"].isChecked() # Init FFT Processor - NFFT = 2 ** 14 + NFFT = 2 ** 13 STRIDE = 2 ** 13 fft_process = FFTProcess( - nfft=NFFT, stride=STRIDE, fs=_sample_rate, callback=add_fft_update + nfft=NFFT, + stride=STRIDE, + update_decimation=1, + fs=_sample_rate, + callback=add_fft_update ) # Setup Modem diff --git a/horusgui/utils.py b/horusgui/utils.py new file mode 100644 index 0000000..70e4903 --- /dev/null +++ b/horusgui/utils.py @@ -0,0 +1,83 @@ +from math import radians, degrees, sin, cos, atan2, sqrt, pi + +# Earthmaths code by Daniel Richman (thanks!) +# Copyright 2012 (C) Daniel Richman; GNU GPL 3 +def position_info(listener, balloon): + """ + Calculate and return information from 2 (lat, lon, alt) tuples + + Returns a dict with: + + - angle at centre + - great circle distance + - distance in a straight line + - bearing (azimuth or initial course) + - elevation (altitude) + + Input and output latitudes, longitudes, angles, bearings and elevations are + in degrees, and input altitudes and output distances are in meters. + """ + + # Earth: + radius = 6371000.0 + + (lat1, lon1, alt1) = listener + (lat2, lon2, alt2) = balloon + + lat1 = radians(lat1) + lat2 = radians(lat2) + lon1 = radians(lon1) + lon2 = radians(lon2) + + # Calculate the bearing, the angle at the centre, and the great circle + # distance using Vincenty's_formulae with f = 0 (a sphere). See + # http://en.wikipedia.org/wiki/Great_circle_distance#Formulas and + # http://en.wikipedia.org/wiki/Great-circle_navigation and + # http://en.wikipedia.org/wiki/Vincenty%27s_formulae + d_lon = lon2 - lon1 + sa = cos(lat2) * sin(d_lon) + sb = (cos(lat1) * sin(lat2)) - (sin(lat1) * cos(lat2) * cos(d_lon)) + bearing = atan2(sa, sb) + aa = sqrt((sa ** 2) + (sb ** 2)) + ab = (sin(lat1) * sin(lat2)) + (cos(lat1) * cos(lat2) * cos(d_lon)) + angle_at_centre = atan2(aa, ab) + great_circle_distance = angle_at_centre * radius + + # Armed with the angle at the centre, calculating the remaining items + # is a simple 2D triangley circley problem: + + # Use the triangle with sides (r + alt1), (r + alt2), distance in a + # straight line. The angle between (r + alt1) and (r + alt2) is the + # angle at the centre. The angle between distance in a straight line and + # (r + alt1) is the elevation plus pi/2. + + # Use sum of angle in a triangle to express the third angle in terms + # of the other two. Use sine rule on sides (r + alt1) and (r + alt2), + # expand with compound angle formulae and solve for tan elevation by + # dividing both sides by cos elevation + ta = radius + alt1 + tb = radius + alt2 + ea = (cos(angle_at_centre) * tb) - ta + eb = sin(angle_at_centre) * tb + elevation = atan2(ea, eb) + + # Use cosine rule to find unknown side. + distance = sqrt((ta ** 2) + (tb ** 2) - 2 * tb * ta * cos(angle_at_centre)) + + # Give a bearing in range 0 <= b < 2pi + if bearing < 0: + bearing += 2 * pi + + return { + "listener": listener, "balloon": balloon, + "listener_radians": (lat1, lon1, alt1), + "balloon_radians": (lat2, lon2, alt2), + "angle_at_centre": degrees(angle_at_centre), + "angle_at_centre_radians": angle_at_centre, + "bearing": degrees(bearing), + "bearing_radians": bearing, + "great_circle_distance": great_circle_distance, + "straight_distance": distance, + "elevation": degrees(elevation), + "elevation_radians": elevation + }