diff --git a/README.md b/README.md index ec66679..6020139 100755 --- a/README.md +++ b/README.md @@ -30,23 +30,23 @@ Written by Mark Jessop ### Build HorusLib -``` -$ git clone https://github.com/projecthorus/horuslib.git -$ cd horuslib && mkdir build_linux && cd build_linux +```console +$ git clone https://github.com/projecthorus/horusdemodlib.git +$ cd horusdemodlib && mkdir build && cd build $ cmake .. $ make -$ cp src/libhorus.so ../../ +$ make install ``` -(Replace .so with .dylib under OSX) - -TODO: Make a separate horuslib python package, which handles building of the library. +TODO: Make a separate horusdemodlib python package, which handles building of the library. ### Create a Virtual Environment Create a virtual environment and install dependencies. ```console +$ git clone https://github.com/projecthorus/horus-gui.git +$ cd horus-gui $ python3 -m venv venv $ source venv/bin/activate (venv) $ pip install pip -U (Optional - this updates pip) @@ -64,4 +64,6 @@ entry points so it can be used like a normal install. ``` ### Run -`$ python -m horusgui.gui` \ No newline at end of file +```console +$ python -m horusgui.gui +``` \ No newline at end of file diff --git a/horusgui/__init__.py b/horusgui/__init__.py new file mode 100755 index 0000000..3dc1f76 --- /dev/null +++ b/horusgui/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/horusgui/audio.py b/horusgui/audio.py similarity index 100% rename from src/horusgui/audio.py rename to horusgui/audio.py diff --git a/src/horusgui/config.py b/horusgui/config.py similarity index 100% rename from src/horusgui/config.py rename to horusgui/config.py diff --git a/src/horusgui/fft.py b/horusgui/fft.py similarity index 100% rename from src/horusgui/fft.py rename to horusgui/fft.py diff --git a/src/horusgui/gui.py b/horusgui/gui.py similarity index 92% rename from src/horusgui/gui.py rename to horusgui/gui.py index 5b1fe0a..6f80bc2 100644 --- a/src/horusgui/gui.py +++ b/horusgui/gui.py @@ -29,7 +29,9 @@ from .fft import * from .modem import * from .config import * from .habitat import * -from .horuslib import HorusLib, Mode +from horusdemodlib.demod import HorusLib, Mode +from horusdemodlib.decoder import decode_packet +from horusdemodlib.payloads import * from . import __version__ # Setup Logging @@ -51,6 +53,8 @@ fft_process = None horus_modem = None habitat_uploader = None +decoder_init = False + # Global running indicator running = False @@ -269,11 +273,16 @@ d2_snr.addWidget(w3_snr) # Telemetry Data w4 = pg.LayoutWidget() -widgets["latestSentenceLabel"] = QtGui.QLabel("Latest Sentence:") -widgets["latestSentenceData"] = QtGui.QLabel("NO DATA") -widgets["latestSentenceData"].setFont(QtGui.QFont("Courier New", 18, QtGui.QFont.Bold)) -w4.addWidget(widgets["latestSentenceLabel"], 0, 0, 1, 1) -w4.addWidget(widgets["latestSentenceData"], 0, 1, 1, 6) +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["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) w5 = pg.LayoutWidget() @@ -436,15 +445,22 @@ def handle_new_packet(frame): else: _packet = frame.data - widgets["latestSentenceData"].setText(f"{_packet}") + widgets["latestRawSentenceData"].setText(f"{_packet}") # Immediately upload RTTY packets. - # Why are we getting packets from the decoder twice? if _packet.startswith('$$$$$'): + # TODO: Check CRC!!! habitat_uploader.add(_packet[3:]+'\n') else: # TODO: Handle binary packets. - pass + + try: + _decoded = decode_packet(frame.data) + widgets["latestDecodedSentenceData"].setText(_decoded['ukhas_str']) + habitat_uploader.add(_decoded['ukhas_str']+'\n') + except Exception as e: + widgets["latestDecodedSentenceData"].setText("DECODE FAILED") + logging.error(f"Decode Failed: {str(e)}") def start_decoding(): @@ -529,7 +545,7 @@ widgets["startDecodeButton"].clicked.connect(start_decoding) # 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 + global fft_update_queue, status_update_queue, decoder_init while fft_update_queue.qsize() > 0: _data = fft_update_queue.get() @@ -541,6 +557,11 @@ def processQueues(): handle_status_update(_status) + if not decoder_init: + init_payload_id_list() + init_custom_field_list() + decoder_init = True + gui_update_timer = QtCore.QTimer() gui_update_timer.timeout.connect(processQueues) diff --git a/src/horusgui/habitat.py b/horusgui/habitat.py similarity index 100% rename from src/horusgui/habitat.py rename to horusgui/habitat.py diff --git a/src/horusgui/horusudp.py b/horusgui/horusudp.py similarity index 100% rename from src/horusgui/horusudp.py rename to horusgui/horusudp.py diff --git a/src/horusgui/modem.py b/horusgui/modem.py similarity index 98% rename from src/horusgui/modem.py rename to horusgui/modem.py index 2861263..621ff25 100644 --- a/src/horusgui/modem.py +++ b/horusgui/modem.py @@ -1,6 +1,6 @@ # Modem Interfacing import logging -from .horuslib import Mode +from horusdemodlib.demod import Mode # Modem paramers and defaults diff --git a/src/horusgui/widgets.py b/horusgui/widgets.py similarity index 100% rename from src/horusgui/widgets.py rename to horusgui/widgets.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c8b4647 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[tool.poetry] +name = "horus-gui" +version = "0.1.0" +description = "" +authors = ["Mark Jessop "] + +[tool.poetry.dependencies] +python = "^3.6" +requests = "^2.24.0" +crcmod = "^1.7" +PyQt5 = "^5.13.0" +pyqtgraph = "^0.11.0" +pyaudio = "^0.2.11" +ruamel.yaml = "^0.16.10" +horusdemodlib = "^0.1.1" + +[tool.poetry.dev-dependencies] + +[build-system] +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" diff --git a/requirements.txt b/requirements.txt index ffb35b2..8de2b6f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ numpy pyaudio -requests crcmod PyQt5 pyqtgraph ruamel.yaml -requests \ No newline at end of file +requests +horusdemodlib \ No newline at end of file diff --git a/setup.py b/setup.py index 91aab7b..c0d03d2 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages regexp = re.compile(r".*__version__ = [\'\"](.*?)[\'\"]", re.S) -init_file = os.path.join(os.path.dirname(__file__), "src", "horusgui", "__init__.py") +init_file = os.path.join(os.path.dirname(__file__), "horusgui", "__init__.py") with open(init_file, "r") as f: module_content = f.read() match = regexp.match(module_content) @@ -29,13 +29,13 @@ with open("requirements.txt", "r") as f: if __name__ == "__main__": setup( name="horusgui", - description="Project Horus Telemetry Decoer", + description="Project Horus GUI Telemetry Decdoer", long_description=readme, version=version, install_requires=requirements, keywords=["horus telemetry radio"], - package_dir={"": "src"}, - packages=find_packages("src"), + package_dir={"": "."}, + packages=find_packages("."), classifiers=[ "Intended Audience :: Developers", "Programming Language :: Python :: 3.6", diff --git a/src/horusgui/__init__.py b/src/horusgui/__init__.py deleted file mode 100755 index 210f529..0000000 --- a/src/horusgui/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "20.6.21" diff --git a/src/horusgui/horuslib.py b/src/horusgui/horuslib.py deleted file mode 100644 index 4e624b5..0000000 --- a/src/horusgui/horuslib.py +++ /dev/null @@ -1,377 +0,0 @@ -#!/usr/bin/env python3 - -import audioop -import ctypes -from ctypes import * -import logging -import sys -from enum import Enum -import os -import logging - -MODEM_STATS_NR_MAX = 8 -MODEM_STATS_NC_MAX = 50 -MODEM_STATS_ET_MAX = 8 -MODEM_STATS_EYE_IND_MAX = 160 -MODEM_STATS_NSPEC = 512 -MODEM_STATS_MAX_F_EST = 4 - - -class COMP(Structure): - """ - Used in MODEM_STATS for representing IQ. - """ - _fields_ = [ - ("real", c_float), - ("imag", c_float) - ] - - -class MODEM_STATS(Structure): # modem_stats.h - """ - Extended modem stats structure - """ - _fields_ = [ - ("Nc", c_int), - ("snr_est", c_float), - # rx_symbols[MODEM_STATS_NR_MAX][MODEM_STATS_NC_MAX+1]; - ("rx_symbols", (COMP * MODEM_STATS_NR_MAX)*(MODEM_STATS_NC_MAX+1)), - ("nr", c_int), - ("sync", c_int), - ("foff", c_float), - ("rx_timing", c_float), - ("clock_offset", c_float), - ("sync_metric", c_float), - # float rx_eye[MODEM_STATS_ET_MAX][MODEM_STATS_EYE_IND_MAX]; - ("rx_eye", (c_float * MODEM_STATS_ET_MAX)*MODEM_STATS_EYE_IND_MAX), - ("neyetr", c_int), - ("neyesamp", c_int), - ("f_est", c_float*MODEM_STATS_MAX_F_EST), - ("fft_buf", c_float * 2*MODEM_STATS_NSPEC), - ("fft_cfg", POINTER(c_ubyte)) - ] - - -class Mode(Enum): - """ - Modes (and aliases for modes) for the HorusLib modem - """ - BINARY = 0 - BINARY_V1 = 0 - RTTY_7N2 = 90 - RTTY = 90 - BINARY_V2_256BIT = 1 - BINARY_V2_128BIT = 2 - - -class Frame(): - """ - Frame class used for demodulation attempts. - - Attributes - ---------- - - data : bytes - Demodulated data output. Empty if demodulation didn't succeed - sync : bool - Modem sync status - snr : float - Estimated SNR - crc_pass : bool - CRC check status - extended_stats - Extended modem stats. These are provided as c_types so will need to be cast prior to use. See MODEM_STATS for structure details - """ - - def __init__(self, data: bytes, sync: bool, crc_pass: bool, snr: float, extended_stats: MODEM_STATS): - self.data = data - self.sync = sync - self.snr = snr - self.crc_pass = crc_pass - self.extended_stats = extended_stats - - -class HorusLib(): - """ - HorusLib provides a binding to horuslib to demoulate frames. - - Example usage: - - from horuslib import HorusLib, Mode - with HorusLib(, mode=Mode.BINARY, verbose=False) as horus: - with open("test.wav", "rb") as f: - while True: - data = f.read(horus.nin*2) - if horus.nin != 0 and data == b'': #detect end of file - break - output = horus.demodulate(data) - if output.crc_pass and output.data: - print(f'{output.data.hex()} SNR: {output.snr}') - for x in range(horus.mfsk): - print(f'F{str(x)}: {float(output.extended_stats.f_est[x])}') - - """ - - def __init__( - self, - libpath=f"", - mode=Mode.BINARY, - rate=-1, - tone_spacing=-1, - stereo_iq=False, - verbose=False, - callback=None, - sample_rate=48000 - ): - """ - Parameters - ---------- - libpath : str - Path to libhorus - mode : Mode - horuslib.Mode.BINARY, horuslib.Mode.BINARY_V2_256BIT, horuslib.Mode.BINARY_V2_128BIT, horuslib.Mode.RTTY, RTTY_7N2 = 99 - rate : int - Changes the modem rate for supported modems. -1 for default - tone_spacing : int - Spacing between tones (hz) -1 for default - stereo_iq : bool - use stereo (IQ) input (quadrature) - verbose : bool - Enabled horus_set_verbose - callback : function - When set you can use add_samples to add any number of audio frames and callback will be called when a demodulated frame is avaliable. - sample_rate : int - The input sample rate of the audio input - """ - - if sys.platform == "darwin": - libpath = os.path.join(libpath, "libhorus.dylib") - elif sys.platform == "win32": - libpath = os.path.join(libpath, "libhorus.dll") - else: - libpath = os.path.join(libpath, "libhorus.so") - - # future improvement would be to try a few places / names - self.c_lib = ctypes.cdll.LoadLibrary(libpath) - - # horus_open_advanced - self.c_lib.horus_open_advanced.restype = POINTER(c_ubyte) - - # horus_nin - self.c_lib.horus_nin.restype = c_uint32 - - # horus_get_Fs - self.c_lib.horus_get_Fs.restype = c_int - - # horus_set_freq_est_limits - (struct horus *hstates, float fsk_lower, float fsk_upper) - self.c_lib.horus_set_freq_est_limits.argtype = [ - POINTER(c_ubyte), - c_float, - c_float, - ] - - # horus_get_max_demod_in - self.c_lib.horus_get_max_demod_in.restype = c_int - - # horus_get_max_ascii_out_len - self.c_lib.horus_get_max_ascii_out_len.restype = c_int - - # horus_crc_ok - self.c_lib.horus_crc_ok.restype = c_int - - # horus_get_modem_extended_stats - (struct horus *hstates, struct MODEM_STATS *stats) - self.c_lib.horus_get_modem_extended_stats.argtype = [ - POINTER(MODEM_STATS), - POINTER(c_ubyte), - ] - - # horus_get_mFSK - self.c_lib.horus_get_mFSK.restype = c_int - - # horus_rx - self.c_lib.horus_rx.restype = c_int - - if type(mode) != type(Mode(0)): - raise ValueError("Must be of type horuslib.Mode") - else: - self.mode = mode - - self.stereo_iq = stereo_iq - - self.callback = callback - - self.input_buffer = bytearray(b"") - - # intial nin - self.nin = 0 - - # try to open the modem and set the verbosity - self.hstates = self.c_lib.horus_open_advanced( - self.mode.value, rate, tone_spacing - ) - self.c_lib.horus_set_verbose(self.hstates, int(verbose)) - - # check that the modem was actually opened and we don't just have a null pointer - if bool(self.hstates): - logging.debug("Opened Horus API") - else: - logging.error("Couldn't open Horus API for some reason") - raise EnvironmentError("Couldn't open Horus API") - - # build some class types to fit the data for demodulation using ctypes - max_demod_in = int(self.c_lib.horus_get_max_demod_in(self.hstates)) - max_ascii_out = int(self.c_lib.horus_get_max_ascii_out_len(self.hstates)) - self.DemodIn = c_short * (max_demod_in * (1 + int(self.stereo_iq))) - self.DataOut = c_char * max_ascii_out - self.c_lib.horus_rx.argtype = [ - POINTER(c_ubyte), - c_char * max_ascii_out, - c_short * max_demod_in, - c_int, - ] - - self.mfsk = int(self.c_lib.horus_get_mFSK(self.hstates)) - - self.resampler_state = None - self.audio_sample_rate = sample_rate - self.modem_sample_rate = 48000 - - # in case someone wanted to use `with` style. I'm not sure if closing the modem does a lot. - def __enter__(self): - return self - - def __exit__(self, *a): - self.close() - - def close(self) -> None: - """ - Closes Horus modem. - """ - self.c_lib.horus_close(self.hstates) - logging.debug("Shutdown horus modem") - - def _update_nin(self) -> None: - """ - Updates nin. Called every time RF is demodulated and doesn't need to be run manually - """ - new_nin = int(self.c_lib.horus_nin(self.hstates)) - if self.nin != new_nin: - logging.debug(f"Updated nin {new_nin}") - self.nin = new_nin - - def demodulate(self, demod_in: bytes) -> Frame: - """ - Demodulates audio in, into bytes output. - - Parameters - ---------- - demod_in : bytes - 16bit, signed for audio in. You'll need .nin frames in to work correctly. - """ - # resample to 48khz - (demod_in, self.resampler_state) = audioop.ratecv(demod_in, 2, 1+int(self.stereo_iq), self.audio_sample_rate, self.modem_sample_rate, self.resampler_state) - - # from_buffer_copy requires exact size so we pad it out. - buffer = bytearray( - len(self.DemodIn()) * sizeof(c_short) - ) # create empty byte array - buffer[: len(demod_in)] = demod_in # copy across what we have - - modulation = self.DemodIn # get an empty modulation array - modulation = modulation.from_buffer_copy( - buffer - ) # copy buffer across and get a pointer to it. - - data_out = self.DataOut() # initilize a pointer to where bytes will be outputed - - self.c_lib.horus_rx(self.hstates, data_out, modulation, int(self.stereo_iq)) - - stats = MODEM_STATS() - self.c_lib.horus_get_modem_extended_stats(self.hstates, byref(stats)) - - crc = bool(self.c_lib.horus_crc_ok(self.hstates)) - - data_out = bytes(data_out) - self._update_nin() - - # strip the null terminator out - data_out = data_out[:-1] - - if data_out == bytes(len(data_out)): - data_out = ( - b"" # check if bytes is just null and return an empty bytes instead - ) - elif self.mode != Mode.RTTY_7N2: - try: - data_out = bytes.fromhex(data_out.decode("ascii")) - except ValueError: - logging.debug(data_out) - logging.error("Couldn't decode the hex from the modem") - return bytes() - else: - # Ascii - data_out = data_out.decode("ascii") - # Strip of all null characters. - data_out = data_out.rstrip('\x00') - - frame = Frame( - data=data_out, - snr=float(stats.snr_est), - sync=bool(stats.sync), - crc_pass=crc, - extended_stats=stats, - ) - return frame - - def add_samples(self, samples: bytes): - """ Add samples to a input buffer, to pass on to demodulate when we have nin samples """ - - # Add samples to input buffer - self.input_buffer.extend(samples) - - _processing = True - _frame = None - while _processing: - # Process data until we have less than _nin samples. - _nin = int(self.nin*(self.audio_sample_rate/self.modem_sample_rate)) - if len(self.input_buffer) > (_nin * 2): - # Demodulate - _frame = self.demodulate(self.input_buffer[:(_nin*2)]) - - # Advance sample buffer. - self.input_buffer = self.input_buffer[(_nin*2):] - - # If we have decoded a packet, send it on to the callback - if len(_frame.data) > 0: - if self.callback: - self.callback(_frame) - else: - _processing = False - - return _frame - - -if __name__ == "__main__": - import sys - if len(sys.argv) != 2: - raise ArgumentError("Usage python3 -m horuslib filename") - filename = sys.argv[1] - - def frame_callback(frame): - print(f"Callback: {frame.data.hex()} SNR: {frame.snr}") - - # Setup Logging - logging.basicConfig( - format="%(asctime)s %(levelname)s: %(message)s", level=logging.DEBUG - ) - - with HorusLib(libpath=".", mode=Mode.BINARY, verbose=False, callback=frame_callback, sample_rate=8000) as horus: - with open(filename, "rb") as f: - while True: - # Fixed read size - 2000 samples - data = f.read(2000 * 2) - if horus.nin != 0 and data == b"": # detect end of file - break - output = horus.add_samples(data) - if output: - print(f"Sync: {output.sync} SNR: {output.snr}")