kopia lustrzana https://github.com/projecthorus/horus-gui
Add relative position displays.
rodzic
7354e3928f
commit
c515c9eed9
16
README.md
16
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 <vk5qi@rfhead.net>
|
||||
|
@ -13,18 +13,14 @@ Written by Mark Jessop <vk5qi@rfhead.net>
|
|||
|
||||
|
||||
### 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.
|
||||
|
|
|
@ -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 """
|
||||
|
|
153
horusgui/gui.py
153
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("<b>Latest Packet (Raw):</b>")
|
||||
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("<b>Latest Packet (Decoded):</b>")
|
||||
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("<b>Callsign</b>")
|
||||
widgets["latestPacketCallsignValue"] = QtGui.QLabel("---")
|
||||
widgets["latestPacketCallsignValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.Bold))
|
||||
widgets["latestPacketTimeLabel"] = QtGui.QLabel("<b>Time</b>")
|
||||
widgets["latestPacketTimeValue"] = QtGui.QLabel("---")
|
||||
widgets["latestPacketTimeValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.Bold))
|
||||
widgets["latestPacketLatitudeLabel"] = QtGui.QLabel("<b>Latitude</b>")
|
||||
widgets["latestPacketLatitudeValue"] = QtGui.QLabel("---")
|
||||
widgets["latestPacketLatitudeValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.Bold))
|
||||
widgets["latestPacketLongitudeLabel"] = QtGui.QLabel("<b>Longitude</b>")
|
||||
widgets["latestPacketLongitudeValue"] = QtGui.QLabel("---")
|
||||
widgets["latestPacketLongitudeValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.Bold))
|
||||
widgets["latestPacketAltitudeLabel"] = QtGui.QLabel("<b>Altitude</b>")
|
||||
widgets["latestPacketAltitudeValue"] = QtGui.QLabel("---")
|
||||
widgets["latestPacketAltitudeValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.Bold))
|
||||
widgets["latestPacketBearingLabel"] = QtGui.QLabel("<b>Bearing</b>")
|
||||
widgets["latestPacketBearingValue"] = QtGui.QLabel("---")
|
||||
widgets["latestPacketBearingValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.Bold))
|
||||
widgets["latestPacketElevationLabel"] = QtGui.QLabel("<b>Elevation</b>")
|
||||
widgets["latestPacketElevationValue"] = QtGui.QLabel("---")
|
||||
widgets["latestPacketElevationValue"].setFont(QtGui.QFont("Courier New", 16, QtGui.QFont.Bold))
|
||||
widgets["latestPacketRangeLabel"] = QtGui.QLabel("<b>Range (km)</b>")
|
||||
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
|
||||
|
|
|
@ -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
|
||||
}
|
Ładowanie…
Reference in New Issue