PyFT8 2.12.2__tar.gz → 3.0.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.12.2 → pyft8-3.0.0}/PKG-INFO +11 -27
- {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/databases.py +45 -37
- {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/pyft8.py +6 -11
- pyft8-3.0.0/PyFT8/rigctrl.py +43 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8.egg-info/PKG-INFO +11 -27
- {pyft8-2.12.2 → pyft8-3.0.0}/README.md +10 -26
- {pyft8-2.12.2 → pyft8-3.0.0}/pyproject.toml +1 -1
- pyft8-2.12.2/PyFT8/rigctrl.py +0 -102
- {pyft8-2.12.2 → pyft8-3.0.0}/LICENSE +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/MANIFEST.in +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/__init__.py +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/gui.py +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/pskreporter.py +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/receiver.py +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/time_utils.py +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/transmitter.py +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8.egg-info/SOURCES.txt +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8.egg-info/dependency_links.txt +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8.egg-info/entry_points.txt +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8.egg-info/requires.txt +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8.egg-info/top_level.txt +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/setup.cfg +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/tests/dev/CQ AAAA.py +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/tests/dev/osd.py +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/tests/dev/test_generate_wav.py +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/tests/dev/test_loopback_performance.py +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/tests/dev/view_worked_before.py +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/tests/plot_baseline.py +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/tests/spare.py +0 -0
- {pyft8-2.12.2 → pyft8-3.0.0}/tests/test_batch_and_live.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyFT8
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.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
|
|
@@ -35,26 +35,20 @@ If you're interested in how this works, maybe have a look at [MiniPyFT8](https:/
|
|
|
35
35
|
- Finds sound cards by keywords so follows them if windows moves them ...
|
|
36
36
|
- Logs QSOs to ADIF file and all spots to WSJTX-style ALL.txt file
|
|
37
37
|
- Uploads spots to pskreporter
|
|
38
|
-
-
|
|
39
|
-
- Or control rigs via Hamlib
|
|
38
|
+
- Control rigs via Hamlib
|
|
40
39
|
|
|
41
40
|
The Gui shows:
|
|
42
41
|
- Simultaneous views of odd and even cycles
|
|
43
42
|
- Messages overlaid on waterfall signals that produce them
|
|
44
43
|
- Worked-before info and fine grid locators / distance and bearing in the message boxes
|
|
45
|
-
- List of stations hearing your transmissions on the selected band
|
|
44
|
+
- List of stations hearing your transmissions on the selected band, alternating with list of stations you're hearing. On these lists, 'new' callsigns (first heard since program launch) are in white font.
|
|
46
45
|
- Marker on message if 2nd call is hearing you
|
|
47
46
|
- Band activity in your level 4 square live updated next to band select buttons
|
|
48
47
|
- Number of remote stations hearing your Tx, number of remote Txs that you're hearing, plus the same info for the 'best' station in your level 4 square
|
|
49
48
|
- Data used for the above is cached to disk so is not lost when restarting the program
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
```
|
|
53
|
-
[pskreporter]
|
|
54
|
-
upload = Y
|
|
55
|
-
```
|
|
50
|
+
<img width="997" height="1025" alt="screenshot" src="https://github.com/user-attachments/assets/88228f65-c39e-45ee-9f73-1d85138563f3" />
|
|
56
51
|
|
|
57
|
-

|
|
58
52
|
|
|
59
53
|
## Motivation
|
|
60
54
|
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.
|
|
@@ -68,7 +62,7 @@ If you want to install this software without getting into the code, you can inst
|
|
|
68
62
|
pip install PyFT8
|
|
69
63
|
```
|
|
70
64
|
|
|
71
|
-
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
|
|
65
|
+
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 April 2026) rewriting and improving.
|
|
72
66
|
|
|
73
67
|
The use cases below are the ones held in the test cases at the bottom of the main pyft8.py file. Where a config folder and .ini file is required, if you leave off the '-c {folder}' option, you'll be asked if you want to create a .ini file in the current directory.
|
|
74
68
|
|
|
@@ -82,30 +76,19 @@ The use cases below are the ones held in the test cases at the bottom of the mai
|
|
|
82
76
|
| Command line create a wav file | pyft8 -w "Mywav.wav" -m "CQ G1OJS IO90"| No config folder / .ini file required |
|
|
83
77
|
|
|
84
78
|
### Rig control
|
|
85
|
-
I
|
|
86
|
-
|
|
87
|
-
I've also included a basic Hamlib interface which launches rigctld and uses that to control the rig. To use this, make sure that the hamlib section of the ini file is populated; this will then take precedence over the direct CAT control section.
|
|
88
|
-
|
|
89
|
-
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.
|
|
79
|
+
I designed PyFT8 to be minimal as far as possible, and it needs very limited control of the rig in order to transmit. However, whilst previous versions used direct CAT control and Hamlib as a fallback, Version 3.0.0 moves to Hamlib as the only interface.
|
|
90
80
|
|
|
91
81
|
## Customising
|
|
92
|
-
Even if you don't want to edit the code, there's quite a bit that you can change in the .ini file. I haven't written a user guide yet, so here's my own .ini file for reference.
|
|
82
|
+
Even if you don't want to edit the code, there's quite a bit that you can change in the .ini file. I haven't written a user guide yet, so here's my own .ini file for reference. You'll note that my instance of rigctld is in C:/WSJT/wsjtx/bin/rigctld-wsjtx, but any rigctld should also work. PyFT8 launches this if it's not already running (to be precise, it launches it if it can't open a socket on localhost:4532).
|
|
93
83
|
|
|
94
84
|
```
|
|
95
85
|
[station]
|
|
96
86
|
call = G1OJS
|
|
97
|
-
grid =
|
|
87
|
+
grid = IO******
|
|
98
88
|
[gui]
|
|
99
89
|
loc = km_deg
|
|
100
90
|
wb = Y
|
|
101
|
-
[
|
|
102
|
-
port = COM4
|
|
103
|
-
baud_rate = 9600
|
|
104
|
-
set_freq_command = FEFE88E0.05.0000000000.FD
|
|
105
|
-
set_freq_value = 5|5|vfBcdLU|1|0
|
|
106
|
-
ptt_on_command = FEFE88E0.1C00.01.FD
|
|
107
|
-
ptt_off_command = FEFE88E0.1C00.00.FD
|
|
108
|
-
[_hamlib_rig]
|
|
91
|
+
[hamlib_rig]
|
|
109
92
|
rigctld = C:/WSJT/wsjtx/bin/rigctld-wsjtx
|
|
110
93
|
port = COM4
|
|
111
94
|
baud_rate = 9600
|
|
@@ -128,7 +111,8 @@ upload = Y
|
|
|
128
111
|
|
|
129
112
|
The image below shows the number of decodes from PyFT8, WSJT-x V2.7.0 running in NORM mode, and FT8_lib, using the same 10 minutes of busy 20m audio that is used to test ft8_lib.
|
|
130
113
|
|
|
131
|
-
|
|
114
|
+
<img width="640" height="480" alt="performance snapshot" src="https://github.com/user-attachments/assets/b62221ec-2d74-4fbc-9c61-34b2bbc0e963" />
|
|
115
|
+
|
|
132
116
|
|
|
133
117
|
## Limitations
|
|
134
118
|
PyFT8 doesn't decode / encode *all* message types. The table below shows which are handled.
|
|
@@ -2,6 +2,7 @@ from PyFT8.pskreporter import PSKR_MQTT_listener
|
|
|
2
2
|
import threading, time, os, pickle, json
|
|
3
3
|
|
|
4
4
|
call_hashes = {}
|
|
5
|
+
|
|
5
6
|
def add_call_hashes(call):
|
|
6
7
|
global call_hashes
|
|
7
8
|
chars = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/"
|
|
@@ -64,37 +65,39 @@ class DiskDict:
|
|
|
64
65
|
with self.lock:
|
|
65
66
|
if(os.path.exists(self.file)):
|
|
66
67
|
with open(f"{self.file}","rb") as f:
|
|
67
|
-
|
|
68
|
+
try:
|
|
69
|
+
self.data = json.load(f)
|
|
70
|
+
except:
|
|
71
|
+
pass
|
|
68
72
|
|
|
69
73
|
def save(self):
|
|
70
74
|
with self.lock:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
os.replace(tmp_file, self.file)
|
|
75
|
+
with open(self.file, "w") as f:
|
|
76
|
+
try:
|
|
77
|
+
json.dump(self.data, f)
|
|
78
|
+
except:
|
|
79
|
+
pass
|
|
77
80
|
|
|
78
81
|
class History:
|
|
79
82
|
def __init__(self, config_folder, my_call, home_square, pskr_refresh_mins, parse_all_file):
|
|
80
|
-
self.t0 = 1775324204
|
|
81
83
|
self.pskr_refresh_mins = pskr_refresh_mins
|
|
82
84
|
self.config_folder = config_folder
|
|
83
85
|
self.my_call = my_call
|
|
84
86
|
self.home_square = home_square
|
|
85
87
|
self.home_square_lev4 = home_square[:4]
|
|
88
|
+
self.freqs_to_bands = {}
|
|
86
89
|
self.dist_brg_cache = {}
|
|
87
90
|
self.hearing_me = DiskDict(f"{self.config_folder}/hearing_me.json", 3) # all-time record of hearing me
|
|
88
91
|
self.heard_by_me = DiskDict(f"{self.config_folder}/heard_by_me.json", 5) # all-time record of heard by me
|
|
89
92
|
self.hearing_me_new = {}
|
|
90
93
|
self.heard_by_me_new = {}
|
|
91
94
|
self.call_to_grid = DiskDict(f"{self.config_folder}/call_to_grid.json", 7) # all time cache call -> fine locator
|
|
92
|
-
self.
|
|
95
|
+
self.band_TxRx_homecall_recent_l4grid = DiskDict(f"{self.config_folder}/recent_l4grid.json", 9) # last 20 mins data -> per band tx/rx & current band detail
|
|
93
96
|
self.home_activity = {}
|
|
94
97
|
self.home_most_remotes = {}
|
|
95
98
|
self.lock = threading.Lock()
|
|
96
99
|
if parse_all_file:
|
|
97
|
-
self.
|
|
100
|
+
self.load_hearing_heard_from_all_file(f"{self.config_folder}/ALL.txt")
|
|
98
101
|
self.hearing_me.save()
|
|
99
102
|
self.heard_by_me.save()
|
|
100
103
|
print("All file parsed and saved to hearing_me / heard_by_me files")
|
|
@@ -102,29 +105,29 @@ class History:
|
|
|
102
105
|
mqtt = PSKR_MQTT_listener(self.home_square_lev4, self.add_mqtt_spot)
|
|
103
106
|
threading.Thread(target = self.count_activity, daemon = True).start()
|
|
104
107
|
|
|
105
|
-
def
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm'][idx]
|
|
112
|
-
|
|
113
|
-
def load_from_wb(self, log_cache):
|
|
108
|
+
def set_bands(self, bands):
|
|
109
|
+
for b in bands:
|
|
110
|
+
f = float(bands[b])
|
|
111
|
+
self.freqs_to_bands[round(f,1)] = b
|
|
112
|
+
|
|
113
|
+
def load_hearing_heard_from_adif(self, log_cache):
|
|
114
114
|
for key in log_cache:
|
|
115
115
|
key_parts = key.split('_')
|
|
116
116
|
if len(key_parts) > 1:
|
|
117
117
|
c, b, m = key_parts
|
|
118
118
|
if m == 'FT8':
|
|
119
|
+
#print(f"Parsing adif log: Add hearing & heard by {c} on {b}")
|
|
119
120
|
self.add_myspots_record(self.hearing_me.data, None, b, c, 0, 0)
|
|
120
121
|
self.add_myspots_record(self.heard_by_me.data, None, b, c, 0, 0)
|
|
122
|
+
else:
|
|
123
|
+
print(m)
|
|
121
124
|
|
|
122
|
-
def
|
|
125
|
+
def load_hearing_heard_from_all_file(self, all_file):
|
|
123
126
|
recs = self.parse_all_txt(all_file)
|
|
124
127
|
if not any(recs): return
|
|
125
128
|
for r in recs:
|
|
126
129
|
if r['md'] == 'FT8':
|
|
127
|
-
band = self.
|
|
130
|
+
band = self.freqs_to_bands.get(round(r['fMHz'], 1), None)
|
|
128
131
|
if band is not None:
|
|
129
132
|
TxRx = 'Tx' if (r['TxRx'] == 'Tx' or r['call_b'] == self.my_call) else 'Rx'
|
|
130
133
|
call = r['call_b'] if TxRx == 'Rx' else r['call_a']
|
|
@@ -175,8 +178,8 @@ class History:
|
|
|
175
178
|
self.call_to_grid.data[call] = grid
|
|
176
179
|
|
|
177
180
|
def add_homespots_record(self, key, t):
|
|
178
|
-
self.
|
|
179
|
-
self.
|
|
181
|
+
self.band_TxRx_homecall_recent_l4grid.data.setdefault(key, [])
|
|
182
|
+
self.band_TxRx_homecall_recent_l4grid.data[key].append(int(t))
|
|
180
183
|
|
|
181
184
|
def add_myspots_record(self, historic_data, new_alert_data, band, call, t, rp):
|
|
182
185
|
self._update_new_alert(band, call, historic_data, new_alert_data)
|
|
@@ -223,29 +226,29 @@ class History:
|
|
|
223
226
|
self.home_most_remotes[b] = [('Nobody',0), ('Nobody',0)]
|
|
224
227
|
|
|
225
228
|
# keep only the remote spots that happened in the self.pskr_refresh_mins window
|
|
226
|
-
for band_TxRx_homecall in self.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
self.
|
|
229
|
+
for band_TxRx_homecall in self.band_TxRx_homecall_recent_l4grid.data:
|
|
230
|
+
band_TxRx_homecall_recent_l4grid = self.band_TxRx_homecall_recent_l4grid.data[band_TxRx_homecall]
|
|
231
|
+
band_TxRx_homecall_recent_l4grid = [t for t in band_TxRx_homecall_recent_l4grid if (time.time() - t) < 60*self.pskr_refresh_mins]
|
|
232
|
+
self.band_TxRx_homecall_recent_l4grid.data[band_TxRx_homecall] = band_TxRx_homecall_recent_l4grid
|
|
230
233
|
|
|
231
234
|
# count number of local Tx and Rx, and identify the local Tx and Rx with most remote spots
|
|
232
|
-
for band_TxRx_homecall in self.
|
|
233
|
-
|
|
234
|
-
if len(
|
|
235
|
+
for band_TxRx_homecall in self.band_TxRx_homecall_recent_l4grid.data:
|
|
236
|
+
band_TxRx_homecall_recent_l4grid = self.band_TxRx_homecall_recent_l4grid.data[band_TxRx_homecall]
|
|
237
|
+
if len(band_TxRx_homecall_recent_l4grid):
|
|
235
238
|
b, iTxRx, c = band_TxRx_homecall.split('_')
|
|
236
239
|
iTxRx = int(iTxRx)
|
|
237
240
|
self.home_activity.setdefault(b, [0, 0])
|
|
238
241
|
self.home_activity[b][iTxRx] +=1
|
|
239
242
|
self.home_most_remotes.setdefault(b, [('Nobody',0), ('Nobody',0)])
|
|
240
|
-
nremotes = len(
|
|
243
|
+
nremotes = len(band_TxRx_homecall_recent_l4grid)
|
|
241
244
|
current_winner = self.home_most_remotes[b][iTxRx]
|
|
242
245
|
if nremotes > current_winner[1]:
|
|
243
246
|
if c != self.my_call:
|
|
244
247
|
self.home_most_remotes[b][iTxRx] = (c, nremotes)
|
|
245
248
|
|
|
246
249
|
def get_spot_counts(self, band, call):
|
|
247
|
-
tx_reports = self.
|
|
248
|
-
rx_reports = self.
|
|
250
|
+
tx_reports = self.band_TxRx_homecall_recent_l4grid.data.get(f"{band}_0_{call}", [])
|
|
251
|
+
rx_reports = self.band_TxRx_homecall_recent_l4grid.data.get(f"{band}_1_{call}", [])
|
|
249
252
|
n_spotting = len(tx_reports) if tx_reports else 0
|
|
250
253
|
n_spotted = len(rx_reports) if rx_reports else 0
|
|
251
254
|
return n_spotted, n_spotting
|
|
@@ -298,6 +301,10 @@ class ADIF:
|
|
|
298
301
|
import calendar
|
|
299
302
|
def parse(rec, field):
|
|
300
303
|
p = rec.find(field)
|
|
304
|
+
if p<0:
|
|
305
|
+
p = rec.find(field.upper())
|
|
306
|
+
if p<0:
|
|
307
|
+
p = rec.find(field.lower())
|
|
301
308
|
if p > 0:
|
|
302
309
|
p1, p2 = rec.find(':',p), rec.find('>',p)
|
|
303
310
|
n = int(rec[p1+1:p2])
|
|
@@ -307,8 +314,9 @@ class ADIF:
|
|
|
307
314
|
for l in f.readlines():
|
|
308
315
|
if parse(l, 'mode') == "FT8":
|
|
309
316
|
c, b, d, t = parse(l, 'call'), parse(l, 'band'), parse(l, 'qso_date'), parse(l, 'time_on')
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
317
|
+
if c and b and d and t:
|
|
318
|
+
time_tuple = time.strptime(d+t, "%Y%m%d%H%M%S")
|
|
319
|
+
tm = calendar.timegm(time_tuple)
|
|
320
|
+
cache[c] = tm
|
|
321
|
+
cache[c + "_"+b+"_FT8"] = tm
|
|
314
322
|
return cache
|
|
@@ -9,13 +9,13 @@ from PyFT8.pskreporter import PSKR_upload
|
|
|
9
9
|
from PyFT8.gui import Gui
|
|
10
10
|
from PyFT8.transmitter import AudioOut
|
|
11
11
|
from PyFT8.time_utils import global_time_utils
|
|
12
|
-
from PyFT8.rigctrl import
|
|
12
|
+
from PyFT8.rigctrl import Rig_hamlib
|
|
13
13
|
from PyFT8.databases import History, ADIF
|
|
14
14
|
|
|
15
|
-
VER = '
|
|
15
|
+
VER = '3.0.0'
|
|
16
16
|
|
|
17
17
|
MAX_TX_START_SECONDS = 2.5
|
|
18
|
-
HEARING_PANEL_LIFE_MINS =
|
|
18
|
+
HEARING_PANEL_LIFE_MINS = 20
|
|
19
19
|
PSKR_REFRESH_MINS = 20
|
|
20
20
|
config_folder, rig, gui, qso, adif_logging, history, pskr_upload, output_device_idx = None, None, None, None, None, None, None, None
|
|
21
21
|
busy_profile, hearing_me = None, None
|
|
@@ -38,9 +38,6 @@ def get_config():
|
|
|
38
38
|
config['bands'] = {'20m':14.074}
|
|
39
39
|
config['gui'] = {'loc':'km_deg', 'wb':'Y'}
|
|
40
40
|
config['hamlib_rig'] = {'rigctld':'C:/WSJT/wsjtx/bin/rigctld-wsjtx', 'port': 'COM4', 'baud_rate':9600, 'model':3070}
|
|
41
|
-
config['rig'] = {'port': 'COM4', 'baud_rate':9600,
|
|
42
|
-
'set_freq_command':'FEFE88E0.05.0000000000.FD', 'set_freq_value':'5|5|vfBcdLU|1|0',
|
|
43
|
-
'ptt_on_command':'FEFE88E0.1C00.01.FD', 'ptt_off_command':'FEFE88E0.1C00.00.FD'}
|
|
44
41
|
config['pskreporter'] = {'upload':'Y'}
|
|
45
42
|
with open(ini_file, 'w') as f:
|
|
46
43
|
config.write(f)
|
|
@@ -57,7 +54,7 @@ class Message:
|
|
|
57
54
|
mycall = qso.mStation['c']
|
|
58
55
|
self.h0_idx, self.f0_idx, self.msg_tuple, self.msg, self.snr, self.dt, self.fHz = c.h0_idx, c.f0_idx, c.msg_tuple, c.msg, c.snr, c.dt, c.fHz
|
|
59
56
|
self.cyclestart = c.cyclestart
|
|
60
|
-
self.expire = time.time() + 29.
|
|
57
|
+
self.expire = time.time() + 29.25
|
|
61
58
|
self.is_from_me = c.msg_tuple[1] == mycall
|
|
62
59
|
self.is_to_me = c.msg_tuple[0] == mycall
|
|
63
60
|
self.is_cq = c.msg_tuple[0].startswith('CQ')
|
|
@@ -351,9 +348,6 @@ def cli():
|
|
|
351
348
|
if config.has_section('hamlib_rig'):
|
|
352
349
|
console_print("Connecting to rig via Hamlib")
|
|
353
350
|
rig = Rig_hamlib(config)
|
|
354
|
-
else:
|
|
355
|
-
console_print("Connecting to rig via CAT")
|
|
356
|
-
rig = Rig_CAT(config)
|
|
357
351
|
|
|
358
352
|
if args.outputcard_keywords:
|
|
359
353
|
outputcard_keywords = args.outputcard_keywords.replace(' ','').split(',')
|
|
@@ -378,8 +372,9 @@ def cli():
|
|
|
378
372
|
if not args.no_gui:
|
|
379
373
|
gui = Gui(audio_in.dBgrid_main, 4, 2, config, on_gui_sidebars_refresh, on_gui_msg_click, on_gui_control_click)
|
|
380
374
|
history = History(config_folder, mc, mg, PSKR_REFRESH_MINS, args.parse_all_file)
|
|
375
|
+
history.set_bands(config['bands'])
|
|
381
376
|
adif_logging = ADIF(f"{config_folder}/PyFT8.adi")
|
|
382
|
-
history.
|
|
377
|
+
history.load_hearing_heard_from_adif(adif_logging.cache)
|
|
383
378
|
|
|
384
379
|
if mc is not None and 'pskreporter' in config.keys():
|
|
385
380
|
if config['pskreporter']['upload'] == 'Y':
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import time, socket, subprocess, threading
|
|
2
|
+
|
|
3
|
+
class Rig_hamlib:
|
|
4
|
+
def __init__(self, config):
|
|
5
|
+
com = config['hamlib_rig']['port']
|
|
6
|
+
s = config['hamlib_rig']['baud_rate']
|
|
7
|
+
rigctld = config['hamlib_rig']['rigctld']
|
|
8
|
+
rig = config['hamlib_rig']['model']
|
|
9
|
+
host, port ="localhost", 4532
|
|
10
|
+
cmd = f"{rigctld} -m {rig} -r {com} -s {s}"
|
|
11
|
+
self.sock = None
|
|
12
|
+
if not self.create_socket(host, port):
|
|
13
|
+
threading.Thread(target = subprocess.run, args = (cmd,)).start()
|
|
14
|
+
time.sleep(0.5)
|
|
15
|
+
if not self.create_socket(host, port):
|
|
16
|
+
print(f"Couldn't create socket for hamlib on {host}:{port}")
|
|
17
|
+
self.set_mode("PKTUSB")
|
|
18
|
+
|
|
19
|
+
def create_socket(self, host, port):
|
|
20
|
+
try:
|
|
21
|
+
self.sock = socket.create_connection((host, port))
|
|
22
|
+
except:
|
|
23
|
+
return False
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
def cmd(self, command):
|
|
27
|
+
if self.sock:
|
|
28
|
+
self.sock.sendall((command + "\n").encode())
|
|
29
|
+
return self.sock.recv(1024).decode()
|
|
30
|
+
|
|
31
|
+
def set_mode(self, mode):
|
|
32
|
+
self.cmd(f"M {mode} 0")
|
|
33
|
+
|
|
34
|
+
def set_freq_Hz(self, hz):
|
|
35
|
+
self.cmd(f"F {hz}")
|
|
36
|
+
|
|
37
|
+
def ptt_on(self):
|
|
38
|
+
self.cmd(f"T 1")
|
|
39
|
+
|
|
40
|
+
def ptt_off(self):
|
|
41
|
+
self.cmd(f"T 0")
|
|
42
|
+
|
|
43
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyFT8
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.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
|
|
@@ -35,26 +35,20 @@ If you're interested in how this works, maybe have a look at [MiniPyFT8](https:/
|
|
|
35
35
|
- Finds sound cards by keywords so follows them if windows moves them ...
|
|
36
36
|
- Logs QSOs to ADIF file and all spots to WSJTX-style ALL.txt file
|
|
37
37
|
- Uploads spots to pskreporter
|
|
38
|
-
-
|
|
39
|
-
- Or control rigs via Hamlib
|
|
38
|
+
- Control rigs via Hamlib
|
|
40
39
|
|
|
41
40
|
The Gui shows:
|
|
42
41
|
- Simultaneous views of odd and even cycles
|
|
43
42
|
- Messages overlaid on waterfall signals that produce them
|
|
44
43
|
- Worked-before info and fine grid locators / distance and bearing in the message boxes
|
|
45
|
-
- List of stations hearing your transmissions on the selected band
|
|
44
|
+
- List of stations hearing your transmissions on the selected band, alternating with list of stations you're hearing. On these lists, 'new' callsigns (first heard since program launch) are in white font.
|
|
46
45
|
- Marker on message if 2nd call is hearing you
|
|
47
46
|
- Band activity in your level 4 square live updated next to band select buttons
|
|
48
47
|
- Number of remote stations hearing your Tx, number of remote Txs that you're hearing, plus the same info for the 'best' station in your level 4 square
|
|
49
48
|
- Data used for the above is cached to disk so is not lost when restarting the program
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
```
|
|
53
|
-
[pskreporter]
|
|
54
|
-
upload = Y
|
|
55
|
-
```
|
|
50
|
+
<img width="997" height="1025" alt="screenshot" src="https://github.com/user-attachments/assets/88228f65-c39e-45ee-9f73-1d85138563f3" />
|
|
56
51
|
|
|
57
|
-

|
|
58
52
|
|
|
59
53
|
## Motivation
|
|
60
54
|
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.
|
|
@@ -68,7 +62,7 @@ If you want to install this software without getting into the code, you can inst
|
|
|
68
62
|
pip install PyFT8
|
|
69
63
|
```
|
|
70
64
|
|
|
71
|
-
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
|
|
65
|
+
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 April 2026) rewriting and improving.
|
|
72
66
|
|
|
73
67
|
The use cases below are the ones held in the test cases at the bottom of the main pyft8.py file. Where a config folder and .ini file is required, if you leave off the '-c {folder}' option, you'll be asked if you want to create a .ini file in the current directory.
|
|
74
68
|
|
|
@@ -82,30 +76,19 @@ The use cases below are the ones held in the test cases at the bottom of the mai
|
|
|
82
76
|
| Command line create a wav file | pyft8 -w "Mywav.wav" -m "CQ G1OJS IO90"| No config folder / .ini file required |
|
|
83
77
|
|
|
84
78
|
### Rig control
|
|
85
|
-
I
|
|
86
|
-
|
|
87
|
-
I've also included a basic Hamlib interface which launches rigctld and uses that to control the rig. To use this, make sure that the hamlib section of the ini file is populated; this will then take precedence over the direct CAT control section.
|
|
88
|
-
|
|
89
|
-
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.
|
|
79
|
+
I designed PyFT8 to be minimal as far as possible, and it needs very limited control of the rig in order to transmit. However, whilst previous versions used direct CAT control and Hamlib as a fallback, Version 3.0.0 moves to Hamlib as the only interface.
|
|
90
80
|
|
|
91
81
|
## Customising
|
|
92
|
-
Even if you don't want to edit the code, there's quite a bit that you can change in the .ini file. I haven't written a user guide yet, so here's my own .ini file for reference.
|
|
82
|
+
Even if you don't want to edit the code, there's quite a bit that you can change in the .ini file. I haven't written a user guide yet, so here's my own .ini file for reference. You'll note that my instance of rigctld is in C:/WSJT/wsjtx/bin/rigctld-wsjtx, but any rigctld should also work. PyFT8 launches this if it's not already running (to be precise, it launches it if it can't open a socket on localhost:4532).
|
|
93
83
|
|
|
94
84
|
```
|
|
95
85
|
[station]
|
|
96
86
|
call = G1OJS
|
|
97
|
-
grid =
|
|
87
|
+
grid = IO******
|
|
98
88
|
[gui]
|
|
99
89
|
loc = km_deg
|
|
100
90
|
wb = Y
|
|
101
|
-
[
|
|
102
|
-
port = COM4
|
|
103
|
-
baud_rate = 9600
|
|
104
|
-
set_freq_command = FEFE88E0.05.0000000000.FD
|
|
105
|
-
set_freq_value = 5|5|vfBcdLU|1|0
|
|
106
|
-
ptt_on_command = FEFE88E0.1C00.01.FD
|
|
107
|
-
ptt_off_command = FEFE88E0.1C00.00.FD
|
|
108
|
-
[_hamlib_rig]
|
|
91
|
+
[hamlib_rig]
|
|
109
92
|
rigctld = C:/WSJT/wsjtx/bin/rigctld-wsjtx
|
|
110
93
|
port = COM4
|
|
111
94
|
baud_rate = 9600
|
|
@@ -128,7 +111,8 @@ upload = Y
|
|
|
128
111
|
|
|
129
112
|
The image below shows the number of decodes from PyFT8, WSJT-x V2.7.0 running in NORM mode, and FT8_lib, using the same 10 minutes of busy 20m audio that is used to test ft8_lib.
|
|
130
113
|
|
|
131
|
-
|
|
114
|
+
<img width="640" height="480" alt="performance snapshot" src="https://github.com/user-attachments/assets/b62221ec-2d74-4fbc-9c61-34b2bbc0e963" />
|
|
115
|
+
|
|
132
116
|
|
|
133
117
|
## Limitations
|
|
134
118
|
PyFT8 doesn't decode / encode *all* message types. The table below shows which are handled.
|
|
@@ -16,26 +16,20 @@ If you're interested in how this works, maybe have a look at [MiniPyFT8](https:/
|
|
|
16
16
|
- Finds sound cards by keywords so follows them if windows moves them ...
|
|
17
17
|
- Logs QSOs to ADIF file and all spots to WSJTX-style ALL.txt file
|
|
18
18
|
- Uploads spots to pskreporter
|
|
19
|
-
-
|
|
20
|
-
- Or control rigs via Hamlib
|
|
19
|
+
- Control rigs via Hamlib
|
|
21
20
|
|
|
22
21
|
The Gui shows:
|
|
23
22
|
- Simultaneous views of odd and even cycles
|
|
24
23
|
- Messages overlaid on waterfall signals that produce them
|
|
25
24
|
- Worked-before info and fine grid locators / distance and bearing in the message boxes
|
|
26
|
-
- List of stations hearing your transmissions on the selected band
|
|
25
|
+
- List of stations hearing your transmissions on the selected band, alternating with list of stations you're hearing. On these lists, 'new' callsigns (first heard since program launch) are in white font.
|
|
27
26
|
- Marker on message if 2nd call is hearing you
|
|
28
27
|
- Band activity in your level 4 square live updated next to band select buttons
|
|
29
28
|
- Number of remote stations hearing your Tx, number of remote Txs that you're hearing, plus the same info for the 'best' station in your level 4 square
|
|
30
29
|
- Data used for the above is cached to disk so is not lost when restarting the program
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
```
|
|
34
|
-
[pskreporter]
|
|
35
|
-
upload = Y
|
|
36
|
-
```
|
|
31
|
+
<img width="997" height="1025" alt="screenshot" src="https://github.com/user-attachments/assets/88228f65-c39e-45ee-9f73-1d85138563f3" />
|
|
37
32
|
|
|
38
|
-

|
|
39
33
|
|
|
40
34
|
## Motivation
|
|
41
35
|
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.
|
|
@@ -49,7 +43,7 @@ If you want to install this software without getting into the code, you can inst
|
|
|
49
43
|
pip install PyFT8
|
|
50
44
|
```
|
|
51
45
|
|
|
52
|
-
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
|
|
46
|
+
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 April 2026) rewriting and improving.
|
|
53
47
|
|
|
54
48
|
The use cases below are the ones held in the test cases at the bottom of the main pyft8.py file. Where a config folder and .ini file is required, if you leave off the '-c {folder}' option, you'll be asked if you want to create a .ini file in the current directory.
|
|
55
49
|
|
|
@@ -63,30 +57,19 @@ The use cases below are the ones held in the test cases at the bottom of the mai
|
|
|
63
57
|
| Command line create a wav file | pyft8 -w "Mywav.wav" -m "CQ G1OJS IO90"| No config folder / .ini file required |
|
|
64
58
|
|
|
65
59
|
### Rig control
|
|
66
|
-
I
|
|
67
|
-
|
|
68
|
-
I've also included a basic Hamlib interface which launches rigctld and uses that to control the rig. To use this, make sure that the hamlib section of the ini file is populated; this will then take precedence over the direct CAT control section.
|
|
69
|
-
|
|
70
|
-
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.
|
|
60
|
+
I designed PyFT8 to be minimal as far as possible, and it needs very limited control of the rig in order to transmit. However, whilst previous versions used direct CAT control and Hamlib as a fallback, Version 3.0.0 moves to Hamlib as the only interface.
|
|
71
61
|
|
|
72
62
|
## Customising
|
|
73
|
-
Even if you don't want to edit the code, there's quite a bit that you can change in the .ini file. I haven't written a user guide yet, so here's my own .ini file for reference.
|
|
63
|
+
Even if you don't want to edit the code, there's quite a bit that you can change in the .ini file. I haven't written a user guide yet, so here's my own .ini file for reference. You'll note that my instance of rigctld is in C:/WSJT/wsjtx/bin/rigctld-wsjtx, but any rigctld should also work. PyFT8 launches this if it's not already running (to be precise, it launches it if it can't open a socket on localhost:4532).
|
|
74
64
|
|
|
75
65
|
```
|
|
76
66
|
[station]
|
|
77
67
|
call = G1OJS
|
|
78
|
-
grid =
|
|
68
|
+
grid = IO******
|
|
79
69
|
[gui]
|
|
80
70
|
loc = km_deg
|
|
81
71
|
wb = Y
|
|
82
|
-
[
|
|
83
|
-
port = COM4
|
|
84
|
-
baud_rate = 9600
|
|
85
|
-
set_freq_command = FEFE88E0.05.0000000000.FD
|
|
86
|
-
set_freq_value = 5|5|vfBcdLU|1|0
|
|
87
|
-
ptt_on_command = FEFE88E0.1C00.01.FD
|
|
88
|
-
ptt_off_command = FEFE88E0.1C00.00.FD
|
|
89
|
-
[_hamlib_rig]
|
|
72
|
+
[hamlib_rig]
|
|
90
73
|
rigctld = C:/WSJT/wsjtx/bin/rigctld-wsjtx
|
|
91
74
|
port = COM4
|
|
92
75
|
baud_rate = 9600
|
|
@@ -109,7 +92,8 @@ upload = Y
|
|
|
109
92
|
|
|
110
93
|
The image below shows the number of decodes from PyFT8, WSJT-x V2.7.0 running in NORM mode, and FT8_lib, using the same 10 minutes of busy 20m audio that is used to test ft8_lib.
|
|
111
94
|
|
|
112
|
-
|
|
95
|
+
<img width="640" height="480" alt="performance snapshot" src="https://github.com/user-attachments/assets/b62221ec-2d74-4fbc-9c61-34b2bbc0e963" />
|
|
96
|
+
|
|
113
97
|
|
|
114
98
|
## Limitations
|
|
115
99
|
PyFT8 doesn't decode / encode *all* message types. The table below shows which are handled.
|
pyft8-2.12.2/PyFT8/rigctrl.py
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import serial, time, socket, subprocess, threading
|
|
2
|
-
|
|
3
|
-
class Rig_CAT:
|
|
4
|
-
def __init__(self, config, verbose = False):
|
|
5
|
-
self.serial_port = False
|
|
6
|
-
self.port = config['rig']['port']
|
|
7
|
-
self.baud_rate = config['rig']['baud_rate']
|
|
8
|
-
self.ptt_on_cmd = self.parse_configstr(config['rig']['ptt_on_command']) if config['rig']['ptt_on_command']else None
|
|
9
|
-
self.ptt_off_cmd = self.parse_configstr(config['rig']['ptt_off_command']) if config['rig']['ptt_off_command'] else None
|
|
10
|
-
self.set_freq_cmd = self.parse_configstr(config['rig']['set_freq_command']) if config['rig']['set_freq_command'] else None
|
|
11
|
-
self.set_freq_value = config['rig']['set_freq_value'] if config['rig']['set_freq_value'] else None
|
|
12
|
-
self.verbose = verbose
|
|
13
|
-
|
|
14
|
-
def parse_configstr(self, configstr):
|
|
15
|
-
if "." in configstr:
|
|
16
|
-
hexstr = configstr.replace(".", "")
|
|
17
|
-
return bytearray.fromhex(hexstr)
|
|
18
|
-
else:
|
|
19
|
-
return bytearray(configstr.encode())
|
|
20
|
-
|
|
21
|
-
def vprint(self, text):
|
|
22
|
-
if self.verbose:
|
|
23
|
-
print(text)
|
|
24
|
-
|
|
25
|
-
def _sendCAT(self, msg):
|
|
26
|
-
try:
|
|
27
|
-
self.serial_port = serial.Serial(port = self.port, baudrate = self.baud_rate, timeout = 0.1)
|
|
28
|
-
except Exception as e:
|
|
29
|
-
print(f"[CAT] couldn't open {self.port}: {e}")
|
|
30
|
-
if (self.serial_port):
|
|
31
|
-
self.serial_port.reset_input_buffer()
|
|
32
|
-
self.vprint(f"[CAT] send {msg.hex(' ')}")
|
|
33
|
-
try:
|
|
34
|
-
self.serial_port.write(msg)
|
|
35
|
-
time.sleep(0.05)
|
|
36
|
-
self.serial_port.close()
|
|
37
|
-
except Exception as e:
|
|
38
|
-
print(f"[CAT] couldn't send CAT command {msg} on {self.port}: {e}")
|
|
39
|
-
|
|
40
|
-
def set_freq_Hz(self, freqHz):
|
|
41
|
-
if self.set_freq_cmd and self.set_freq_value:
|
|
42
|
-
self.vprint(f"[CAT] SET frequency to {freqHz} Hz")
|
|
43
|
-
start, length, fmt, mult, offset = self.set_freq_value.split("|")
|
|
44
|
-
start, length, mult, offset = int(start), int(length), int(mult), int(offset)
|
|
45
|
-
fVal = freqHz * mult + offset
|
|
46
|
-
nDigits = length if fmt == "text" else 2*length
|
|
47
|
-
s = f"{fVal:0{nDigits}d}"
|
|
48
|
-
if fmt=='text':
|
|
49
|
-
fBytes = s.encode()
|
|
50
|
-
else:
|
|
51
|
-
pairs = [(int(s[i]) << 4) | int(s[i+1]) for i in range(0, len(s), 2)]
|
|
52
|
-
if fmt == "vfBcdLU":
|
|
53
|
-
fBytes = bytes(pairs[::-1])
|
|
54
|
-
else:
|
|
55
|
-
fBytes = bytes(pairs)
|
|
56
|
-
cmd = bytearray(self.set_freq_cmd)
|
|
57
|
-
cmd[start:start+length] = fBytes
|
|
58
|
-
if fmt.startswith("vfBcd"): # CI-V
|
|
59
|
-
cmd = b'\x00' + cmd
|
|
60
|
-
self._sendCAT(cmd)
|
|
61
|
-
|
|
62
|
-
def ptt_on(self):
|
|
63
|
-
if self.ptt_on_cmd:
|
|
64
|
-
self.vprint(f"[CAT] PTT On")
|
|
65
|
-
self._sendCAT(self.ptt_on_cmd)
|
|
66
|
-
|
|
67
|
-
def ptt_off(self):
|
|
68
|
-
if self.ptt_off_cmd:
|
|
69
|
-
self.vprint(f"[CAT] PTT Off")
|
|
70
|
-
self._sendCAT(self.ptt_off_cmd)
|
|
71
|
-
|
|
72
|
-
class Rig_hamlib:
|
|
73
|
-
def __init__(self, config):
|
|
74
|
-
com = config['hamlib_rig']['port']
|
|
75
|
-
s = config['hamlib_rig']['baud_rate']
|
|
76
|
-
rigctld = config['hamlib_rig']['rigctld']
|
|
77
|
-
rig = config['hamlib_rig']['model']
|
|
78
|
-
host, port ="localhost", 4532
|
|
79
|
-
cmd = f"{rigctld} -m {rig} -r /{com} -s {s}"
|
|
80
|
-
threading.Thread(target = subprocess.run, args = (cmd,)).start()
|
|
81
|
-
self.sock = socket.create_connection((host, port))
|
|
82
|
-
|
|
83
|
-
def cmd(self, command):
|
|
84
|
-
self.sock.sendall((command + "\n").encode())
|
|
85
|
-
return self.sock.recv(1024).decode()
|
|
86
|
-
|
|
87
|
-
def set_freq_Hz(self, hz):
|
|
88
|
-
self.cmd(f"F {hz}")
|
|
89
|
-
|
|
90
|
-
def ptt_on(self):
|
|
91
|
-
self.cmd(f"T 1")
|
|
92
|
-
|
|
93
|
-
def ptt_off(self):
|
|
94
|
-
self.cmd(f"T 0")
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if __name__ == '__main__':
|
|
98
|
-
rig = Rig_hamlib()
|
|
99
|
-
rig.set_freq_Hz(14074000)
|
|
100
|
-
rig.ptt_on()
|
|
101
|
-
time.sleep(0.1)
|
|
102
|
-
rig.ptt_off()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|