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.
- {pyft8-3.0.2 → pyft8-3.3.1}/PKG-INFO +5 -3
- {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8/databases.py +59 -63
- pyft8-3.3.1/PyFT8/gui.py +310 -0
- pyft8-3.3.1/PyFT8/osd.py +88 -0
- pyft8-3.3.1/PyFT8/pyft8.py +219 -0
- pyft8-3.3.1/PyFT8/qso_manager.py +119 -0
- pyft8-3.3.1/PyFT8/receiver.py +613 -0
- pyft8-3.3.1/PyFT8/rigctrl.py +103 -0
- {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8/time_utils.py +20 -7
- {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8/transmitter.py +61 -55
- {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8.egg-info/PKG-INFO +5 -3
- {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8.egg-info/SOURCES.txt +14 -5
- {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8.egg-info/requires.txt +3 -0
- {pyft8-3.0.2 → pyft8-3.3.1}/README.md +1 -2
- {pyft8-3.0.2 → pyft8-3.3.1}/pyproject.toml +5 -2
- pyft8-3.3.1/tests/misc/animation.py +36 -0
- pyft8-3.3.1/tests/misc/bits_loopback.py +204 -0
- pyft8-3.3.1/tests/misc/bits_loopback_short.py +169 -0
- pyft8-3.3.1/tests/misc/symbols_loopback.py +81 -0
- {pyft8-3.0.2/tests/dev → pyft8-3.3.1/tests/misc}/test_loopback_performance.py +2 -10
- pyft8-3.3.1/tests/pipeline/Fine tuning compare IOS_PC.py +373 -0
- pyft8-3.3.1/tests/pipeline/Targetted tests.py +396 -0
- pyft8-3.3.1/tests/pipeline/test_01.wav +0 -0
- {pyft8-3.0.2 → pyft8-3.3.1}/tests/plot_baseline.py +10 -9
- pyft8-3.3.1/tests/spare.py +52 -0
- {pyft8-3.0.2 → pyft8-3.3.1}/tests/test_batch_and_live.py +60 -49
- pyft8-3.0.2/PyFT8/gui.py +0 -227
- pyft8-3.0.2/PyFT8/pyft8.py +0 -399
- pyft8-3.0.2/PyFT8/receiver.py +0 -422
- pyft8-3.0.2/PyFT8/rigctrl.py +0 -43
- pyft8-3.0.2/tests/spare.py +0 -18
- {pyft8-3.0.2 → pyft8-3.3.1}/LICENSE +0 -0
- {pyft8-3.0.2 → pyft8-3.3.1}/MANIFEST.in +0 -0
- {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8/__init__.py +0 -0
- {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8/pskreporter.py +0 -0
- {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8.egg-info/dependency_links.txt +0 -0
- {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8.egg-info/entry_points.txt +0 -0
- {pyft8-3.0.2 → pyft8-3.3.1}/PyFT8.egg-info/top_level.txt +0 -0
- {pyft8-3.0.2 → pyft8-3.3.1}/setup.cfg +0 -0
- {pyft8-3.0.2/tests/dev → pyft8-3.3.1/tests/misc}/CQ AAAA.py +0 -0
- {pyft8-3.0.2/tests/dev → pyft8-3.3.1/tests/misc}/osd.py +0 -0
- {pyft8-3.0.2/tests/dev → pyft8-3.3.1/tests/misc}/test_generate_wav.py +0 -0
- {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.
|
|
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 [](https://pepy.tech/projects/pyft8) [](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="
|
|
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
|
-
|
|
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
|
-
|
|
60
|
+
time_utils.sleep(autosave_t0)
|
|
60
61
|
while True:
|
|
61
|
-
|
|
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
|
-
|
|
75
|
-
with
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
88
|
+
def __init__(self, config_folder, my_call, home_square, pskr_refresh_mins):
|
|
83
89
|
self.pskr_refresh_mins = pskr_refresh_mins
|
|
84
|
-
self.
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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,
|
|
126
|
-
recs = self.parse_all_txt(
|
|
127
|
-
if not any(recs):
|
|
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
|
|
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
|
-
|
|
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(
|
|
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.
|
|
182
|
-
self.
|
|
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
|
-
|
|
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.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
self.
|
|
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.
|
|
236
|
-
|
|
237
|
-
if len(
|
|
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(
|
|
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.
|
|
251
|
-
rx_reports = self.
|
|
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,
|
|
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 =
|
|
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)
|
pyft8-3.3.1/PyFT8/gui.py
ADDED
|
@@ -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
|
+
|