PyFT8 3.0.2__tar.gz → 3.3.1__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 (43) hide show
  1. {pyft8-3.0.2 → pyft8-3.3.1}/PKG-INFO +5 -3
  2. {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8/databases.py +59 -63
  3. pyft8-3.3.1/PyFT8/gui.py +310 -0
  4. pyft8-3.3.1/PyFT8/osd.py +88 -0
  5. pyft8-3.3.1/PyFT8/pyft8.py +219 -0
  6. pyft8-3.3.1/PyFT8/qso_manager.py +119 -0
  7. pyft8-3.3.1/PyFT8/receiver.py +613 -0
  8. pyft8-3.3.1/PyFT8/rigctrl.py +103 -0
  9. {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8/time_utils.py +20 -7
  10. {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8/transmitter.py +61 -55
  11. {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8.egg-info/PKG-INFO +5 -3
  12. {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8.egg-info/SOURCES.txt +14 -5
  13. {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8.egg-info/requires.txt +3 -0
  14. {pyft8-3.0.2 → pyft8-3.3.1}/README.md +1 -2
  15. {pyft8-3.0.2 → pyft8-3.3.1}/pyproject.toml +5 -2
  16. pyft8-3.3.1/tests/misc/animation.py +36 -0
  17. pyft8-3.3.1/tests/misc/bits_loopback.py +204 -0
  18. pyft8-3.3.1/tests/misc/bits_loopback_short.py +169 -0
  19. pyft8-3.3.1/tests/misc/symbols_loopback.py +81 -0
  20. {pyft8-3.0.2/tests/dev → pyft8-3.3.1/tests/misc}/test_loopback_performance.py +2 -10
  21. pyft8-3.3.1/tests/pipeline/Fine tuning compare IOS_PC.py +373 -0
  22. pyft8-3.3.1/tests/pipeline/Targetted tests.py +396 -0
  23. pyft8-3.3.1/tests/pipeline/test_01.wav +0 -0
  24. {pyft8-3.0.2 → pyft8-3.3.1}/tests/plot_baseline.py +10 -9
  25. pyft8-3.3.1/tests/spare.py +52 -0
  26. {pyft8-3.0.2 → pyft8-3.3.1}/tests/test_batch_and_live.py +60 -49
  27. pyft8-3.0.2/PyFT8/gui.py +0 -227
  28. pyft8-3.0.2/PyFT8/pyft8.py +0 -399
  29. pyft8-3.0.2/PyFT8/receiver.py +0 -422
  30. pyft8-3.0.2/PyFT8/rigctrl.py +0 -43
  31. pyft8-3.0.2/tests/spare.py +0 -18
  32. {pyft8-3.0.2 → pyft8-3.3.1}/LICENSE +0 -0
  33. {pyft8-3.0.2 → pyft8-3.3.1}/MANIFEST.in +0 -0
  34. {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8/__init__.py +0 -0
  35. {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8/pskreporter.py +0 -0
  36. {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8.egg-info/dependency_links.txt +0 -0
  37. {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8.egg-info/entry_points.txt +0 -0
  38. {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8.egg-info/top_level.txt +0 -0
  39. {pyft8-3.0.2 → pyft8-3.3.1}/setup.cfg +0 -0
  40. {pyft8-3.0.2/tests/dev → pyft8-3.3.1/tests/misc}/CQ AAAA.py +0 -0
  41. {pyft8-3.0.2/tests/dev → pyft8-3.3.1/tests/misc}/osd.py +0 -0
  42. {pyft8-3.0.2/tests/dev → pyft8-3.3.1/tests/misc}/test_generate_wav.py +0 -0
  43. {pyft8-3.0.2/tests/dev → pyft8-3.3.1/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.0.2
3
+ Version: 3.3.1
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/)
@@ -112,8 +115,7 @@ upload = Y
112
115
 
113
116
  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
117
 
115
- <img width="640" height="480" alt="performance snapshot" src="https://github.com/user-attachments/assets/b62221ec-2d74-4fbc-9c61-34b2bbc0e963" />
116
-
118
+ <img width="900" height="898" alt="batch test performance" src="https://github.com/user-attachments/assets/513d4121-5483-4e7b-81df-785dc4db3d37" />
117
119
 
118
120
  ## Limitations
119
121
  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, time
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):
@@ -71,46 +72,44 @@ class DiskDict:
71
72
  pass
72
73
 
73
74
  def save(self):
74
- with self.lock:
75
- with open(self.file, "w") as f:
76
- try:
77
- json.dump(self.data, f)
78
- except:
79
- pass
75
+ if self.data != {}:
76
+ with self.lock:
77
+ with open(f"{self.file}_tmp", "w") as f:
78
+ try:
79
+ json.dump(self.data, f)
80
+ except:
81
+ return
82
+ try:
83
+ os.replace(f"{self.file}_tmp", self.file)
84
+ except:
85
+ return
80
86
 
81
87
  class History:
82
- 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, pskr_refresh_mins):
83
89
  self.pskr_refresh_mins = pskr_refresh_mins
84
- self.config_folder = config_folder
90
+ self.log_cache = None
85
91
  self.my_call = my_call
86
92
  self.home_square = home_square
87
93
  self.home_square_lev4 = home_square[:4]
88
94
  self.freqs_to_bands = {}
89
95
  self.dist_brg_cache = {}
90
- self.hearing_me = DiskDict(f"{self.config_folder}/hearing_me.json", 3) # all-time record of hearing me
91
- self.heard_by_me = DiskDict(f"{self.config_folder}/heard_by_me.json", 5) # all-time record of heard by me
92
96
  self.hearing_me_new = {}
93
97
  self.heard_by_me_new = {}
94
- self.call_to_grid = DiskDict(f"{self.config_folder}/call_to_grid.json", 7) # all time cache call -> fine locator
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
96
98
  self.home_activity = {}
97
99
  self.home_most_remotes = {}
98
100
  self.lock = threading.Lock()
99
- if parse_all_file:
100
- self.load_hearing_heard_from_all_file(f"{self.config_folder}/ALL.txt")
101
- self.hearing_me.save()
102
- self.heard_by_me.save()
103
- print("All file parsed and saved to hearing_me / heard_by_me files")
104
- else:
105
- mqtt = PSKR_MQTT_listener(self.home_square_lev4, self.add_mqtt_spot)
106
- threading.Thread(target = self.count_activity, daemon = True).start()
101
+ self.all_file = f"{config_folder}/ALL.txt"
102
+ self.hearing_me = DiskDict(f"{config_folder}/hearing_me.json", 3) # all-time record of hearing me
103
+ self.heard_by_me = DiskDict(f"{config_folder}/heard_by_me.json", 5) # all-time record of heard by me
104
+ self.call_to_grid = DiskDict(f"{config_folder}/call_to_grid.json", 7) # all time cache call -> fine locator
105
+ 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
107
106
 
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):
107
+ def start_collect_new(self):
108
+ mqtt = PSKR_MQTT_listener(self.home_square_lev4, self.add_mqtt_spot)
109
+ threading.Thread(target = self.count_activity, daemon = True).start()
110
+
111
+ def incorporate_log_data(self, log_cache):
112
+ self.log_cache = log_cache
114
113
  for key in log_cache:
115
114
  key_parts = key.split('_')
116
115
  if len(key_parts) > 1:
@@ -122,9 +121,13 @@ class History:
122
121
  else:
123
122
  print(m)
124
123
 
125
- def load_hearing_heard_from_all_file(self, all_file):
126
- recs = self.parse_all_txt(all_file)
127
- if not any(recs): return
124
+ def load_hearing_heard_from_all_file(self, bands):
125
+ recs = self.parse_all_txt()
126
+ if not any(recs):
127
+ return
128
+ for b in bands:
129
+ f = float(bands[b])
130
+ self.freqs_to_bands[round(f,1)] = b
128
131
  for r in recs:
129
132
  if r['md'] == 'FT8':
130
133
  band = self.freqs_to_bands.get(round(r['fMHz'], 1), None)
@@ -135,10 +138,10 @@ class History:
135
138
  data = self.heard_by_me.data if TxRx == 'Rx' else self.hearing_me.data
136
139
  self.add_myspots_record(data, None, band, call, 0, 0)
137
140
 
138
- def parse_all_txt(self, all_file):
141
+ def parse_all_txt(self):
139
142
  rows, recs = None, []
140
- if os.path.exists(all_file):
141
- with open(all_file, 'r') as f:
143
+ if os.path.exists(self.all_file):
144
+ with open(self.all_file, 'r') as f:
142
145
  rows = f.readlines()
143
146
  if rows is not None:
144
147
  for r in rows:
@@ -152,14 +155,13 @@ class History:
152
155
  return recs
153
156
 
154
157
  def write_all_txt_row(self, cyclestart_string, fMHz, TxRx, mode, snr, dt, fHz, msg):
155
- all_file = f"{self.config_folder}/ALL.txt"
156
- filemode = 'w' if not os.path.exists(all_file) else 'a'
158
+ filemode = 'w' if not os.path.exists(self.all_file) else 'a'
157
159
  row = f"{cyclestart_string} {fMHz:8.3f} {TxRx} {mode} {snr:+03d} {dt:4.1f} {fHz:4.0f} {msg}"
158
- with open(all_file, filemode) as f:
160
+ with open(self.all_file, filemode) as f:
159
161
  f.write(f"{row}\n")
160
162
 
161
163
  def add_mqtt_spot(self, d):
162
- tnow = int(time.time())
164
+ tnow = int(time_utils.time())
163
165
  sc, rc = (d['sc'], d['sl']), (d['rc'], d['rl'])
164
166
  for iTxRx, call_grid in enumerate([sc, rc]):
165
167
  call, grid = call_grid
@@ -178,14 +180,14 @@ class History:
178
180
  self.call_to_grid.data[call] = grid
179
181
 
180
182
  def add_homespots_record(self, key, t):
181
- self.band_TxRx_homecall_recent_l4grid.data.setdefault(key, [])
182
- self.band_TxRx_homecall_recent_l4grid.data[key].append(int(t))
183
+ self.band_TxRx_homecall_recent_L4grid.data.setdefault(key, [])
184
+ self.band_TxRx_homecall_recent_L4grid.data[key].append(int(t))
183
185
 
184
186
  def add_myspots_record(self, historic_data, new_alert_data, band, call, t, rp):
185
187
  self._update_new_alert(band, call, historic_data, new_alert_data)
186
188
  historic_data.setdefault(band, {})
187
189
  if call in historic_data[band]:
188
- if t < historic_data[band][call]['t']:
190
+ if int(t) < int(historic_data[band][call]['t']):
189
191
  return
190
192
  historic_data[band][call] = {'t': int(t),'rp':int(rp)}
191
193
 
@@ -205,7 +207,7 @@ class History:
205
207
  result = call in new_alert_data[band]
206
208
  return result
207
209
 
208
- def is_hearing_me(self, band, call, since_seconds):
210
+ def is_hearing_me(self, band, call, since_seconds = 120):
209
211
  result = False
210
212
  if band in self.hearing_me.data:
211
213
  if call in self.hearing_me.data[band]:
@@ -215,7 +217,7 @@ class History:
215
217
  def count_activity(self):
216
218
  import numpy as np
217
219
  while True:
218
- time.sleep(5)
220
+ time_utils.sleep(5)
219
221
  self.home_activity = {}
220
222
  self.home_most_remotes = {}
221
223
  with self.lock:
@@ -226,29 +228,29 @@ class History:
226
228
  self.home_most_remotes[b] = [('Nobody',0), ('Nobody',0)]
227
229
 
228
230
  # keep only the remote spots that happened in the self.pskr_refresh_mins window
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
231
+ for band_TxRx_homecall in self.band_TxRx_homecall_recent_L4grid.data:
232
+ band_TxRx_homecall_recent_L4grid = self.band_TxRx_homecall_recent_L4grid.data[band_TxRx_homecall]
233
+ band_TxRx_homecall_recent_L4grid = [t for t in band_TxRx_homecall_recent_L4grid if (time_utils.time() - t) < 60*self.pskr_refresh_mins]
234
+ self.band_TxRx_homecall_recent_L4grid.data[band_TxRx_homecall] = band_TxRx_homecall_recent_L4grid
233
235
 
234
236
  # count number of local Tx and Rx, and identify the local Tx and Rx with most remote spots
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):
237
+ for band_TxRx_homecall in self.band_TxRx_homecall_recent_L4grid.data:
238
+ band_TxRx_homecall_recent_L4grid = self.band_TxRx_homecall_recent_L4grid.data[band_TxRx_homecall]
239
+ if len(band_TxRx_homecall_recent_L4grid):
238
240
  b, iTxRx, c = band_TxRx_homecall.split('_')
239
241
  iTxRx = int(iTxRx)
240
242
  self.home_activity.setdefault(b, [0, 0])
241
243
  self.home_activity[b][iTxRx] +=1
242
244
  self.home_most_remotes.setdefault(b, [('Nobody',0), ('Nobody',0)])
243
- nremotes = len(band_TxRx_homecall_recent_l4grid)
245
+ nremotes = len(band_TxRx_homecall_recent_L4grid)
244
246
  current_winner = self.home_most_remotes[b][iTxRx]
245
247
  if nremotes > current_winner[1]:
246
248
  if c != self.my_call:
247
249
  self.home_most_remotes[b][iTxRx] = (c, nremotes)
248
250
 
249
251
  def get_spot_counts(self, band, 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}", [])
252
+ tx_reports = self.band_TxRx_homecall_recent_L4grid.data.get(f"{band}_0_{call}", [])
253
+ rx_reports = self.band_TxRx_homecall_recent_L4grid.data.get(f"{band}_1_{call}", [])
252
254
  n_spotting = len(tx_reports) if tx_reports else 0
253
255
  n_spotted = len(rx_reports) if rx_reports else 0
254
256
  return n_spotted, n_spotting
@@ -280,20 +282,14 @@ class ADIF:
280
282
  ensure_file_exists(self.adif_log_file, header = "header <eoh>\n")
281
283
  self.cache = self._build_cache()
282
284
 
283
- def log(self, times, band_info, mStation, oStation, rpts):
284
- log_dict = {'call':oStation['c'], 'gridsquare':oStation['g'], 'mode':'FT8',
285
- 'operator':mStation['c'], 'station_callsign':mStation['c'], 'my_gridsquare':mStation['g'],
286
- 'rst_sent':rpts['sent'], 'rst_rcvd':rpts['rcvd'],
287
- 'qso_date':time.strftime("%Y%m%d", times['time_on']), 'qso_date_off':time.strftime("%Y%m%d", times['time_off']),
288
- 'time_on':time.strftime("%H%M%S", times['time_on']), 'time_off':time.strftime("%H%M%S", times['time_on']),
289
- 'band':band_info['b'], 'freq':band_info['fMHz']}
285
+ def log(self, log_dict):
290
286
  with open(self.adif_log_file,'a') as f:
291
287
  for k, v in log_dict.items():
292
288
  v = str(v)
293
289
  f.write(f"<{k}:{len(v)}>{v} ")
294
290
  f.write(f"<eor>\n")
295
291
  cbm = log_dict['call'] + "_" + log_dict['band'] + "_FT8"
296
- tm = time.time()
292
+ tm = time_utils.time()
297
293
  self.cache[log_dict['call']] = tm
298
294
  self.cache[cbm] = tm
299
295
 
@@ -313,7 +309,7 @@ class ADIF:
313
309
  with open(self.adif_log_file, 'r') as f:
314
310
  for l in f.readlines():
315
311
  if parse(l, 'mode') == "FT8":
316
- c, b, d, t = parse(l, 'call'), parse(l, 'band'), parse(l, 'qso_date'), parse(l, 'time_on')
312
+ c, b, d, t = parse(l, 'call:'), parse(l, 'band'), parse(l, 'qso_date'), parse(l, 'time_on')
317
313
  if c and b and d and t:
318
314
  time_tuple = time.strptime(d+t, "%Y%m%d%H%M%S")
319
315
  tm = calendar.timegm(time_tuple)
@@ -0,0 +1,310 @@
1
+ import numpy as np
2
+ import matplotlib.pyplot as plt
3
+ from matplotlib import rcParams
4
+ import queue
5
+ from matplotlib.animation import FuncAnimation
6
+ from matplotlib.widgets import Slider, Button
7
+ from PyFT8.time_utils import time_utils
8
+
9
+ rcParams['toolbar'] = 'None'
10
+ MAIN_TEXT_COLOR = '#f0f9fa'
11
+ TEXT_BACKGROUND_COLOR = '#2a2b2b'
12
+ INFO_TEXT_COLOR = 'white'
13
+ BUTTONCOLOR = 'grey'
14
+ HOVERCOLOR = 'darkgreen'
15
+ ACTIVE_BUTTON_COLOR = 'cyan'
16
+ INACTIVE_BUTTON_COLOR = '#edeef0'
17
+ MAX_FONT_SIZE_MAIN = 10
18
+ L = {'pmargin':0.04, 'sidebar_width': 0.17, 'banner_height':0.1, 'vsep1':0.01, 'hsep1':0.02}
19
+
20
+ # ================== WATERFALL ======================================================
21
+
22
+ class Scrollbox:
23
+ def __init__(self, fig, box, nlines = 5, monospace = False, default_text = '', fontsize = None):
24
+ self.fig = fig
25
+ self.ax = fig.add_axes(box)
26
+ self.default_text = default_text
27
+ bbox = self.ax.get_window_extent().transformed(fig.dpi_scale_trans.inverted())
28
+ self.fontsize = np.min([0.5 * bbox.height * fig.dpi / nlines, MAX_FONT_SIZE_MAIN]) if fontsize is None else fontsize
29
+ self.nlines = nlines
30
+ self.line_height = 0.9 / nlines
31
+ self.lines = []
32
+ self.lineartists = []
33
+ for i in range(self.nlines):
34
+ self.lineartists.append(self.ax.text(0.03,1 - self.line_height * (i+1),
35
+ '', color = MAIN_TEXT_COLOR, fontsize = self.fontsize, clip_on = True))
36
+ if monospace:
37
+ self.lineartists[-1].set_fontfamily('monospace')
38
+ self.ax.set_xticks([])
39
+ self.ax.set_yticks([])
40
+ self.ax.set_facecolor(TEXT_BACKGROUND_COLOR)
41
+
42
+ def scroll_print(self, text, color = MAIN_TEXT_COLOR):
43
+ self.lines = self.lines[-(self.nlines-1):]
44
+ self.lines.append({'text':text, 'color':color})
45
+ for i, line in enumerate(self.lines):
46
+ self.lineartists[i].set_text(line['text'])
47
+ self.lineartists[i].set_color(line['color'])
48
+
49
+ def clear(self):
50
+ self.lines = []
51
+ for i in range(self.nlines):
52
+ self.lineartists[i].set_text(self.default_text)
53
+
54
+ def list_print(self, lst, colors = None):
55
+ for i, txt in enumerate(lst[:self.nlines]):
56
+ if txt != self.lineartists[i].get_text():
57
+ self.lineartists[i].set_text(txt)
58
+ col = colors[i] if colors is not None and i < len(colors) else 'white'
59
+ self.lineartists[i].set_color(col)
60
+ for i in range(len(lst), self.nlines):
61
+ if self.lineartists[i].get_text() != '':
62
+ self.lineartists[i].set_text('')
63
+
64
+ class ButtonBox:
65
+ def __init__(self, fig, box, btn_pc = 30, onclick = None, clickargs=None, btn_label = ''):
66
+ btnbox, infobox = box.copy(), box.copy()
67
+ btnbox[2] = box[2] * btn_pc /100
68
+ infobox[2] = box[2] * (100-btn_pc) /100
69
+ infobox[0] = box[0] + box[2] * (btn_pc /100)
70
+ self.btn_axs = fig.add_axes(btnbox)
71
+ self.info_axs = fig.add_axes(infobox)
72
+ self.info_axs.set_xticks([])
73
+ self.info_axs.set_yticks([])
74
+ self.info_axs.set_facecolor(TEXT_BACKGROUND_COLOR)
75
+ self.label2 = self.info_axs.text(0.03, 0.5, '', color = INFO_TEXT_COLOR, verticalalignment = 'center', clip_on = True)
76
+ self.btn_widg = Button(self.btn_axs, btn_label, color = BUTTONCOLOR, hovercolor = HOVERCOLOR)
77
+ self.label = self.btn_widg.label
78
+ self.label.set_color(MAIN_TEXT_COLOR)
79
+ self.clickargs = clickargs
80
+ self.active = False
81
+ self.btn_widg.on_clicked(lambda x: onclick(clickargs))
82
+
83
+ def set_active(self, active: bool):
84
+ if self.active != active:
85
+ self.active = active
86
+ color = ACTIVE_BUTTON_COLOR if self.active else INACTIVE_BUTTON_COLOR
87
+ self.label.set_color(color)
88
+ self.label2.set_color(color)
89
+
90
+ def set_info_text(self, text):
91
+ if text != self.label2:
92
+ self.label2.set_text(text)
93
+
94
+ MESSAGE_TYPES = {'generic':{'bg':'blue', 'fg':'white'}, 'CQ':{'bg':'green', 'fg':'white'},'from_me': {'bg':'yellow', 'fg':'white'}, 'to_me':{'bg':'red', 'fg':'white'}}
95
+ class Msg_box:
96
+ def __init__(self, fig, ax, w, h, onclick):
97
+ from matplotlib.patches import Rectangle
98
+ self.onclick = onclick
99
+ self.fig, self.ax = fig, ax
100
+ rect = Rectangle((0, 0), w, h, alpha=0.5, edgecolor='lime', lw=1)
101
+ self.patch = self.ax.add_patch(rect)
102
+ self.text_inst = self.ax.text(0, 0, '', fontsize='small', fontweight = 'bold' )
103
+ self.cid = fig.canvas.mpl_connect('button_press_event', self._onclick)
104
+ self.visible = False
105
+
106
+ def set_properties(self, x, y, message):
107
+ self.patch.set_xy((x, y))
108
+ self.text_inst.set_position((x, y+1))
109
+ self.text_inst.set_text(message['display_text'])
110
+ message_type_params = MESSAGE_TYPES[message['message_type']]
111
+ self.text_inst.set_color(message_type_params['fg'])
112
+ self.patch.set_facecolor(message_type_params['bg'])
113
+ self.message = message
114
+ self.cycle = message['origin']['odd_even']
115
+ self.patch.set_visible(True)
116
+ self.text_inst.set_visible(True)
117
+ self.visible = True
118
+
119
+
120
+ def hide(self):
121
+ self.patch.set_visible(False)
122
+ self.text_inst.set_visible(False)
123
+ self.visible = False
124
+
125
+ def _onclick(self, event):
126
+ if self.visible:
127
+ b, _ = self.patch.contains(event)
128
+ if(b):
129
+ self.onclick({'action': 'MESSAGE_CLICK', 'message':self.message})
130
+
131
+ class Gui:
132
+ def __init__(self, config, rig, history, console_print, wf_data, hearing_me_since_mins = 5):
133
+ self.rig = rig
134
+ self.on_click = None
135
+ self.plt = plt
136
+ self.fig = plt.figure(figsize = (10,10), facecolor=(.18, .71, .71, 0.4))
137
+ self.fig.canvas.manager.set_window_title('PyFT8 by G1OJS')
138
+ self.config, self.history = config, history
139
+ self.console_print = console_print
140
+ self.wf_top = 1-L['pmargin']-L['banner_height']-L['vsep1']
141
+ self.wf_left = L['pmargin']+L['sidebar_width']+L['hsep1']
142
+ self.hearing_me_since_mins = hearing_me_since_mins
143
+ self.wf_data = wf_data
144
+ self.sidebars_last_update = 0
145
+ self.sidebars_page = 0
146
+ self._make_axes()
147
+ self.gui_queue = queue.Queue()
148
+ self.active_msg_boxes = []
149
+ self.inactive_msg_boxes = []
150
+ self.image = self.ax_wf.imshow(self.wf_data['data'],vmax=120,vmin=90,origin='lower',interpolation='none', aspect = 'auto')
151
+ self._make_buttons()
152
+ self.tlast = 0
153
+ self.band_info = {'current_band': None, 'fMHz':0, 'time_set':0}
154
+ self.sidebars_artists = [*[bb.label for bb in self.button_boxes], *[bb.label2 for bb in self.button_boxes],
155
+ *self.band_stats.lineartists, *self.console.lineartists, *self.hm.lineartists]
156
+
157
+ self.ani = FuncAnimation(self.fig, self._animate, interval = 100, frames=(100000), blit = False)
158
+
159
+ def register_onclick(self, on_click):
160
+ self.on_click = on_click
161
+
162
+ def get_band_info(self):
163
+ return self.band_info
164
+
165
+ def _animate(self, frames):
166
+ #t = time_utils.time()
167
+ #print(t - self.tlast)
168
+ #self.tlast = t
169
+
170
+ if self.gui_queue.empty():
171
+ self.image.set_data(self.wf_data['data'])
172
+ else:
173
+ t0 = time_utils.time()
174
+ while (not self.gui_queue.empty()) and (time_utils.time()-t0 < 0.1):
175
+ action, data = self.gui_queue.get()
176
+ if action == "clear_cycle":
177
+ curr_cycle = data
178
+ self._clear_msg_boxes(curr_cycle)
179
+ else:
180
+ x, y, message = data
181
+ mb = self._get_msg_box()
182
+ self.active_msg_boxes.append(mb)
183
+ mb.set_properties(x, y, message)
184
+
185
+ #p = [mb.patch for mb in self.active_msg_boxes]
186
+ #t = [mb.text_inst for mb in self.active_msg_boxes]
187
+
188
+ #return [self.image, *p, *t, *self.sidebars_artists]
189
+
190
+ def _on_click_local(self, clickargs):
191
+ btn_action = clickargs['action']
192
+ if(btn_action == 'SET_BAND'):
193
+ current_band, freqMHz = clickargs['band'], clickargs['freq']
194
+ self.band_info = {'current_band':current_band, 'fMHz':freqMHz, 'time_set':time_utils.time()}
195
+ self.rig.set_freq_Hz(int(1000000*float(self.band_info['fMHz'])))
196
+ self.console_print(f"[PyFT8] Set band: {self.band_info['current_band']} {self.band_info['fMHz']}")
197
+ if clickargs['action'] == "MESSAGE_CLICK":
198
+ self.console_print(f"[GUI] Clicked on message '{clickargs['message']['msg_tuple']}'")
199
+ self.on_click(clickargs)
200
+ self._refresh_sidebars()
201
+
202
+ def _clear_msg_boxes(self, curr_cycle):
203
+ self._refresh_sidebars()
204
+ to_hide = [mb for mb in self.active_msg_boxes if mb.cycle == curr_cycle]
205
+ for mb in to_hide:
206
+ self.inactive_msg_boxes.append(mb)
207
+ self.active_msg_boxes.remove(mb)
208
+ mb.hide()
209
+
210
+ def _get_msg_box(self):
211
+ if len(self.inactive_msg_boxes) == 0:
212
+ mb = Msg_box(self.fig, self.ax_wf, self.wf_data['sig_w'], self.wf_data['sig_h'], onclick = self._on_click_local)
213
+ else:
214
+ mb = self.inactive_msg_boxes.pop()
215
+ return mb
216
+
217
+ def _make_axes(self):
218
+ self.ax_wf = self.fig.add_axes([self.wf_left, L['pmargin'], 1-self.wf_left-L['pmargin'], self.wf_top-L['pmargin']])
219
+ self.ax_wf.set_xticks([])
220
+ self.ax_wf.set_yticks([])
221
+ self.band_stats = Scrollbox(self.fig, [L['pmargin'], self.wf_top+L['vsep1'], L['sidebar_width'], L['banner_height']], nlines = 4, monospace = True)
222
+ self.band_stats.ax.text(-0.2,0.75,'Tx')
223
+ self.band_stats.ax.text(-0.2,0.25,'Rx')
224
+ self.console = Scrollbox(self.fig, [self.wf_left, self.wf_top+L['vsep1'], 1-self.wf_left-L['pmargin'], L['banner_height']])
225
+
226
+ def _make_buttons(self):
227
+ self.button_boxes = []
228
+ bh, bs = 0.02, 0.002
229
+ bb = ButtonBox(self.fig, [L['pmargin'], self.wf_top - (len(self.button_boxes)+1) * bh + bs, L['sidebar_width'], bh-bs], btn_pc = 100,
230
+ btn_label = "CQ", onclick = self._on_click_local, clickargs = {'action':'CQ'})
231
+ self.button_boxes.append(bb)
232
+ bb = ButtonBox(self.fig, [L['pmargin'], self.wf_top - (len(self.button_boxes)+1) * bh + bs, L['sidebar_width'], bh-bs], btn_pc = 100,
233
+ btn_label = "Repeat last", onclick = self._on_click_local, clickargs = {'action':'RPT_LAST'})
234
+ self.button_boxes.append(bb)
235
+ bb = ButtonBox(self.fig, [L['pmargin'], self.wf_top - (len(self.button_boxes)+1) * bh + bs, L['sidebar_width'], bh-bs], btn_pc = 100,
236
+ btn_label = "Tx off", onclick = self._on_click_local, clickargs = {'action':'TX_OFF'})
237
+ self.button_boxes.append(bb)
238
+ for band, freq in self.config['bands'].items():
239
+ bb = ButtonBox(self.fig, [L['pmargin'], self.wf_top - (len(self.button_boxes)+1) * bh + bs, L['sidebar_width'], bh-bs], btn_pc = 30,
240
+ btn_label = band, onclick = self._on_click_local, clickargs = {'action':'SET_BAND', 'band':band, 'freq':freq})
241
+ self.button_boxes.append(bb)
242
+
243
+ self.hm = Scrollbox(self.fig, [L['pmargin'], L['pmargin'], L['sidebar_width'], self.wf_top - (len(self.button_boxes)+2) * bh + bs - L['vsep1']],
244
+ nlines = 30, monospace = True, fontsize = 8)
245
+
246
+ def _refresh_sidebars(self):
247
+ current_band = self.band_info['current_band']
248
+
249
+ # band stats
250
+ grd = self.config['station']['grid'][:4]
251
+ for bb in self.button_boxes:
252
+ button_band = bb.clickargs.get('band','')
253
+ if button_band:
254
+ if current_band is not None:
255
+ bb.set_active(button_band == current_band)
256
+ if button_band in self.history.home_activity:
257
+ cnts = self.history.home_activity[button_band]
258
+ bb.set_info_text(f"{cnts[0]}Tx, {cnts[1]}Rx")
259
+
260
+ if current_band is not None and self.history is not None:
261
+ # home square counts
262
+ if current_band in self.history.home_most_remotes:
263
+ tx_lead, rx_lead = self.history.home_most_remotes[current_band]
264
+ myCall = self.config['station']['call']
265
+ n_spotted, n_spotting = self.history.get_spot_counts(current_band, myCall)
266
+ self.band_stats.scroll_print(f"{myCall:<7} {tx_lead[0]:<7}", color = '#ff756b')
267
+ self.band_stats.scroll_print(f"{n_spotting:<7} {tx_lead[1]:<7}", color = '#ff756b')
268
+ self.band_stats.scroll_print(f"{myCall:<7} {rx_lead[0]:<7}", color = '#b6f0c6')
269
+ self.band_stats.scroll_print(f"{n_spotted:<7} {rx_lead[1]:<7}", color = '#b6f0c6')
270
+
271
+ #refresh hearing me / heard by me panel
272
+ historic_data = self.history.hearing_me.data if self.sidebars_page == 1 else self.history.heard_by_me.data
273
+ new_calls_data = self.history.hearing_me_new if self.sidebars_page == 1 else self.history.heard_by_me_new
274
+ timewindow_str = f"<{self.hearing_me_since_mins:.0f} mins"
275
+ title_txt = f"Hearing me {timewindow_str}" if self.sidebars_page==1 else f"Heard by me {timewindow_str}"
276
+ display_rows = [(title_txt, 2e40, 'white')]
277
+ tnow = time_utils.time()
278
+ if current_band in historic_data:
279
+ band_rpts = historic_data[current_band]
280
+ calls_now = [call for call in band_rpts if (tnow - band_rpts[call]['t']) < 60*self.hearing_me_since_mins]
281
+ subtitle_txt = f"{len(calls_now)}/{len(band_rpts)} now/ever"
282
+ display_rows.append((subtitle_txt, 1e40, 'white'))
283
+ for remote_call in calls_now:
284
+ rpt = band_rpts[remote_call]
285
+ snr, geo_text, timestamp = int(rpt['rp']), self.history.get_geo_text(remote_call, self.config['gui']['loc']), rpt['t']
286
+ color = 'white' if self.history.is_in_new_alert(current_band, remote_call, new_calls_data) else 'lime'
287
+ display_rows.append((f"{remote_call:<7} {snr:+03d} {geo_text:<12}", timestamp, color))
288
+ display_rows.sort(key = lambda row: row[1], reverse = True)
289
+ self.hm.list_print([row[0] for row in display_rows], [row[2] for row in display_rows])
290
+ self.sidebars_page = (self.sidebars_page +1 )%2
291
+
292
+ def set_bandstats_title(self, txt):
293
+ self.band_stats.ax.set_title(txt, fontsize = 10)
294
+
295
+ def after_new_search(self, curr_cycle):
296
+ self.gui_queue.put(('clear_cycle', curr_cycle))
297
+
298
+ def set_message(self, message):
299
+ o = message['origin']
300
+ x = int(o['t0'] / self.wf_data['dt'] + o['odd_even'] * self.wf_data['pixels_per_cycle'])
301
+ y = int(o['f0'] / self.wf_data['df'])
302
+ self.gui_queue.put(('add_message', (x, y, message)))
303
+
304
+
305
+
306
+
307
+
308
+
309
+
310
+