PyFT8 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {pyft8-2.0.0 → pyft8-2.1.0}/PKG-INFO +25 -4
  2. pyft8-2.1.0/PyFT8/gui.py +108 -0
  3. pyft8-2.1.0/PyFT8/pyft8.py +248 -0
  4. {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8/receiver.py +83 -83
  5. pyft8-2.0.0/PyFT8/IC-7100.py → pyft8-2.1.0/PyFT8/rigctrl.py +36 -26
  6. pyft8-2.1.0/PyFT8/time_utils.py +39 -0
  7. {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8/transmitter.py +26 -41
  8. {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8.egg-info/PKG-INFO +25 -4
  9. {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8.egg-info/SOURCES.txt +3 -3
  10. {pyft8-2.0.0 → pyft8-2.1.0}/README.md +24 -3
  11. {pyft8-2.0.0 → pyft8-2.1.0}/pyproject.toml +1 -1
  12. pyft8-2.1.0/tests/spare.py +18 -0
  13. {pyft8-2.0.0 → pyft8-2.1.0}/tests/test_batch_and_live.py +15 -11
  14. pyft8-2.0.0/PyFT8/gui.py +0 -75
  15. pyft8-2.0.0/PyFT8/pyft8.py +0 -79
  16. pyft8-2.0.0/PyFT8/time_utils.py +0 -37
  17. pyft8-2.0.0/tests/spare.py +0 -9
  18. {pyft8-2.0.0 → pyft8-2.1.0}/LICENSE +0 -0
  19. {pyft8-2.0.0 → pyft8-2.1.0}/MANIFEST.in +0 -0
  20. {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8/__init__.py +0 -0
  21. {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8.egg-info/dependency_links.txt +0 -0
  22. {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8.egg-info/entry_points.txt +0 -0
  23. {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8.egg-info/requires.txt +0 -0
  24. {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8.egg-info/top_level.txt +0 -0
  25. {pyft8-2.0.0 → pyft8-2.1.0}/setup.cfg +0 -0
  26. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_01.wav +0 -0
  27. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_02.wav +0 -0
  28. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_03.wav +0 -0
  29. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_04.wav +0 -0
  30. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_05.wav +0 -0
  31. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_06.wav +0 -0
  32. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_07.wav +0 -0
  33. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_08.wav +0 -0
  34. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_09.wav +0 -0
  35. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_10.wav +0 -0
  36. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_11.wav +0 -0
  37. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_12.wav +0 -0
  38. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_13.wav +0 -0
  39. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_14.wav +0 -0
  40. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_15.wav +0 -0
  41. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_16.wav +0 -0
  42. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_17.wav +0 -0
  43. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_18.wav +0 -0
  44. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_19.wav +0 -0
  45. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_20.wav +0 -0
  46. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_21.wav +0 -0
  47. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_22.wav +0 -0
  48. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_23.wav +0 -0
  49. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_24.wav +0 -0
  50. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_25.wav +0 -0
  51. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_26.wav +0 -0
  52. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_27.wav +0 -0
  53. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_28.wav +0 -0
  54. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_29.wav +0 -0
  55. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_30.wav +0 -0
  56. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_31.wav +0 -0
  57. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_32.wav +0 -0
  58. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_33.wav +0 -0
  59. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_34.wav +0 -0
  60. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_35.wav +0 -0
  61. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_36.wav +0 -0
  62. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_37.wav +0 -0
  63. {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_38.wav +0 -0
  64. {pyft8-2.0.0/tests/old → pyft8-2.1.0/tests/dev}/test_generate_wav.py +0 -0
  65. {pyft8-2.0.0/tests/old → pyft8-2.1.0/tests/dev}/test_loopback_performance.py +0 -0
  66. {pyft8-2.0.0 → pyft8-2.1.0}/tests/plot_baseline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyFT8
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: FT8 Decoding and Encoding in Python with test/loopback code
5
5
  Author-email: G1OJS <g1ojs@yahoo.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -22,7 +22,18 @@ Dynamic: license-file
22
22
 
23
23
  This repository contains the source code for PyFT8, an all-Python open source FT8 transceiver that you can run as a basic GUI or from the command line to receive and transmit. Decoding performance (number of decodes) is about 70% of that achieved by WSJT-x in NORM mode, but (tbc) slightly above ft8_lib. At the time of writing (3-3-26) this new version 2.0.0 establishes the prior cli functionality plus an interesting GUI, on which I intend to build a transceiver function shortly.
24
24
 
25
- <img width="714" height="968" alt="image" src="https://github.com/user-attachments/assets/be165135-d18d-42d9-848c-c455b9d99275" />
25
+ PyFT8 is somewhat experimental, with a focus on demonstrating FT8 written in Python, but can be used as a standalone replacement for WSJT-x and other software. However, please see [Rig control](https://github.com/G1OJS/PyFT8/blob/main/README.md#rig-control) below.
26
+
27
+ ## Features
28
+ - Doesn't try to do everything, so launches quickly (~2 seconds on my old Dell Optiplex 790)
29
+ - use with or without gui (receive and send messages via command line commands)
30
+ - GUI provides simultaneous views of odd and even cycles
31
+ - messages overlaid on waterfall signals that produce them
32
+ - automatically chooses clearest Tx frequency
33
+ - modern programming language throughout
34
+ - finds sound cards by keywords so follows them if windows moves them ...
35
+
36
+ https://github.com/user-attachments/assets/1a2e6b24-a6cc-4360-af50-6f810b99da33
26
37
 
27
38
  ## Motivation
28
39
  This started out as me thinking "How hard can it be, really?" after some frustration with Windows moving sound devices around and wanting to get a minimal decoder running that I can fully control.
@@ -36,7 +47,7 @@ If you want to install this software without getting into the code, you can inst
36
47
  pip install PyFT8
37
48
  ```
38
49
 
39
- Once installed, you can use the following commands to run it.
50
+ Once installed, you can use the following commands to run it. Otherwise, please download or browse the code, or fork the repo and play with it! If you do fork it, please check back here as I'm constantly (as of Jan 2026) rewriting and improving.
40
51
 
41
52
  |Usage | Command example| Notes |
42
53
  |----------------------|----------------------|----------------------|
@@ -45,8 +56,12 @@ Once installed, you can use the following commands to run it.
45
56
  | Command line Rx without a GUI | pyft8 -i "Keyword1, Keyword2" -n| |
46
57
  | Command line transmit | pyft8 -o "Keyword1, Keyword2" -m "CQ G1OJS IO90"| Tx on next cycle. You supply the PTT control method.|
47
58
  | Command line create a wav file | pyft8 -w "Mywav.wav" -m "CQ G1OJS IO90"| -w "Mywav.wav" can be omitted |
59
+ | Launch configured GUI|pyft8 -i "Keyword1, Keyword2" -o "Keyword1, Keyword2" -c {config folder}| Config folder stores PyFT8.ini (your callsign, grid, buttons) and PyFT8.adi log file. Run this once to create default PyFT8.ini file.|
60
+
61
+ ### Rig control
62
+ PyFT8 doesn't currently support CAT control for rigs in general. However, I've included the Python code that I use with my Icom IC-7100 in the file 'rigctrl.py'. You can modify this to control your own rig of course if you know Python.
48
63
 
49
- Otherwise, please download or browse the code, or fork the repo and play with it! If you do fork it, please check back here as I'm constantly (as of Jan 2026) rewriting and improving.
64
+ Alternatively, you can run PyFT8 without rig control; if there is no rig found, PyFT8 defaults to running without a rig connected. In this case, you need to provide your own PTT method and note that the band buttons will only set the information used for logging QSOs to the PyFT8.adi file. Or you can use PyFT8 as Rx-only.
50
65
 
51
66
  ## Performance Compared with FT8_lib and WSJT-x
52
67
 
@@ -70,6 +85,12 @@ and distributed under the GNU General Public License v3 (GPL-3.0), hence the use
70
85
  Also thanks to [Robert Morris](https://github.com/rtmrtmrtmrtm) for [basicft8(*1)](https://github.com/rtmrtmrtmrtm/basicft8) - the first code I properly read when I was wondering whether to start this journey. (*1 note: applies to FT8 pre V2)
71
86
 
72
87
  ## Useful resources:
88
+ **The QEX paper**
89
+ - [The FT4 and FT8 Communication Protocols](https://wsjt.sourceforge.io/FT4_FT8_QEX.pdf)
90
+ - [Ref 14 from the above paper at the Internet Archive](https://web.archive.org/web/20250409140104/https://www.arrl.org/files/file/QEX%20Binaries/2020/ft4_ft8_protocols.tgz)
91
+
92
+ Note - section 9 of the paper states that the above 2 resources are in the public domain, with some restrictions. All other resources including the source code are protected by copyright but licensed under Version 3 of the GNU General Public License (GPLv3).
93
+
73
94
  **WSJTx - focussed:**
74
95
  - [WSJT-X on Sourceforge](https://sourceforge.net/p/wsjt)
75
96
  - [W4KEK WSJT-x git mirror](https://www.repo.radio/w4kek/WSJT-X) (searchable)
@@ -0,0 +1,108 @@
1
+ import numpy as np
2
+ import matplotlib.pyplot as plt
3
+ import time, queue
4
+ from matplotlib import rcParams
5
+ from matplotlib.animation import FuncAnimation
6
+ from matplotlib.widgets import Slider, Button
7
+
8
+ rcParams['toolbar'] = 'None'
9
+ # ================== WATERFALL ======================================================
10
+
11
+ class Msg_box:
12
+ def __init__(self, fig, ax, tbin, fbin, w, h, text, colors, attached_params, onclick, expire = 0):
13
+ from matplotlib.patches import Rectangle
14
+ self.onclick = onclick
15
+ self.origin = (tbin, fbin)
16
+ rect = Rectangle(self.origin, width=w, height=h, alpha=0.6, edgecolor='lime', lw=2)
17
+ self.patch = ax.add_patch(rect)
18
+ self.text_inst = ax.text(tbin, fbin+2, text, fontsize='small', fontweight='bold' )
19
+ self.cid = fig.canvas.mpl_connect('button_press_event', self._onclick)
20
+ self.set_properties(tbin, text, colors, attached_params, expire)
21
+
22
+ def set_properties(self, tbin, text, colors, attached_params, expire):
23
+ self.patch.set_x(tbin)
24
+ self.attached_params = attached_params
25
+ self.text_inst.set_x(tbin)
26
+ self.text_inst.set_text(text)
27
+ self.text_inst.set_color(colors[1])
28
+ self.expire = expire
29
+ self.patch.set_facecolor(colors[0])
30
+ self.patch.set_visible(True)
31
+ self.text_inst.set_visible(True)
32
+
33
+ def hide_if_expired(self):
34
+ if time.time() > self.expire > 0:
35
+ self.patch.set_visible(False)
36
+ self.text_inst.set_visible(False)
37
+
38
+ def _onclick(self, event):
39
+ b, _ = self.patch.contains(event)
40
+ if(b):
41
+ self.onclick(self.text_inst.get_text(), self.attached_params)
42
+
43
+ class Gui:
44
+ def __init__(self, dBgrid, hps, bpt, config, on_msg_click, on_control_click):
45
+ self.mStation = {'c':config['station']['call'], 'g':config['station']['grid']}
46
+ self.on_msg_click = on_msg_click
47
+ self.on_control_click = on_control_click
48
+ self.dBgrid = dBgrid
49
+ self.hps, self.bpt = hps, bpt
50
+ self.msg_boxes = {}
51
+ self.decode_queue = queue.Queue()
52
+ self.make_layout(config)
53
+
54
+ def make_layout(self, config):
55
+ self.fig, self.ax_wf = plt.subplots(figsize=(10,10), frameon = False)
56
+ self.fig.canvas.manager.set_window_title('PyFT8 by G1OJS')
57
+ #self.fig.suptitle("PyFT8 by G1OJS")
58
+ self.plt = plt
59
+ plt.tight_layout()
60
+ self.image = self.ax_wf.imshow(self.dBgrid.T,vmax=120,vmin=90,origin='lower',interpolation='none')
61
+ wf_ylim = self.ax_wf.get_ylim()
62
+ self.ax_wf.set_axis_off()
63
+
64
+ self.buttons = []
65
+ styles = {'ctrl':{'fc':'grey','c':'black'}, 'band':{'fc':'green','c':'white'}}
66
+ control_buttons = [{'label':'CQ','style':'ctrl','data':None}, {'label':'Repeat last','style':'ctrl','data':None},
67
+ {'label':'Tx off','style':'ctrl','data':None}]
68
+ #{'label':'Averaging','style':'ctrl','data':None}]
69
+ for band, freq in config['bands'].items():
70
+ control_buttons.append({'label':band,'style':'band','data':freq})
71
+
72
+ btn_axs = []
73
+ for i, btn in enumerate(control_buttons):
74
+ btn_axs.append(plt.axes([0.05, 0.9 - 0.022 * i, 0.1, 0.02]))
75
+ style = styles[btn['style']]
76
+ btn_widg = Button(btn_axs[-1], btn['label'], color=style['fc'], hovercolor='skyblue')
77
+ btn_widg.data = btn['data']
78
+ btn_widg.on_clicked(lambda event, btn_widg=btn_widg: self.on_control_click(btn_widg))
79
+ self.buttons.append(btn_widg)
80
+ self.ani = FuncAnimation(self.fig, self._animate, interval = 40, frames=(100000), blit=True)
81
+
82
+ def post_decode(self, decode):
83
+ self.decode_queue.put(decode)
84
+
85
+ def _show_decode(self, queued_decode):
86
+ h0_idx, f0_idx, msg, attached_params = queued_decode
87
+ colors = ['blue', 'white']
88
+ if msg.startswith("CQ"): colors = ['green', 'white']
89
+ if self.mStation['c'] in msg: colors = ['yellow', 'black']
90
+ if msg.startswith(self.mStation['c']): colors = ['red', 'white']
91
+ if not f0_idx in self.msg_boxes:
92
+ btn = Msg_box(self.fig, self.ax_wf, h0_idx, f0_idx, 79*self.hps, 8*self.bpt, msg, colors, attached_params, onclick = self.on_msg_click)
93
+ self.msg_boxes[f0_idx] = btn
94
+ self.msg_boxes[f0_idx].set_properties(h0_idx, msg, colors, attached_params, expire = time.time() + 28)
95
+
96
+ def _tidy_msg_boxes(self):
97
+ for fb in self.msg_boxes:
98
+ self.msg_boxes[fb].hide_if_expired()
99
+
100
+ def _animate(self, frame):
101
+ self.image.set_data(self.dBgrid.T)
102
+ while not self.decode_queue.empty():
103
+ self._show_decode(self.decode_queue.get())
104
+ if (frame % 10 == 0):
105
+ self._tidy_msg_boxes()
106
+ return [self.image, *self.ax_wf.patches, *self.ax_wf.texts]
107
+
108
+
@@ -0,0 +1,248 @@
1
+ import argparse
2
+ import time
3
+ import os
4
+ import threading
5
+ import numpy as np
6
+ from PyFT8.receiver import Receiver, AudioIn
7
+ from PyFT8.gui import Gui
8
+ from PyFT8.transmitter import AudioOut
9
+ from PyFT8.time_utils import global_time_utils
10
+
11
+ MAX_TX_START_SECONDS = 2.5
12
+ T_CYC = 15
13
+
14
+ def load_rigctrl():
15
+ try:
16
+ from PyFT8.rigctrl import Rig
17
+ print("Loaded Rig control")
18
+ return Rig()
19
+ except ImportError:
20
+ print("No Rig control found")
21
+ return None
22
+
23
+ def get_config():
24
+ import configparser
25
+ global config
26
+ config = configparser.ConfigParser()
27
+ if not os.path.exists(ini_file):
28
+ config['station'] = {'call':'station_callsign', 'grid':'station_grid'}
29
+ config['bands'] = {'20m':14.074}
30
+ with open(ini_file, 'w') as f:
31
+ config.write(f)
32
+ print(f"Wrote default config to {ini_file}")
33
+ print(f"Reading config from {ini_file}")
34
+ config.read(ini_file)
35
+
36
+ class FT8_QSO:
37
+ def __init__(self):
38
+ self.mStation = {'c':config['station']['call'], 'g':config['station']['grid']}
39
+ self.band_info = {'b':'20m', 'f':14.074}
40
+ self.start()
41
+
42
+ def start(self):
43
+ self.last_tx = {'msg':None,'cycle':None}
44
+ self.oStation = {'c':None, 'g':None}
45
+ self.times = {'time_on':None, 'time_off':None}
46
+ self.rpts = {'sent': None, 'rcvd': None}
47
+
48
+ def log_to_adif(self):
49
+ log_dict = {'call':self.oStation['c'], 'gridsquare':self.oStation['g'], 'mode':'FT8',
50
+ 'operator':self.mStation['c'], 'station_callsign':self.mStation['c'], 'my_gridsquare':self.mStation['g'],
51
+ 'rst_sent':self.rpts['sent'], 'rst_rcvd':self.rpts['rcvd'],
52
+ 'qso_date':time.strftime("%Y%m%d", self.times['time_on']), 'qso_date_off':time.strftime("%Y%m%d", self.times['time_off']),
53
+ 'time_on':time.strftime("%H%M%S", self.times['time_on']), 'time_off':time.strftime("%H%M%S", self.times['time_on']),
54
+ 'band':self.band_info['b'], 'freq':self.band_info['f']}
55
+ if(not os.path.exists(logfile)):
56
+ with open(adif_log_file, 'w') as f:
57
+ f.write("header <eoh>")
58
+ with open(adif_log_file,'a') as f:
59
+ f.write(f"\n")
60
+ for k, v in log_dict.items():
61
+ v = str(v)
62
+ f.write(f"<{k}:{len(v)}>{v} ")
63
+ f.write(f"<eor>\n")
64
+
65
+ def isReport(grid_rpt): return "+" in grid_rpt or "-" in grid_rpt
66
+ def isRReport(grid_rpt): return isReport(grid_rpt) and 'R' in grid_rpt
67
+ def isRRR(grid_rpt): return 'RRR' in grid_rpt
68
+ def isRR73(grid_rpt): return 'RR73' in grid_rpt
69
+ def is73(grid_rpt): return '73' in grid_rpt and not isRR73(grid_rpt)
70
+ def isGrid(grid_rpt): return not isReport(grid_rpt) and not is73(grid_rpt) and not isRR73(grid_rpt) and not isRRR(grid_rpt)
71
+
72
+ def progress_qso(clicked_msg, msg_params):
73
+ global qso
74
+ their_snr, their_cycle_start_time = msg_params
75
+ if time.time() - their_cycle_start_time > (15 + MAX_TX_START_SECONDS):
76
+ print("Try next cycle")
77
+ return
78
+
79
+ call_a, call_b, grid_rpt, _ = clicked_msg.split()
80
+ my_station = qso.mStation
81
+ reply = ""
82
+
83
+ if call_a == "CQ":
84
+ qso.start()
85
+ qso.times['time_on'] = time.gmtime()
86
+ qso.oStation = {'c': call_b, 'g': grid_rpt}
87
+ reply = f"{qso.oStation['c']} {my_station['c']} {my_station['g']}"
88
+ transmit_threaded(reply)
89
+ return
90
+
91
+ if call_a == my_station['c']:
92
+ if qso.times['time_on'] is None:
93
+ qso.times['time_on'] = time.gmtime()
94
+ qso.oStation['c'] = call_b
95
+ if isGrid(grid_rpt):
96
+ qso.oStation = {'c': call_b, 'g': grid_rpt}
97
+ qso.rpts['sent'] = f"{their_snr:+03d}"
98
+ reply = f"{qso.oStation['c']} {my_station['c']} {their_snr:+03d}"
99
+ if isReport(grid_rpt):
100
+ reply = f"{qso.oStation['c']} {my_station['c']} R{their_snr:+03d}"
101
+ qso.rpts['rcvd'] = grid_rpt[-3:]
102
+ if isRReport(grid_rpt) or isRRR(grid_rpt):
103
+ reply = f"{qso.oStation['c']} {my_station['c']} RR73"
104
+ if isRR73(grid_rpt):
105
+ reply = f"{qso.oStation['c']} {my_station['c']} 73"
106
+ transmit_threaded(reply)
107
+
108
+ if is73(grid_rpt) or " 73" in reply or isRR73(grid_rpt):
109
+ qso.times['time_off'] = time.gmtime()
110
+ qso.log_to_adif()
111
+
112
+ def make_wav(msg, wave_output_file): # move to transmitter.py?
113
+ symbols = audio_out.create_ft8_symbols(msg)
114
+ audio_data = audio_out.create_ft8_wave(symbols)
115
+ audio_out.write_to_wave_file(audio_data, wave_output_file)
116
+ print(f"Created wave file {wave_output_file}")
117
+
118
+ def transmit_threaded(msg, cycle = None): # move to transmitter.py?
119
+ threading.Thread(target = transmit, args = (msg, cycle,), daemon = True).start()
120
+
121
+ def transmit(msg, cycle = None): # move to transmitter.py?
122
+ if output_device_idx is None:
123
+ print("No output device")
124
+ return
125
+ if msg is None:
126
+ return
127
+ ct = global_time_utils.cycle_time()
128
+ print(f"Cycle requested: {cycle}")
129
+ if cycle is None: # transmit asap
130
+ cycle = global_time_utils.curr_cycle_from_time()
131
+ print(f"Cycle clicked: {cycle}")
132
+ if ct > MAX_TX_START_SECONDS:
133
+ cycle = 1-cycle # transmit next cycle
134
+ print(f"Transmit {msg} cycle = {cycle}")
135
+ symbols = audio_out.create_ft8_symbols(msg)
136
+ audio_data = audio_out.create_ft8_wave(symbols, f_base = clear_frequencies[cycle])
137
+ if ct > MAX_TX_START_SECONDS:
138
+ delay = 15.25 - ct
139
+ time.sleep(delay)
140
+ if cycle != global_time_utils.curr_cycle_from_time():
141
+ time.sleep(T_CYC)
142
+ if qso:
143
+ qso.last_tx = {'msg':msg,'cycle':global_time_utils.curr_cycle_from_time()}
144
+ print(f"Transmitting {qso.last_tx['msg']} cycle = {qso.last_tx['cycle']}")
145
+ rig.PyFT8_ptt_on()
146
+ audio_out.play_data_to_soundcard(audio_data, output_device_idx)
147
+ rig.PyFT8_ptt_off()
148
+ return True
149
+
150
+ def wait_for_keyboard():
151
+ import time
152
+ try:
153
+ while True:
154
+ time.sleep(1)
155
+ except KeyboardInterrupt:
156
+ pass
157
+
158
+ #============= Callbacks for GUI ==========================================================
159
+ def on_decode(c):
160
+ if gui:
161
+ message_cycle_started = global_time_utils.cyclestart_time(time.time())
162
+ txt = f"{c.msg} ({c.snr:+03d})"
163
+ gui.post_decode((c.h0_idx, c.f0_idx, txt, (c.snr, message_cycle_started)))
164
+ print(f"{c.cyclestart_str} {c.snr} {c.dt:4.1f} {c.fHz} ~ {c.msg}")
165
+
166
+ def on_busy_profile(busy_profile, cycle):
167
+ if output_device_idx is None:
168
+ return
169
+ fmax = 950 if qso.band_info['b']=='60m' else 2000
170
+ f0_idx, fn_idx = int(500/audio_in.df), int(fmax/audio_in.df)
171
+ idx = np.argmin(busy_profile[f0_idx:fn_idx])
172
+ clear_frequencies[cycle] = (f0_idx + idx) * audio_in.df
173
+ print(f"Set Tx freq to {clear_frequencies[cycle]:6.1f} for cycle {cycle}")
174
+
175
+ def on_control_click(btn_widg):
176
+ btn_text, btn_data = btn_widg.label.get_text(), btn_widg.data
177
+ print(btn_text, btn_data)
178
+ if btn_text == "CQ":
179
+ mc, mg = config['station']['call'], config['station']['grid']
180
+ transmit_threaded(f"CQ {mc} {mg}")
181
+ if btn_text == "Repeat last":
182
+ transmit_threaded(qso.last_tx['msg'], cycle = qso.last_tx['cycle'])
183
+ if btn_text == "Tx off":
184
+ rig.PyFT8_ptt_off()
185
+ if('m' in btn_text):
186
+ qso.band_info = {'b':btn_text, 'f':btn_data}
187
+ rig.PyFT8_set_freq_Hz(int(1000000*float(qso.band_info['f'])))
188
+
189
+ def on_msg_click(clicked_msg, msg_params):
190
+ progress_qso(clicked_msg, msg_params)
191
+
192
+ #=============== CLI ========================================================================
193
+ def cli():
194
+ global audio_in, audio_out, output_device_idx, rig, gui, qso, ini_file, adif_log_file, clear_frequencies
195
+ import time
196
+ parser = argparse.ArgumentParser(prog='PyFT8rx', description = 'Command Line FT8 decoder')
197
+ parser.add_argument('-c', '--config_folder', help = 'Location of config folder e.g. C:/Users/drala/Documents/Projects/GitHub/G1OJS/PyFT8_cfg', default = './')
198
+ parser.add_argument('-i', '--inputcard_keywords', help = 'Comma-separated keywords to identify the input sound device')
199
+ parser.add_argument('-v','--verbose', action='store_true', help = 'Verbose: include debugging output')
200
+ parser.add_argument('-o','--outputcard_keywords', help = 'Comma-separated keywords to identify the output sound device')
201
+ parser.add_argument('-n','--no_gui', action='store_true', help = "Don't create a gui")
202
+ parser.add_argument('-m','--transmit_message', nargs='?', help = 'Transmit a message')
203
+ parser.add_argument('-w','--wave_output_file', nargs='?', help = 'Wave output file name', default = 'PyFT8.wav')
204
+ args = parser.parse_args()
205
+
206
+ output_device_idx = None
207
+ ini_file = f"{args.config_folder}/PyFT8.ini".strip()
208
+ get_config()
209
+ qso = FT8_QSO()
210
+ rig = load_rigctrl()
211
+
212
+ if args.transmit_message or args.outputcard_keywords:
213
+ audio_out = AudioOut()
214
+ clear_frequencies = [760, 760]
215
+
216
+ if args.outputcard_keywords:
217
+ outputcard_keywords = args.outputcard_keywords.replace(' ','').split(',')
218
+ output_device_idx = audio_out.find_device(outputcard_keywords)
219
+ adif_log_file = f"{args.config_folder}/PyFT8.adi"
220
+
221
+ if args.transmit_message:
222
+ if not transmit(args.transmit_message):
223
+ make_wav(args.transmit_message, f"{args.config_folder}/{args.wave_output_file}")
224
+ else:
225
+ audio_in = AudioIn(3100)
226
+ input_device_idx = audio_in.find_device(args.inputcard_keywords.replace(' ','').split(','))
227
+ if not input_device_idx:
228
+ print("No input device")
229
+ else:
230
+ gui = None if args.no_gui else Gui(audio_in.dBgrid_main, 4, 2, config, on_msg_click, on_control_click)
231
+ rx = Receiver(audio_in, [200, 3100], on_decode, on_busy_profile)
232
+ audio_in.start_streamed_audio(input_device_idx)
233
+ if gui is not None:
234
+ gui.plt.show()
235
+ else:
236
+ wait_for_keyboard()
237
+
238
+
239
+ #================== TEST CODE ============================================================
240
+ print(__name__)
241
+ if __name__ == "__main__":
242
+ import mock
243
+ #with mock.patch('sys.argv', ['pyft8', '-i Mic, CODEC', '-o Speak, CODEC', '-cC:/Users/drala/Documents/Projects/GitHub/G1OJS/PyFT8_cfg']):
244
+ #with mock.patch('sys.argv', ['pyft8', '-i Mic, CODEC']):
245
+ #with mock.patch('sys.argv', ['pyft8', '-i Mic, CODEC', '-n']):
246
+ with mock.patch('sys.argv', ['pyft8', '-m', "CQ G1OJS IO90", '-cC:/Users/drala/Documents/Projects/GitHub/G1OJS/PyFT8_cfg']):
247
+ #with mock.patch('sys.argv', ['pyft8', '-m', "CQ G1OJS IO90", '-o', "Speak, CODEC"]):
248
+ cli()