PyFT8 3.1.0__tar.gz → 3.6.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 (44) hide show
  1. {pyft8-3.1.0 → pyft8-3.6.0}/PKG-INFO +6 -5
  2. {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8/databases.py +80 -68
  3. pyft8-3.6.0/PyFT8/gui.py +298 -0
  4. pyft8-3.6.0/PyFT8/message_broker.py +70 -0
  5. pyft8-3.6.0/PyFT8/osd.py +88 -0
  6. pyft8-3.6.0/PyFT8/pyft8.py +169 -0
  7. pyft8-3.6.0/PyFT8/qso_manager.py +122 -0
  8. pyft8-3.6.0/PyFT8/receiver.py +618 -0
  9. {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8/time_utils.py +20 -7
  10. {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8/transmitter.py +61 -55
  11. {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8.egg-info/PKG-INFO +6 -5
  12. {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8.egg-info/SOURCES.txt +16 -5
  13. {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8.egg-info/requires.txt +3 -0
  14. {pyft8-3.1.0 → pyft8-3.6.0}/README.md +2 -4
  15. {pyft8-3.1.0 → pyft8-3.6.0}/pyproject.toml +5 -2
  16. pyft8-3.6.0/tests/misc/animation.py +36 -0
  17. pyft8-3.6.0/tests/misc/bits_loopback.py +204 -0
  18. pyft8-3.6.0/tests/misc/bits_loopback_short.py +169 -0
  19. pyft8-3.6.0/tests/misc/symbols_loopback.py +81 -0
  20. {pyft8-3.1.0/tests/dev → pyft8-3.6.0/tests/misc}/test_loopback_performance.py +2 -10
  21. pyft8-3.6.0/tests/misc/text.py +10 -0
  22. pyft8-3.6.0/tests/pipeline/Fine tuning compare IOS_PC.py +373 -0
  23. pyft8-3.6.0/tests/pipeline/Targetted tests.py +396 -0
  24. pyft8-3.6.0/tests/pipeline/test_01.wav +0 -0
  25. {pyft8-3.1.0 → pyft8-3.6.0}/tests/plot_baseline.py +10 -9
  26. pyft8-3.6.0/tests/spare.py +52 -0
  27. {pyft8-3.1.0 → pyft8-3.6.0}/tests/test_batch_and_live.py +60 -50
  28. pyft8-3.1.0/PyFT8/gui.py +0 -227
  29. pyft8-3.1.0/PyFT8/pyft8.py +0 -404
  30. pyft8-3.1.0/PyFT8/receiver.py +0 -422
  31. pyft8-3.1.0/tests/spare.py +0 -18
  32. {pyft8-3.1.0 → pyft8-3.6.0}/LICENSE +0 -0
  33. {pyft8-3.1.0 → pyft8-3.6.0}/MANIFEST.in +0 -0
  34. {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8/__init__.py +0 -0
  35. {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8/pskreporter.py +0 -0
  36. {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8/rigctrl.py +0 -0
  37. {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8.egg-info/dependency_links.txt +0 -0
  38. {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8.egg-info/entry_points.txt +0 -0
  39. {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8.egg-info/top_level.txt +0 -0
  40. {pyft8-3.1.0 → pyft8-3.6.0}/setup.cfg +0 -0
  41. {pyft8-3.1.0/tests/dev → pyft8-3.6.0/tests/misc}/CQ AAAA.py +0 -0
  42. {pyft8-3.1.0/tests/dev → pyft8-3.6.0/tests/misc}/osd.py +0 -0
  43. {pyft8-3.1.0/tests/dev → pyft8-3.6.0/tests/misc}/test_generate_wav.py +0 -0
  44. {pyft8-3.1.0/tests/dev → pyft8-3.6.0/tests/misc}/view_worked_before.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyFT8
3
- Version: 3.1.0
3
+ Version: 3.6.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
@@ -15,6 +15,9 @@ License-File: LICENSE
15
15
  Requires-Dist: numpy>=1.24.0
16
16
  Requires-Dist: matplotlib
17
17
  Requires-Dist: pyaudio
18
+ Requires-Dist: pyserial
19
+ Requires-Dist: paho-mqtt
20
+ Requires-Dist: psutil
18
21
  Dynamic: license-file
19
22
 
20
23
  # PyFT8 [![PyPI Downloads](https://static.pepy.tech/personalized-badge/pyft8?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/pyft8) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/pyft8?period=weekly&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads/wk)](https://pepy.tech/projects/pyft8) [PyPI link](https://pypi.org/project/PyFT8/)
@@ -48,8 +51,7 @@ If you're interested in how this works, maybe have a look at [MiniPyFT8](https:/
48
51
  - 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
52
  - Data used for the above is cached to disk so is not lost when restarting the program
50
53
 
51
- <img width="997" height="1025" alt="screenshot" src="https://github.com/user-attachments/assets/88228f65-c39e-45ee-9f73-1d85138563f3" />
52
-
54
+ <img width="983" height="1021" alt="screenshot" src="https://github.com/user-attachments/assets/591b4423-cf50-4be7-88c1-a9ba2347a326" />
53
55
 
54
56
  ## Motivation
55
57
  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.
@@ -112,8 +114,7 @@ upload = Y
112
114
 
113
115
  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.
114
116
 
115
- <img width="640" height="480" alt="performance snapshot" src="https://github.com/user-attachments/assets/b62221ec-2d74-4fbc-9c61-34b2bbc0e963" />
116
-
117
+ <img width="900" height="898" alt="batch test performance" src="https://github.com/user-attachments/assets/513d4121-5483-4e7b-81df-785dc4db3d37" />
117
118
 
118
119
  ## Limitations
119
120
  PyFT8 doesn't decode / encode *all* message types. The table below shows which are handled.
@@ -1,5 +1,6 @@
1
+ import threading, os, pickle, json
1
2
  from PyFT8.pskreporter import PSKR_MQTT_listener
2
- import threading, time, os, pickle, json
3
+ from PyFT8.time_utils import time_utils
3
4
 
4
5
  call_hashes, hashes_for_calls = {}, {}
5
6
 
@@ -56,9 +57,9 @@ class DiskDict:
56
57
  threading.Thread(target = self._autosave, args=(autosave_t0,), daemon = True).start()
57
58
 
58
59
  def _autosave(self, autosave_t0, autosave_period = 15):
59
- time.sleep(autosave_t0)
60
+ time_utils.sleep(autosave_t0)
60
61
  while True:
61
- time.sleep(autosave_period)
62
+ time_utils.sleep(autosave_period)
62
63
  self.save()
63
64
 
64
65
  def load(self):
@@ -84,52 +85,50 @@ class DiskDict:
84
85
  return
85
86
 
86
87
  class History:
87
- def __init__(self, config_folder, my_call, home_square, pskr_refresh_mins, parse_all_file):
88
+ def __init__(self, config_folder, my_call, home_square = '', geo_units = 'km', pskr_refresh_mins = 20):
88
89
  self.pskr_refresh_mins = pskr_refresh_mins
89
- self.config_folder = config_folder
90
+ self.geo_units = geo_units
91
+ self.log_cache = None
90
92
  self.my_call = my_call
91
93
  self.home_square = home_square
92
94
  self.home_square_lev4 = home_square[:4]
93
95
  self.freqs_to_bands = {}
94
96
  self.dist_brg_cache = {}
95
- self.hearing_me = DiskDict(f"{self.config_folder}/hearing_me.json", 3) # all-time record of hearing me
96
- self.heard_by_me = DiskDict(f"{self.config_folder}/heard_by_me.json", 5) # all-time record of heard by me
97
97
  self.hearing_me_new = {}
98
98
  self.heard_by_me_new = {}
99
- self.call_to_grid = DiskDict(f"{self.config_folder}/call_to_grid.json", 7) # all time cache call -> fine locator
100
- 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
101
99
  self.home_activity = {}
102
100
  self.home_most_remotes = {}
103
101
  self.lock = threading.Lock()
104
- if parse_all_file:
105
- self.load_hearing_heard_from_all_file(f"{self.config_folder}/ALL.txt")
106
- self.hearing_me.save()
107
- self.heard_by_me.save()
108
- print("All file parsed and saved to hearing_me / heard_by_me files")
109
- else:
110
- mqtt = PSKR_MQTT_listener(self.home_square_lev4, self.add_mqtt_spot)
111
- threading.Thread(target = self.count_activity, daemon = True).start()
102
+ self.all_file = f"{config_folder}/ALL.txt"
103
+ self.hearing_me = DiskDict(f"{config_folder}/hearing_me.json", 3) # all-time record of hearing me
104
+ self.heard_by_me = DiskDict(f"{config_folder}/heard_by_me.json", 5) # all-time record of heard by me
105
+ self.call_to_grid = DiskDict(f"{config_folder}/call_to_grid.json", 7) # all time cache call -> fine locator
106
+ self.band_TxRx_homecall_recent_L4grid = DiskDict(f"{config_folder}/recent_l4grid.json", 9) # last 20 mins data -> per band tx/rx & current band detail
112
107
 
113
- def set_bands(self, bands):
114
- for b in bands:
115
- f = float(bands[b])
116
- self.freqs_to_bands[round(f,1)] = b
117
-
118
- def load_hearing_heard_from_adif(self, log_cache):
108
+ def start_collect_new(self):
109
+ mqtt = PSKR_MQTT_listener(self.home_square_lev4, self.add_mqtt_spot)
110
+ threading.Thread(target = self.count_activity, daemon = True).start()
111
+
112
+ def incorporate_log_data(self, log_cache):
113
+ self.log_cache = log_cache
119
114
  for key in log_cache:
120
115
  key_parts = key.split('_')
121
116
  if len(key_parts) > 1:
122
117
  c, b, m = key_parts
123
118
  if m == 'FT8':
124
119
  #print(f"Parsing adif log: Add hearing & heard by {c} on {b}")
125
- self.add_myspots_record(self.hearing_me.data, None, b, c, 0, 0)
126
- self.add_myspots_record(self.heard_by_me.data, None, b, c, 0, 0)
120
+ self._add_myspots_record(self.hearing_me.data, None, b, c, 0, 0)
121
+ self._add_myspots_record(self.heard_by_me.data, None, b, c, 0, 0)
127
122
  else:
128
123
  print(m)
129
124
 
130
- def load_hearing_heard_from_all_file(self, all_file):
131
- recs = self.parse_all_txt(all_file)
132
- if not any(recs): return
125
+ def load_hearing_heard_from_all_file(self, bands):
126
+ recs = self.parse_all_txt()
127
+ if not any(recs):
128
+ return
129
+ for b in bands:
130
+ f = float(bands[b])
131
+ self.freqs_to_bands[round(f,1)] = b
133
132
  for r in recs:
134
133
  if r['md'] == 'FT8':
135
134
  band = self.freqs_to_bands.get(round(r['fMHz'], 1), None)
@@ -138,12 +137,12 @@ class History:
138
137
  call = r['call_b'] if TxRx == 'Rx' else r['call_a']
139
138
  if len(call) > 3:
140
139
  data = self.heard_by_me.data if TxRx == 'Rx' else self.hearing_me.data
141
- self.add_myspots_record(data, None, band, call, 0, 0)
140
+ self._add_myspots_record(data, None, band, call, 0, 0)
142
141
 
143
- def parse_all_txt(self, all_file):
142
+ def parse_all_txt(self):
144
143
  rows, recs = None, []
145
- if os.path.exists(all_file):
146
- with open(all_file, 'r') as f:
144
+ if os.path.exists(self.all_file):
145
+ with open(self.all_file, 'r') as f:
147
146
  rows = f.readlines()
148
147
  if rows is not None:
149
148
  for r in rows:
@@ -156,15 +155,27 @@ class History:
156
155
  print("Didn't find any records in an ALL.txt file in the config folder")
157
156
  return recs
158
157
 
159
- def write_all_txt_row(self, cyclestart_string, fMHz, TxRx, mode, snr, dt, fHz, msg):
160
- all_file = f"{self.config_folder}/ALL.txt"
161
- filemode = 'w' if not os.path.exists(all_file) else 'a'
162
- row = f"{cyclestart_string} {fMHz:8.3f} {TxRx} {mode} {snr:+03d} {dt:4.1f} {fHz:4.0f} {msg}"
163
- with open(all_file, filemode) as f:
158
+ def process_message(self, message, band_info, myCall):
159
+ m = message
160
+ self._write_all_txt_row(m['cyclestart_string'], float(band_info['fMHz']), 'Rx', 'FT8',
161
+ m['their_snr'], m['dt'], m['fHz'], m['hail'], m['their_call'], m['grid_rpt'])
162
+ self._add_myspots_record(self.heard_by_me.data, self.heard_by_me_new,
163
+ band_info['current_band'], m['their_call'], int(time_utils.time()), m['their_snr'])
164
+ if m['hail'] == myCall: # They're calling me, so they're hearing me
165
+ rpt = m['grid_rpt'][-3:]
166
+ if rpt.isnumeric():
167
+ self._add_myspots_record(self.hearing_me.data, self.hearing_me_new,
168
+ band_info['current_band'], m['their_call'], int(time_utils.time()), int(rpt))
169
+
170
+
171
+ def _write_all_txt_row(self, cyclestart_string, fMHz, TxRx, mode, snr_str, dt, fHz, hail, their_call, grid_rpt):
172
+ filemode = 'w' if not os.path.exists(self.all_file) else 'a'
173
+ row = f"{cyclestart_string} {fMHz:8.3f} {TxRx} {mode} {snr_str} {dt:4.1f} {fHz:4.0f} {hail} {their_call} {grid_rpt}"
174
+ with open(self.all_file, filemode) as f:
164
175
  f.write(f"{row}\n")
165
176
 
166
177
  def add_mqtt_spot(self, d):
167
- tnow = int(time.time())
178
+ tnow = int(time_utils.time())
168
179
  sc, rc = (d['sc'], d['sl']), (d['rc'], d['rl'])
169
180
  for iTxRx, call_grid in enumerate([sc, rc]):
170
181
  call, grid = call_grid
@@ -172,9 +183,9 @@ class History:
172
183
  if self.home_square_lev4 in grid:
173
184
  self.add_homespots_record(f"{d['b']}_{iTxRx}_{call}", tnow)
174
185
  if d['sc'] == self.my_call:
175
- self.add_myspots_record(self.hearing_me.data, self.hearing_me_new, d['b'], d['rc'], tnow, d['rp'])
186
+ self._add_myspots_record(self.hearing_me.data, self.hearing_me_new, d['b'], d['rc'], tnow, d['rp'])
176
187
  if d['rc'] == self.my_call:
177
- self.add_myspots_record(self.heard_by_me.data, self.heard_by_me_new, d['b'], d['sc'], tnow, d['rp'])
188
+ self._add_myspots_record(self.heard_by_me.data, self.heard_by_me_new, d['b'], d['sc'], tnow, d['rp'])
178
189
 
179
190
  def store_best_grid(self, call, grid):
180
191
  if call.startswith('<'): return
@@ -183,10 +194,10 @@ class History:
183
194
  self.call_to_grid.data[call] = grid
184
195
 
185
196
  def add_homespots_record(self, key, t):
186
- self.band_TxRx_homecall_recent_l4grid.data.setdefault(key, [])
187
- self.band_TxRx_homecall_recent_l4grid.data[key].append(int(t))
197
+ self.band_TxRx_homecall_recent_L4grid.data.setdefault(key, [])
198
+ self.band_TxRx_homecall_recent_L4grid.data[key].append(int(t))
188
199
 
189
- def add_myspots_record(self, historic_data, new_alert_data, band, call, t, rp):
200
+ def _add_myspots_record(self, historic_data, new_alert_data, band, call, t, rp):
190
201
  self._update_new_alert(band, call, historic_data, new_alert_data)
191
202
  historic_data.setdefault(band, {})
192
203
  if call in historic_data[band]:
@@ -210,7 +221,7 @@ class History:
210
221
  result = call in new_alert_data[band]
211
222
  return result
212
223
 
213
- def is_hearing_me(self, band, call, since_seconds):
224
+ def is_hearing_me(self, band, call, since_seconds = 120):
214
225
  result = False
215
226
  if band in self.hearing_me.data:
216
227
  if call in self.hearing_me.data[band]:
@@ -220,7 +231,7 @@ class History:
220
231
  def count_activity(self):
221
232
  import numpy as np
222
233
  while True:
223
- time.sleep(5)
234
+ time_utils.sleep(5)
224
235
  self.home_activity = {}
225
236
  self.home_most_remotes = {}
226
237
  with self.lock:
@@ -231,29 +242,29 @@ class History:
231
242
  self.home_most_remotes[b] = [('Nobody',0), ('Nobody',0)]
232
243
 
233
244
  # keep only the remote spots that happened in the self.pskr_refresh_mins window
234
- for band_TxRx_homecall in self.band_TxRx_homecall_recent_l4grid.data:
235
- band_TxRx_homecall_recent_l4grid = self.band_TxRx_homecall_recent_l4grid.data[band_TxRx_homecall]
236
- band_TxRx_homecall_recent_l4grid = [t for t in band_TxRx_homecall_recent_l4grid if (time.time() - t) < 60*self.pskr_refresh_mins]
237
- self.band_TxRx_homecall_recent_l4grid.data[band_TxRx_homecall] = band_TxRx_homecall_recent_l4grid
245
+ for band_TxRx_homecall in self.band_TxRx_homecall_recent_L4grid.data:
246
+ band_TxRx_homecall_recent_L4grid = self.band_TxRx_homecall_recent_L4grid.data[band_TxRx_homecall]
247
+ band_TxRx_homecall_recent_L4grid = [t for t in band_TxRx_homecall_recent_L4grid if (time_utils.time() - t) < 60*self.pskr_refresh_mins]
248
+ self.band_TxRx_homecall_recent_L4grid.data[band_TxRx_homecall] = band_TxRx_homecall_recent_L4grid
238
249
 
239
250
  # count number of local Tx and Rx, and identify the local Tx and Rx with most remote spots
240
- for band_TxRx_homecall in self.band_TxRx_homecall_recent_l4grid.data:
241
- band_TxRx_homecall_recent_l4grid = self.band_TxRx_homecall_recent_l4grid.data[band_TxRx_homecall]
242
- if len(band_TxRx_homecall_recent_l4grid):
251
+ for band_TxRx_homecall in self.band_TxRx_homecall_recent_L4grid.data:
252
+ band_TxRx_homecall_recent_L4grid = self.band_TxRx_homecall_recent_L4grid.data[band_TxRx_homecall]
253
+ if len(band_TxRx_homecall_recent_L4grid):
243
254
  b, iTxRx, c = band_TxRx_homecall.split('_')
244
255
  iTxRx = int(iTxRx)
245
256
  self.home_activity.setdefault(b, [0, 0])
246
257
  self.home_activity[b][iTxRx] +=1
247
258
  self.home_most_remotes.setdefault(b, [('Nobody',0), ('Nobody',0)])
248
- nremotes = len(band_TxRx_homecall_recent_l4grid)
259
+ nremotes = len(band_TxRx_homecall_recent_L4grid)
249
260
  current_winner = self.home_most_remotes[b][iTxRx]
250
261
  if nremotes > current_winner[1]:
251
262
  if c != self.my_call:
252
263
  self.home_most_remotes[b][iTxRx] = (c, nremotes)
253
264
 
254
265
  def get_spot_counts(self, band, call):
255
- tx_reports = self.band_TxRx_homecall_recent_l4grid.data.get(f"{band}_0_{call}", [])
256
- rx_reports = self.band_TxRx_homecall_recent_l4grid.data.get(f"{band}_1_{call}", [])
266
+ tx_reports = self.band_TxRx_homecall_recent_L4grid.data.get(f"{band}_0_{call}", [])
267
+ rx_reports = self.band_TxRx_homecall_recent_L4grid.data.get(f"{band}_1_{call}", [])
257
268
  n_spotting = len(tx_reports) if tx_reports else 0
258
269
  n_spotted = len(rx_reports) if rx_reports else 0
259
270
  return n_spotted, n_spotting
@@ -262,12 +273,19 @@ class History:
262
273
  self.dist_brg_cache.setdefault(grid, grids_to_dist_brg(self.home_square, grid, units))
263
274
  return self.dist_brg_cache[grid]
264
275
 
265
- def get_geo_text(self, call, units):
276
+ def get_message_extra_info(self, their_call, current_band):
277
+ geo_text = self.get_geo_text(their_call)
278
+ wb_time = self.log_cache.get(their_call,'')
279
+ wb_text = f"wb: {time_utils.format_duration(time_utils.time() - float(wb_time))}" if wb_time else ''
280
+ hearing_me = '# ' if self.is_hearing_me(current_band, their_call) else ' '
281
+ return hearing_me, wb_text, geo_text
282
+
283
+ def get_geo_text(self, call):
266
284
  geo_text = ''
267
285
  grid = self.call_to_grid.data.get(call, False)
268
286
  if grid:
269
- loc = grid if units == 'grid' else self.get_dist_brg(grid, units)
270
- units_str = '' if units == 'grid' else ('km' if 'km' in units else 'mi')
287
+ loc = grid if self.geo_units == 'grid' else self.get_dist_brg(grid, self.geo_units)
288
+ units_str = '' if self.geo_units == 'grid' else ('km' if 'km' in self.geo_units else 'mi')
271
289
  geo_text = f"{int(loc[0]):5d}{units_str} {int(loc[1]):3d}°"
272
290
  return geo_text
273
291
 
@@ -285,25 +303,19 @@ class ADIF:
285
303
  ensure_file_exists(self.adif_log_file, header = "header <eoh>\n")
286
304
  self.cache = self._build_cache()
287
305
 
288
- def log(self, times, band_info, mStation, oStation, rpts):
289
- log_dict = {'call':oStation['c'], 'gridsquare':oStation['g'], 'mode':'FT8',
290
- 'operator':mStation['c'], 'station_callsign':mStation['c'], 'my_gridsquare':mStation['g'],
291
- 'rst_sent':rpts['sent'], 'rst_rcvd':rpts['rcvd'],
292
- 'qso_date':time.strftime("%Y%m%d", times['time_on']), 'qso_date_off':time.strftime("%Y%m%d", times['time_off']),
293
- 'time_on':time.strftime("%H%M%S", times['time_on']), 'time_off':time.strftime("%H%M%S", times['time_on']),
294
- 'band':band_info['b'], 'freq':band_info['fMHz']}
306
+ def log(self, log_dict):
295
307
  with open(self.adif_log_file,'a') as f:
296
308
  for k, v in log_dict.items():
297
309
  v = str(v)
298
310
  f.write(f"<{k}:{len(v)}>{v} ")
299
311
  f.write(f"<eor>\n")
300
312
  cbm = log_dict['call'] + "_" + log_dict['band'] + "_FT8"
301
- tm = time.time()
313
+ tm = time_utils.time()
302
314
  self.cache[log_dict['call']] = tm
303
315
  self.cache[cbm] = tm
304
316
 
305
317
  def _build_cache(self):
306
- import calendar
318
+ import calendar, time
307
319
  def parse(rec, field):
308
320
  p = rec.find(field)
309
321
  if p<0:
@@ -318,7 +330,7 @@ class ADIF:
318
330
  with open(self.adif_log_file, 'r') as f:
319
331
  for l in f.readlines():
320
332
  if parse(l, 'mode') == "FT8":
321
- c, b, d, t = parse(l, 'call'), parse(l, 'band'), parse(l, 'qso_date'), parse(l, 'time_on')
333
+ c, b, d, t = parse(l, 'call:'), parse(l, 'band'), parse(l, 'qso_date'), parse(l, 'time_on')
322
334
  if c and b and d and t:
323
335
  time_tuple = time.strptime(d+t, "%Y%m%d%H%M%S")
324
336
  tm = calendar.timegm(time_tuple)
@@ -0,0 +1,298 @@
1
+ import numpy as np
2
+ import matplotlib.pyplot as plt
3
+ from matplotlib import rcParams
4
+ import queue
5
+ from matplotlib.widgets import Button
6
+ from PyFT8.time_utils import time_utils
7
+
8
+ rcParams['toolbar'] = 'None'
9
+ MAIN_TEXT_COLOR = '#f0f9fa'
10
+ TEXT_BACKGROUND_COLOR = '#2a2b2b'
11
+ INFO_TEXT_COLOR = 'white'
12
+ BUTTONCOLOR = 'grey'
13
+ HOVERCOLOR = 'darkgreen'
14
+ ACTIVE_BUTTON_COLOR = 'cyan'
15
+ INACTIVE_BUTTON_COLOR = '#edeef0'
16
+ MAX_FONT_SIZE_MAIN = 10
17
+ L = {'pmargin':0.04, 'sidebar_width': 0.17, 'banner_height':0.1, 'vsep1':0.01, 'hsep1':0.02}
18
+
19
+
20
+ class ButtonBox:
21
+ def __init__(self, fig, _reset_axis, box, btn_pc = 30, onclick = None, clickargs=None, btn_text = ' ', info_text = ' '):
22
+ self._reset_axis = _reset_axis
23
+ self.clickargs = clickargs
24
+ btnbox, infobox = box.copy(), box.copy()
25
+ btnbox[2] = box[2] * btn_pc /100
26
+ infobox[2] = box[2] * (100-btn_pc) /100
27
+ infobox[0] = box[0] + box[2] * (btn_pc /100)
28
+ self.btn_axs = fig.add_axes(btnbox)
29
+ self.btn_widg = Button(self.btn_axs, btn_text, color = BUTTONCOLOR, hovercolor = HOVERCOLOR)
30
+ self.btn_widg.on_clicked(lambda x: onclick(clickargs))
31
+ self.info_axs = fig.add_axes(infobox)
32
+ self.info_text_inst = self.info_axs.text(0.03, 0.5, info_text, color = INFO_TEXT_COLOR, verticalalignment = 'center', clip_on = True)
33
+ self.set_info_text(info_text)
34
+ self.state_is_active = None
35
+ self.set_state(False)
36
+
37
+ def set_state(self, is_active: bool):
38
+ if is_active != self.state_is_active:
39
+ self.state_is_active = is_active
40
+ color = ACTIVE_BUTTON_COLOR if is_active else INACTIVE_BUTTON_COLOR
41
+ self.btn_widg.label.set_color(color)
42
+ self.btn_axs.draw_artist(self.btn_widg.label)
43
+ if self.info_text_inst:
44
+ self.info_text_inst.set_color(color)
45
+ self.info_axs.draw_artist(self.info_text_inst)
46
+
47
+ def set_info_text(self, info_text):
48
+ self._reset_axis(self.info_axs)
49
+ self.info_text_inst.set_text(info_text)
50
+ self.info_axs.draw_artist(self.info_text_inst)
51
+
52
+
53
+ MESSAGE_TYPES = {'generic':{'bg':'blue', 'fg':'white', 'alpha':0.5}, 'CQ':{'bg':'green', 'fg':'white', 'alpha':0.8},
54
+ 'from_me': {'bg':'yellow', 'fg':'black', 'alpha':0.95}, 'to_me':{'bg':'red', 'fg':'white', 'alpha':0.9}}
55
+ class Msg_box:
56
+ def __init__(self, fig, ax, w, h, onclick):
57
+ from matplotlib.patches import Rectangle
58
+ self.onclick = onclick
59
+ self.fig, self.ax = fig, ax
60
+ rect = Rectangle((0, 0), w, h, edgecolor='lime', lw=2)
61
+ self.patch = self.ax.add_patch(rect)
62
+ self.text_inst = self.ax.text(0, 0, '', fontsize='small', fontweight = 'bold' )
63
+ self.cid = fig.canvas.mpl_connect('button_press_event', self._onclick)
64
+ self.active = False
65
+
66
+ def set_properties(self, x, y, message):
67
+ self.patch.set_xy((x, y))
68
+ self.text_inst.set_position((x, y+1))
69
+ self.text_inst.set_text(message['display_text'])
70
+ message_type_params = MESSAGE_TYPES[message['message_type']]
71
+ self.text_inst.set_color(message_type_params['fg'])
72
+ self.patch.set_facecolor(message_type_params['bg'])
73
+ self.patch.set_alpha(message_type_params['alpha'])
74
+ self.message = message
75
+ self.cycle = message['their_tx_cycle']
76
+ self.active = True
77
+
78
+ def _onclick(self, event):
79
+ if self.active:
80
+ b, _ = self.patch.contains(event)
81
+ if(b):
82
+ self.onclick({'action': 'MESSAGE_CLICK', 'message':self.message})
83
+
84
+ class Gui:
85
+ def __init__(self, message_broker, rig_control, console_print, configured_bands, hearing_me_since_mins = 5):
86
+ self.hearing_me_since_mins = hearing_me_since_mins
87
+ self.waterfall_data = message_broker.waterfall_data
88
+ self.history = message_broker.history
89
+ self.qso_manager = None
90
+ self.configured_bands = configured_bands
91
+ self.console_print = console_print
92
+ self.myCall, self.myGrid = message_broker.myCall, message_broker.myGrid
93
+ self.band_info = {'current_band': None, 'fMHz':0, 'time_set':0}
94
+
95
+ self.plt = plt
96
+ self.fig = plt.figure(figsize = (10,10), facecolor=(.18, .71, .71, 0.4))
97
+ self.fig.canvas.manager.set_window_title('PyFT8 by G1OJS')
98
+ self.wf_top = 1-L['pmargin']-L['banner_height']-L['vsep1']
99
+ self.wf_left = L['pmargin']+L['sidebar_width']+L['hsep1']
100
+
101
+ self.ax_wf = self.fig.add_axes([self.wf_left, L['pmargin'], 1-self.wf_left-L['pmargin'], self.wf_top-L['pmargin']])
102
+ self.ax_wf.set_xticks([])
103
+ self.ax_wf.set_yticks([])
104
+
105
+ self.image = self.ax_wf.imshow(self.waterfall_data['data'],vmax=120,vmin=90,origin='lower',interpolation='none', aspect = 'auto')
106
+ self.ax_ss = self.fig.add_axes([L['pmargin'], self.wf_top+L['vsep1'], L['sidebar_width'], L['banner_height']])
107
+ self.ax_cs = self.fig.add_axes([self.wf_left, self.wf_top+L['vsep1'], 1-self.wf_left-L['pmargin'], L['banner_height']])
108
+
109
+ self.console_rows = [('','white'),('','white'),('','white'),('','white'),('','white')]
110
+ self.hearing_page = 0
111
+ self.active_msg_boxes = []
112
+ self.inactive_msg_boxes = []
113
+ self.button_boxes = []
114
+
115
+ bh, bs = 0.02, 0.002
116
+ bb = ButtonBox(self.fig, self._reset_axis, [L['pmargin'], self.wf_top - (len(self.button_boxes)+1) * bh + bs, L['sidebar_width'], bh-bs], btn_pc = 100,
117
+ btn_text = "CQ", onclick = self._on_click_local, clickargs = {'action':'CQ'})
118
+ self.button_boxes.append(bb)
119
+ bb = ButtonBox(self.fig, self._reset_axis, [L['pmargin'], self.wf_top - (len(self.button_boxes)+1) * bh + bs, L['sidebar_width'], bh-bs], btn_pc = 100,
120
+ btn_text = "Repeat last", onclick = self._on_click_local, clickargs = {'action':'RPT_LAST'})
121
+ self.button_boxes.append(bb)
122
+ bb = ButtonBox(self.fig, self._reset_axis, [L['pmargin'], self.wf_top - (len(self.button_boxes)+1) * bh + bs, L['sidebar_width'], bh-bs], btn_pc = 100,
123
+ btn_text = "Tx off", onclick = self._on_click_local, clickargs = {'action':'TX_OFF'})
124
+ self.button_boxes.append(bb)
125
+ for band_info in self.configured_bands:
126
+ band, fMHz = band_info['band'], band_info['fMHz']
127
+ bb = ButtonBox(self.fig, self._reset_axis, [L['pmargin'], self.wf_top - (len(self.button_boxes)+1) * bh + bs, L['sidebar_width'], bh-bs], btn_pc = 30,
128
+ btn_text = band, onclick = self._on_click_local, clickargs = {'action':'SET_BAND', 'band':band, 'fMHz':fMHz})
129
+ self.button_boxes.append(bb)
130
+
131
+ hm_top = self.wf_top - (len(self.button_boxes)+2) * bh + bs - L['vsep1']
132
+ self.ax_hm = self.fig.add_axes([L['pmargin'], L['pmargin'], L['sidebar_width'], hm_top])
133
+
134
+ self.tlast = 0
135
+ self.band_info = {'current_band': None, 'fMHz':0, 'time_set':0}
136
+ self._refresh_hearing()
137
+ self._refresh_band_buttons()
138
+ self._refresh_square_stats()
139
+
140
+ def get_band_info(self):
141
+ return self.band_info
142
+
143
+ def register_qso_manager(self, qsm):
144
+ self.qso_manager = qsm
145
+
146
+ def set_bandstats_title(self, txt):
147
+ self.ax_ss.set_title(txt, fontsize = 10)
148
+
149
+ def update_console(self, text, color, nlines = 5):
150
+ ax = self.ax_cs
151
+ bbox = ax.get_window_extent().transformed(self.fig.dpi_scale_trans.inverted())
152
+ rowheight = bbox.height * self.fig.dpi / nlines
153
+ fontsize = np.min([0.5 * rowheight, MAX_FONT_SIZE_MAIN])
154
+ self._reset_axis(ax)
155
+ self.console_rows[1:] = self.console_rows[:-1]
156
+ self.console_rows[0] = (text, color)
157
+ for i, rw in enumerate(self.console_rows):
158
+ ax.draw_artist(self._text_row(ax, 0.03, .03+0.9*i/len(self.console_rows), rw[0], color = rw[1], fontsize = fontsize))
159
+
160
+ def monitor_waterfall(self):
161
+ last_ptr = 0
162
+ self.plt.show(block = False)
163
+ t0 = time_utils.time()
164
+ while True:
165
+ time_utils.sleep(0.05)
166
+ self.image.set_data(self.waterfall_data['data'])
167
+ self.ax_wf.draw_artist(self.image)
168
+ for mb in self.active_msg_boxes:
169
+ self.ax_wf.draw_artist(mb.patch)
170
+ self.ax_wf.draw_artist(mb.text_inst)
171
+ self.fig.canvas.update()
172
+ self.fig.canvas.flush_events()
173
+ t = time_utils.time()
174
+ #print(f"{t - t0:6.3f}")
175
+ t0 = t
176
+
177
+ def after_search(self, curr_cycle):
178
+ self._refresh_hearing()
179
+ self._clear_msg_boxes(curr_cycle)
180
+ self._refresh_band_buttons()
181
+ self._refresh_square_stats()
182
+
183
+ def display_message(self, message):
184
+ x = int(message['t0'] / self.waterfall_data['dt'] + message['their_tx_cycle'] * self.waterfall_data['pixels_per_cycle'])
185
+ y = int(message['fHz'] / self.waterfall_data['df'])
186
+ mb = self._get_msg_box()
187
+ self.active_msg_boxes.append(mb)
188
+ mb.set_properties(x, y, message)
189
+
190
+ def _reset_axis(self, ax):
191
+ from matplotlib.patches import Rectangle
192
+ rect = Rectangle((0, 0), 1, 1, facecolor = TEXT_BACKGROUND_COLOR)
193
+ ax.draw_artist(ax.add_patch(rect))
194
+ ax.set_xticks([])
195
+ ax.set_yticks([])
196
+
197
+ def _on_click_local(self, clickargs):
198
+ btn_action = clickargs['action']
199
+ if(btn_action == 'SET_BAND'):
200
+ current_band, freqMHz = clickargs['band'], clickargs['fMHz']
201
+ self.band_info = {'current_band':current_band, 'fMHz':freqMHz, 'time_set':time_utils.time()}
202
+ self.console_print(f"[PyFT8] Set band: {self.band_info['current_band']} {self.band_info['fMHz']}")
203
+ self._refresh_hearing()
204
+ for cyc in range(2):
205
+ self._clear_msg_boxes(cyc)
206
+ self._refresh_band_buttons()
207
+ self._refresh_square_stats()
208
+ if clickargs['action'] == "MESSAGE_CLICK":
209
+ m = clickargs['message']
210
+ m_string = f"{m['hail']} {m['their_call']} {m['grid_rpt']}"
211
+ self.console_print(f"[GUI] Clicked on message '{m_string}'")
212
+ self.qso_manager.on_click(clickargs)
213
+
214
+ def _clear_msg_boxes(self, curr_cycle):
215
+ to_remove = [mb for mb in self.active_msg_boxes if mb.cycle == curr_cycle]
216
+ for mb in to_remove:
217
+ mb.active = False
218
+ self.inactive_msg_boxes.append(mb)
219
+ self.active_msg_boxes.remove(mb)
220
+
221
+ def _get_msg_box(self):
222
+ if len(self.inactive_msg_boxes) == 0:
223
+ mb = Msg_box(self.fig, self.ax_wf, self.waterfall_data['sig_w'], self.waterfall_data['sig_h'], onclick = self._on_click_local)
224
+ else:
225
+ mb = self.inactive_msg_boxes.pop()
226
+ return mb
227
+
228
+ def _text_row(self, ax, x, y, text = '', color = 'white', **args):
229
+ art = ax.text(x, y, text, color = color)
230
+ art.set_fontfamily('monospace')
231
+ return art
232
+
233
+ def _refresh_square_stats(self):
234
+ ax = self.ax_ss
235
+ self._reset_axis(ax)
236
+ ax.draw_artist(ax.text(-0.2,0.75,'Tx'))
237
+ ax.draw_artist(ax.text(-0.2,0.25,'Rx'))
238
+ current_band = self.band_info['current_band']
239
+ if current_band is not None and self.history is not None:
240
+ if current_band in self.history.home_most_remotes:
241
+ tx_lead, rx_lead = self.history.home_most_remotes[current_band]
242
+ n_spotted, n_spotting = self.history.get_spot_counts(current_band, self.myCall)
243
+ ax.draw_artist(self._text_row(ax, 0.03, .75, f"{self.myCall:<7} {tx_lead[0]:<7}", color = '#ff756b' ))
244
+ ax.draw_artist(self._text_row(ax, 0.03, .6, f"{n_spotting:<7} {tx_lead[1]:<7}", color = '#ff756b' ))
245
+ ax.draw_artist(self._text_row(ax, 0.03, .25, f"{self.myCall:<7} {rx_lead[0]:<7}", color = '#b6f0c6' ))
246
+ ax.draw_artist(self._text_row(ax, 0.03, .1, f"{n_spotted:<7} {rx_lead[1]:<7}", color = '#b6f0c6' ))
247
+
248
+ def _refresh_band_buttons(self):
249
+ current_band = self.band_info['current_band']
250
+ grd = self.myGrid[:4]
251
+ for bb in self.button_boxes:
252
+ button_band = bb.clickargs.get('band',None)
253
+ if button_band is not None:
254
+ bb.set_state(button_band == current_band)
255
+ if button_band in self.history.home_activity:
256
+ cnts = self.history.home_activity[button_band]
257
+ bb.set_info_text(f"{cnts[0]}Tx, {cnts[1]}Rx")
258
+ self.fig.canvas.update()
259
+ self.fig.canvas.flush_events()
260
+
261
+ def _refresh_hearing(self):
262
+ current_band = self.band_info['current_band']
263
+ ax = self.ax_hm
264
+ self._reset_axis(ax)
265
+ line_height = 0.03
266
+ bbox = ax.get_window_extent().transformed(self.fig.dpi_scale_trans.inverted())
267
+ nlines = int(0.9 / line_height)
268
+ row_artists = []
269
+ if current_band is not None and self.history is not None:
270
+ historic_data = self.history.hearing_me.data if self.hearing_page == 1 else self.history.heard_by_me.data
271
+ new_calls_data = self.history.hearing_me_new if self.hearing_page == 1 else self.history.heard_by_me_new
272
+ timewindow_str = f"<{self.hearing_me_since_mins:.0f} mins"
273
+ title_txt = f"Hearing me {timewindow_str}" if self.hearing_page==1 else f"Heard by me {timewindow_str}"
274
+ row_artists.append(self._text_row(ax, 0.03, 1 - line_height, title_txt, color = 'white'))
275
+ if current_band in historic_data:
276
+ tnow = time_utils.time()
277
+ band_rpts = historic_data[current_band]
278
+ calls_now = [call for call in band_rpts if (tnow - band_rpts[call]['t']) < 60*self.hearing_me_since_mins]
279
+ calls_now.sort(key = lambda c: band_rpts[c]['t'], reverse = True)
280
+ subtitle_txt = f"{len(calls_now)}/{len(band_rpts)} now/ever"
281
+ row_artists.append(self._text_row(ax, 0.03, 1 - 2*line_height, subtitle_txt, color = 'white'))
282
+ for i, remote_call in enumerate(calls_now[:nlines - 3]):
283
+ rpt = band_rpts[remote_call]
284
+ row_txt = f"{remote_call:<7} {self.history.get_geo_text(remote_call)}"
285
+ color = 'white' if self.history.is_in_new_alert(current_band, remote_call, new_calls_data) else 'lime'
286
+ row_artists.append(self._text_row(ax, 0.03, 1 - line_height*(i+3.2), row_txt, color = color))
287
+ for row_art in row_artists:
288
+ ax.draw_artist(row_art)
289
+ self.fig.canvas.update()
290
+ self.fig.canvas.flush_events()
291
+ self.hearing_page = (self.hearing_page +1 )%2
292
+
293
+
294
+
295
+
296
+
297
+
298
+