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.
- {pyft8-2.0.0 → pyft8-2.1.0}/PKG-INFO +25 -4
- pyft8-2.1.0/PyFT8/gui.py +108 -0
- pyft8-2.1.0/PyFT8/pyft8.py +248 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8/receiver.py +83 -83
- pyft8-2.0.0/PyFT8/IC-7100.py → pyft8-2.1.0/PyFT8/rigctrl.py +36 -26
- pyft8-2.1.0/PyFT8/time_utils.py +39 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8/transmitter.py +26 -41
- {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8.egg-info/PKG-INFO +25 -4
- {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8.egg-info/SOURCES.txt +3 -3
- {pyft8-2.0.0 → pyft8-2.1.0}/README.md +24 -3
- {pyft8-2.0.0 → pyft8-2.1.0}/pyproject.toml +1 -1
- pyft8-2.1.0/tests/spare.py +18 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/test_batch_and_live.py +15 -11
- pyft8-2.0.0/PyFT8/gui.py +0 -75
- pyft8-2.0.0/PyFT8/pyft8.py +0 -79
- pyft8-2.0.0/PyFT8/time_utils.py +0 -37
- pyft8-2.0.0/tests/spare.py +0 -9
- {pyft8-2.0.0 → pyft8-2.1.0}/LICENSE +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/MANIFEST.in +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8/__init__.py +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8.egg-info/dependency_links.txt +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8.egg-info/entry_points.txt +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8.egg-info/requires.txt +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/PyFT8.egg-info/top_level.txt +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/setup.cfg +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_01.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_02.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_03.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_04.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_05.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_06.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_07.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_08.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_09.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_10.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_11.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_12.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_13.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_14.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_15.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_16.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_17.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_18.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_19.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_20.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_21.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_22.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_23.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_24.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_25.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_26.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_27.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_28.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_29.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_30.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_31.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_32.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_33.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_34.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_35.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_36.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_37.wav +0 -0
- {pyft8-2.0.0 → pyft8-2.1.0}/tests/data/ft8_lib_20m_busy/test_38.wav +0 -0
- {pyft8-2.0.0/tests/old → pyft8-2.1.0/tests/dev}/test_generate_wav.py +0 -0
- {pyft8-2.0.0/tests/old → pyft8-2.1.0/tests/dev}/test_loopback_performance.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
pyft8-2.1.0/PyFT8/gui.py
ADDED
|
@@ -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()
|