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.
- {pyft8-3.1.0 → pyft8-3.6.0}/PKG-INFO +6 -5
- {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8/databases.py +80 -68
- pyft8-3.6.0/PyFT8/gui.py +298 -0
- pyft8-3.6.0/PyFT8/message_broker.py +70 -0
- pyft8-3.6.0/PyFT8/osd.py +88 -0
- pyft8-3.6.0/PyFT8/pyft8.py +169 -0
- pyft8-3.6.0/PyFT8/qso_manager.py +122 -0
- pyft8-3.6.0/PyFT8/receiver.py +618 -0
- {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8/time_utils.py +20 -7
- {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8/transmitter.py +61 -55
- {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8.egg-info/PKG-INFO +6 -5
- {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8.egg-info/SOURCES.txt +16 -5
- {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8.egg-info/requires.txt +3 -0
- {pyft8-3.1.0 → pyft8-3.6.0}/README.md +2 -4
- {pyft8-3.1.0 → pyft8-3.6.0}/pyproject.toml +5 -2
- pyft8-3.6.0/tests/misc/animation.py +36 -0
- pyft8-3.6.0/tests/misc/bits_loopback.py +204 -0
- pyft8-3.6.0/tests/misc/bits_loopback_short.py +169 -0
- pyft8-3.6.0/tests/misc/symbols_loopback.py +81 -0
- {pyft8-3.1.0/tests/dev → pyft8-3.6.0/tests/misc}/test_loopback_performance.py +2 -10
- pyft8-3.6.0/tests/misc/text.py +10 -0
- pyft8-3.6.0/tests/pipeline/Fine tuning compare IOS_PC.py +373 -0
- pyft8-3.6.0/tests/pipeline/Targetted tests.py +396 -0
- pyft8-3.6.0/tests/pipeline/test_01.wav +0 -0
- {pyft8-3.1.0 → pyft8-3.6.0}/tests/plot_baseline.py +10 -9
- pyft8-3.6.0/tests/spare.py +52 -0
- {pyft8-3.1.0 → pyft8-3.6.0}/tests/test_batch_and_live.py +60 -50
- pyft8-3.1.0/PyFT8/gui.py +0 -227
- pyft8-3.1.0/PyFT8/pyft8.py +0 -404
- pyft8-3.1.0/PyFT8/receiver.py +0 -422
- pyft8-3.1.0/tests/spare.py +0 -18
- {pyft8-3.1.0 → pyft8-3.6.0}/LICENSE +0 -0
- {pyft8-3.1.0 → pyft8-3.6.0}/MANIFEST.in +0 -0
- {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8/__init__.py +0 -0
- {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8/pskreporter.py +0 -0
- {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8/rigctrl.py +0 -0
- {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8.egg-info/dependency_links.txt +0 -0
- {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8.egg-info/entry_points.txt +0 -0
- {pyft8-3.1.0 → pyft8-3.6.0}/PyFT8.egg-info/top_level.txt +0 -0
- {pyft8-3.1.0 → pyft8-3.6.0}/setup.cfg +0 -0
- {pyft8-3.1.0/tests/dev → pyft8-3.6.0/tests/misc}/CQ AAAA.py +0 -0
- {pyft8-3.1.0/tests/dev → pyft8-3.6.0/tests/misc}/osd.py +0 -0
- {pyft8-3.1.0/tests/dev → pyft8-3.6.0/tests/misc}/test_generate_wav.py +0 -0
- {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.
|
|
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 [](https://pepy.tech/projects/pyft8) [](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="
|
|
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="
|
|
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
|
-
|
|
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):
|
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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.
|
|
126
|
-
self.
|
|
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,
|
|
131
|
-
recs = self.parse_all_txt(
|
|
132
|
-
if not any(recs):
|
|
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.
|
|
140
|
+
self._add_myspots_record(data, None, band, call, 0, 0)
|
|
142
141
|
|
|
143
|
-
def parse_all_txt(self
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
187
|
-
self.
|
|
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
|
|
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
|
-
|
|
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.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
self.
|
|
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.
|
|
241
|
-
|
|
242
|
-
if len(
|
|
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(
|
|
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.
|
|
256
|
-
rx_reports = self.
|
|
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
|
|
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
|
|
270
|
-
units_str = '' if
|
|
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,
|
|
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 =
|
|
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)
|
pyft8-3.6.0/PyFT8/gui.py
ADDED
|
@@ -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
|
+
|