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.
Files changed (30) hide show
  1. {pyft8-2.12.2 → pyft8-3.0.0}/PKG-INFO +11 -27
  2. {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/databases.py +45 -37
  3. {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/pyft8.py +6 -11
  4. pyft8-3.0.0/PyFT8/rigctrl.py +43 -0
  5. {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8.egg-info/PKG-INFO +11 -27
  6. {pyft8-2.12.2 → pyft8-3.0.0}/README.md +10 -26
  7. {pyft8-2.12.2 → pyft8-3.0.0}/pyproject.toml +1 -1
  8. pyft8-2.12.2/PyFT8/rigctrl.py +0 -102
  9. {pyft8-2.12.2 → pyft8-3.0.0}/LICENSE +0 -0
  10. {pyft8-2.12.2 → pyft8-3.0.0}/MANIFEST.in +0 -0
  11. {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/__init__.py +0 -0
  12. {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/gui.py +0 -0
  13. {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/pskreporter.py +0 -0
  14. {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/receiver.py +0 -0
  15. {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/time_utils.py +0 -0
  16. {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8/transmitter.py +0 -0
  17. {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8.egg-info/SOURCES.txt +0 -0
  18. {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8.egg-info/dependency_links.txt +0 -0
  19. {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8.egg-info/entry_points.txt +0 -0
  20. {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8.egg-info/requires.txt +0 -0
  21. {pyft8-2.12.2 → pyft8-3.0.0}/PyFT8.egg-info/top_level.txt +0 -0
  22. {pyft8-2.12.2 → pyft8-3.0.0}/setup.cfg +0 -0
  23. {pyft8-2.12.2 → pyft8-3.0.0}/tests/dev/CQ AAAA.py +0 -0
  24. {pyft8-2.12.2 → pyft8-3.0.0}/tests/dev/osd.py +0 -0
  25. {pyft8-2.12.2 → pyft8-3.0.0}/tests/dev/test_generate_wav.py +0 -0
  26. {pyft8-2.12.2 → pyft8-3.0.0}/tests/dev/test_loopback_performance.py +0 -0
  27. {pyft8-2.12.2 → pyft8-3.0.0}/tests/dev/view_worked_before.py +0 -0
  28. {pyft8-2.12.2 → pyft8-3.0.0}/tests/plot_baseline.py +0 -0
  29. {pyft8-2.12.2 → pyft8-3.0.0}/tests/spare.py +0 -0
  30. {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: 2.12.2
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
- - Direct CAT control for some rigs, designed to drop connection when not used, allowing sharing of rig's serial port
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
- To enable uploading of spots to pskreporter, make sure that your .ini file includes
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
- ![screenshot](screenshot.png)
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 Jan 2026) rewriting and improving.
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've included the Python code that I use with my Icom IC-7100 in the file 'rigctrl.py', and believe I've moved sufficient 'specification' for the rig protocol into the .ini file so that you can paste in your own rig specification (see for e.g. the Omnirig .ini file for your rig) and get it working with PyFT8 controlling PTT and frequency. I designed this code to drop the serial connection when it's not required, so that the rig's serial port can be accessed by other software at the same time (assuming that the other software returns the favour and doesn't permanently hog the serial port).
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. I use CAT control so my hamlib block is disabled by adding a _ to the name of the block (I keep it there for testing only). I need to add some more switches really to catch up with recent additions.
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 = IO90******
87
+ grid = IO******
98
88
  [gui]
99
89
  loc = km_deg
100
90
  wb = Y
101
- [rig]
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
- ![performance snapshot](performance%20snapshot.png)
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
- self.data = json.load(f)
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
- tmp_file = f"{self.file}.tmp"
72
- with open(tmp_file, "w") as f:
73
- json.dump(self.data, f)
74
- f.flush()
75
- os.fsync(f.fileno())
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.band_TxRx_homecall_report_times = DiskDict(f"{self.config_folder}/report_times.json", 9) # last 20 mins data -> per band tx/rx & current band detail
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.load_all_file(f"{self.config_folder}/ALL.txt")
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 band_from_MHz(self, fMHz): # rewrite this to use the band button defs from ini file
106
- f = int(fMHz)
107
- if f > 0:
108
- freqs = [1,3,5,7,10,14,18,21,24,28,50,144,433]
109
- idx = freqs.index(f) if f in freqs else -1
110
- if idx > -1:
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 load_all_file(self, all_file):
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.band_from_MHz(r['fMHz'])
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.band_TxRx_homecall_report_times.data.setdefault(key, [])
179
- self.band_TxRx_homecall_report_times.data[key].append(int(t-self.t0))
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.band_TxRx_homecall_report_times.data:
227
- band_TxRx_homecall_report_times = self.band_TxRx_homecall_report_times.data[band_TxRx_homecall]
228
- band_TxRx_homecall_report_times = [t for t in band_TxRx_homecall_report_times if (time.time() - self.t0 - t) < 60*self.pskr_refresh_mins]
229
- self.band_TxRx_homecall_report_times.data[band_TxRx_homecall] = band_TxRx_homecall_report_times
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.band_TxRx_homecall_report_times.data:
233
- band_TxRx_homecall_report_times = self.band_TxRx_homecall_report_times.data[band_TxRx_homecall]
234
- if len(band_TxRx_homecall_report_times):
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(band_TxRx_homecall_report_times)
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.band_TxRx_homecall_report_times.data.get(f"{band}_0_{call}", [])
248
- rx_reports = self.band_TxRx_homecall_report_times.data.get(f"{band}_1_{call}", [])
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
- time_tuple = time.strptime(d+t, "%Y%m%d%H%M%S")
311
- tm = calendar.timegm(time_tuple)
312
- cache[c] = tm
313
- cache[c + "_"+b+"_FT8"] = tm
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 Rig_CAT, Rig_hamlib
12
+ from PyFT8.rigctrl import Rig_hamlib
13
13
  from PyFT8.databases import History, ADIF
14
14
 
15
- VER = '2.12.2'
15
+ VER = '3.0.0'
16
16
 
17
17
  MAX_TX_START_SECONDS = 2.5
18
- HEARING_PANEL_LIFE_MINS = 5
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.8
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.load_from_wb(adif_logging.cache)
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: 2.12.2
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
- - Direct CAT control for some rigs, designed to drop connection when not used, allowing sharing of rig's serial port
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
- To enable uploading of spots to pskreporter, make sure that your .ini file includes
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
- ![screenshot](screenshot.png)
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 Jan 2026) rewriting and improving.
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've included the Python code that I use with my Icom IC-7100 in the file 'rigctrl.py', and believe I've moved sufficient 'specification' for the rig protocol into the .ini file so that you can paste in your own rig specification (see for e.g. the Omnirig .ini file for your rig) and get it working with PyFT8 controlling PTT and frequency. I designed this code to drop the serial connection when it's not required, so that the rig's serial port can be accessed by other software at the same time (assuming that the other software returns the favour and doesn't permanently hog the serial port).
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. I use CAT control so my hamlib block is disabled by adding a _ to the name of the block (I keep it there for testing only). I need to add some more switches really to catch up with recent additions.
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 = IO90******
87
+ grid = IO******
98
88
  [gui]
99
89
  loc = km_deg
100
90
  wb = Y
101
- [rig]
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
- ![performance snapshot](performance%20snapshot.png)
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
- - Direct CAT control for some rigs, designed to drop connection when not used, allowing sharing of rig's serial port
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
- To enable uploading of spots to pskreporter, make sure that your .ini file includes
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
- ![screenshot](screenshot.png)
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 Jan 2026) rewriting and improving.
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've included the Python code that I use with my Icom IC-7100 in the file 'rigctrl.py', and believe I've moved sufficient 'specification' for the rig protocol into the .ini file so that you can paste in your own rig specification (see for e.g. the Omnirig .ini file for your rig) and get it working with PyFT8 controlling PTT and frequency. I designed this code to drop the serial connection when it's not required, so that the rig's serial port can be accessed by other software at the same time (assuming that the other software returns the favour and doesn't permanently hog the serial port).
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. I use CAT control so my hamlib block is disabled by adding a _ to the name of the block (I keep it there for testing only). I need to add some more switches really to catch up with recent additions.
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 = IO90******
68
+ grid = IO******
79
69
  [gui]
80
70
  loc = km_deg
81
71
  wb = Y
82
- [rig]
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
- ![performance snapshot](performance%20snapshot.png)
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "PyFT8"
3
- version = "2.12.2"
3
+ version = "3.0.0"
4
4
  license = "GPL-3.0-or-later"
5
5
 
6
6
  authors = [
@@ -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