PyFT8 3.3.1__tar.gz → 3.6.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.3.1 → pyft8-3.6.1}/PKG-INFO +2 -3
- {pyft8-3.3.1 → pyft8-3.6.1}/PyFT8/databases.py +35 -14
- pyft8-3.6.1/PyFT8/gui.py +298 -0
- pyft8-3.6.1/PyFT8/message_broker.py +70 -0
- pyft8-3.6.1/PyFT8/pyft8.py +169 -0
- pyft8-3.6.1/PyFT8/qso_manager.py +122 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/PyFT8/receiver.py +46 -41
- {pyft8-3.3.1 → pyft8-3.6.1}/PyFT8.egg-info/PKG-INFO +2 -3
- {pyft8-3.3.1 → pyft8-3.6.1}/PyFT8.egg-info/SOURCES.txt +2 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/README.md +1 -2
- {pyft8-3.3.1 → pyft8-3.6.1}/pyproject.toml +1 -1
- pyft8-3.6.1/tests/misc/text.py +10 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/test_batch_and_live.py +10 -11
- pyft8-3.3.1/PyFT8/gui.py +0 -310
- pyft8-3.3.1/PyFT8/pyft8.py +0 -219
- pyft8-3.3.1/PyFT8/qso_manager.py +0 -119
- {pyft8-3.3.1 → pyft8-3.6.1}/LICENSE +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/MANIFEST.in +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/PyFT8/__init__.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/PyFT8/osd.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/PyFT8/pskreporter.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/PyFT8/rigctrl.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/PyFT8/time_utils.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/PyFT8/transmitter.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/PyFT8.egg-info/dependency_links.txt +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/PyFT8.egg-info/entry_points.txt +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/PyFT8.egg-info/requires.txt +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/PyFT8.egg-info/top_level.txt +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/setup.cfg +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/misc/CQ AAAA.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/misc/animation.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/misc/bits_loopback.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/misc/bits_loopback_short.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/misc/osd.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/misc/symbols_loopback.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/misc/test_generate_wav.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/misc/test_loopback_performance.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/misc/view_worked_before.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/pipeline/Fine tuning compare IOS_PC.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/pipeline/Targetted tests.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/pipeline/test_01.wav +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/plot_baseline.py +0 -0
- {pyft8-3.3.1 → pyft8-3.6.1}/tests/spare.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyFT8
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.6.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
|
|
@@ -51,8 +51,7 @@ If you're interested in how this works, maybe have a look at [MiniPyFT8](https:/
|
|
|
51
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
|
|
52
52
|
- Data used for the above is cached to disk so is not lost when restarting the program
|
|
53
53
|
|
|
54
|
-
<img width="
|
|
55
|
-
|
|
54
|
+
<img width="983" height="1021" alt="screenshot" src="https://github.com/user-attachments/assets/591b4423-cf50-4be7-88c1-a9ba2347a326" />
|
|
56
55
|
|
|
57
56
|
## Motivation
|
|
58
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.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import threading, os, pickle, json
|
|
1
|
+
import threading, os, pickle, json
|
|
2
2
|
from PyFT8.pskreporter import PSKR_MQTT_listener
|
|
3
3
|
from PyFT8.time_utils import time_utils
|
|
4
4
|
|
|
@@ -85,8 +85,9 @@ class DiskDict:
|
|
|
85
85
|
return
|
|
86
86
|
|
|
87
87
|
class History:
|
|
88
|
-
def __init__(self, config_folder, my_call, home_square, pskr_refresh_mins):
|
|
88
|
+
def __init__(self, config_folder, my_call, home_square = '', geo_units = 'km', pskr_refresh_mins = 20):
|
|
89
89
|
self.pskr_refresh_mins = pskr_refresh_mins
|
|
90
|
+
self.geo_units = geo_units
|
|
90
91
|
self.log_cache = None
|
|
91
92
|
self.my_call = my_call
|
|
92
93
|
self.home_square = home_square
|
|
@@ -116,8 +117,8 @@ class History:
|
|
|
116
117
|
c, b, m = key_parts
|
|
117
118
|
if m == 'FT8':
|
|
118
119
|
#print(f"Parsing adif log: Add hearing & heard by {c} on {b}")
|
|
119
|
-
self.
|
|
120
|
-
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)
|
|
121
122
|
else:
|
|
122
123
|
print(m)
|
|
123
124
|
|
|
@@ -136,7 +137,7 @@ class History:
|
|
|
136
137
|
call = r['call_b'] if TxRx == 'Rx' else r['call_a']
|
|
137
138
|
if len(call) > 3:
|
|
138
139
|
data = self.heard_by_me.data if TxRx == 'Rx' else self.hearing_me.data
|
|
139
|
-
self.
|
|
140
|
+
self._add_myspots_record(data, None, band, call, 0, 0)
|
|
140
141
|
|
|
141
142
|
def parse_all_txt(self):
|
|
142
143
|
rows, recs = None, []
|
|
@@ -154,9 +155,22 @@ class History:
|
|
|
154
155
|
print("Didn't find any records in an ALL.txt file in the config folder")
|
|
155
156
|
return recs
|
|
156
157
|
|
|
157
|
-
def
|
|
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):
|
|
158
172
|
filemode = 'w' if not os.path.exists(self.all_file) else 'a'
|
|
159
|
-
row = f"{cyclestart_string} {fMHz:8.3f} {TxRx} {mode} {
|
|
173
|
+
row = f"{cyclestart_string} {fMHz:8.3f} {TxRx} {mode} {snr_str} {dt:4.1f} {fHz:4.0f} {hail} {their_call} {grid_rpt}"
|
|
160
174
|
with open(self.all_file, filemode) as f:
|
|
161
175
|
f.write(f"{row}\n")
|
|
162
176
|
|
|
@@ -169,9 +183,9 @@ class History:
|
|
|
169
183
|
if self.home_square_lev4 in grid:
|
|
170
184
|
self.add_homespots_record(f"{d['b']}_{iTxRx}_{call}", tnow)
|
|
171
185
|
if d['sc'] == self.my_call:
|
|
172
|
-
self.
|
|
186
|
+
self._add_myspots_record(self.hearing_me.data, self.hearing_me_new, d['b'], d['rc'], tnow, d['rp'])
|
|
173
187
|
if d['rc'] == self.my_call:
|
|
174
|
-
self.
|
|
188
|
+
self._add_myspots_record(self.heard_by_me.data, self.heard_by_me_new, d['b'], d['sc'], tnow, d['rp'])
|
|
175
189
|
|
|
176
190
|
def store_best_grid(self, call, grid):
|
|
177
191
|
if call.startswith('<'): return
|
|
@@ -183,7 +197,7 @@ class History:
|
|
|
183
197
|
self.band_TxRx_homecall_recent_L4grid.data.setdefault(key, [])
|
|
184
198
|
self.band_TxRx_homecall_recent_L4grid.data[key].append(int(t))
|
|
185
199
|
|
|
186
|
-
def
|
|
200
|
+
def _add_myspots_record(self, historic_data, new_alert_data, band, call, t, rp):
|
|
187
201
|
self._update_new_alert(band, call, historic_data, new_alert_data)
|
|
188
202
|
historic_data.setdefault(band, {})
|
|
189
203
|
if call in historic_data[band]:
|
|
@@ -259,12 +273,19 @@ class History:
|
|
|
259
273
|
self.dist_brg_cache.setdefault(grid, grids_to_dist_brg(self.home_square, grid, units))
|
|
260
274
|
return self.dist_brg_cache[grid]
|
|
261
275
|
|
|
262
|
-
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):
|
|
263
284
|
geo_text = ''
|
|
264
285
|
grid = self.call_to_grid.data.get(call, False)
|
|
265
286
|
if grid:
|
|
266
|
-
loc = grid if
|
|
267
|
-
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')
|
|
268
289
|
geo_text = f"{int(loc[0]):5d}{units_str} {int(loc[1]):3d}°"
|
|
269
290
|
return geo_text
|
|
270
291
|
|
|
@@ -294,7 +315,7 @@ class ADIF:
|
|
|
294
315
|
self.cache[cbm] = tm
|
|
295
316
|
|
|
296
317
|
def _build_cache(self):
|
|
297
|
-
import calendar
|
|
318
|
+
import calendar, time
|
|
298
319
|
def parse(rec, field):
|
|
299
320
|
p = rec.find(field)
|
|
300
321
|
if p<0:
|
pyft8-3.6.1/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
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import queue, threading
|
|
2
|
+
from PyFT8.time_utils import time_utils
|
|
3
|
+
|
|
4
|
+
class Broker():
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self.myCall, self.myGrid = None, None
|
|
7
|
+
self.rx = None
|
|
8
|
+
self.history = None
|
|
9
|
+
self.gui = None
|
|
10
|
+
self.history = None
|
|
11
|
+
self.pskr_upload = None
|
|
12
|
+
self.message_queue = queue.Queue()
|
|
13
|
+
self.message_queue_non_time_critical = queue.Queue()
|
|
14
|
+
self.waterfall_data = None
|
|
15
|
+
self.configured_bands = None
|
|
16
|
+
self.on_decode = None
|
|
17
|
+
threading.Thread(target = self._process_message_ntc, daemon = True).start()
|
|
18
|
+
|
|
19
|
+
def register_on_decode(self, func):
|
|
20
|
+
self.on_decode = func
|
|
21
|
+
|
|
22
|
+
def process_message(self, message_dict):
|
|
23
|
+
hail, their_call, grid_rpt = message_dict['hail'], message_dict['their_call'], message_dict['grid_rpt']
|
|
24
|
+
cyclestart_string, their_snr = message_dict['cyclestart_string'], message_dict['their_snr']
|
|
25
|
+
m = message_dict
|
|
26
|
+
screen_format = f"{cyclestart_string} {their_snr} {m['dt']:4.1f} {m['fHz']:4.0f} ~ {hail} {their_call} {grid_rpt}"
|
|
27
|
+
print(f"{screen_format:50s} decoded@ {m['decode_completed']:5.1f}s, dec = {m['decode_status']}")
|
|
28
|
+
|
|
29
|
+
mtype_val = 0 + 1*(their_call == self.myCall) + 2*(hail == self.myCall) + 3*(their_call != self.myCall and hail.startswith('CQ'))
|
|
30
|
+
mtype = ['generic', 'from_me', 'to_me', 'CQ'][mtype_val]
|
|
31
|
+
message_dict.update( {'message_type':mtype, 'display_text':f"{hail} {their_call} {grid_rpt}",
|
|
32
|
+
'priority':(mtype == 'to_me' or mtype == 'CQ')} )
|
|
33
|
+
|
|
34
|
+
if self.history:
|
|
35
|
+
current_band = self.gui.get_band_info()['current_band']
|
|
36
|
+
hearing_me, wb_text, geo_text = self.history.get_message_extra_info(their_call, current_band)
|
|
37
|
+
display_text = f"{message_dict['hail']} {message_dict['their_call']} {message_dict['grid_rpt']} {hearing_me} {wb_text} {geo_text}"
|
|
38
|
+
message_dict.update( {'hearing_me':hearing_me, 'wb_text':wb_text, 'geo_text':geo_text, 'display_text':display_text } )
|
|
39
|
+
if message_dict['priority']:
|
|
40
|
+
if self.gui:
|
|
41
|
+
self.gui.display_message(message_dict)
|
|
42
|
+
|
|
43
|
+
self.message_queue_non_time_critical.put(message_dict)
|
|
44
|
+
|
|
45
|
+
if self.on_decode:
|
|
46
|
+
self.on_decode(message_dict)
|
|
47
|
+
|
|
48
|
+
def _process_message_ntc(self):
|
|
49
|
+
while True:
|
|
50
|
+
time_utils.sleep(0.25)
|
|
51
|
+
while not self.message_queue_non_time_critical.empty():
|
|
52
|
+
time_utils.sleep(0.01)
|
|
53
|
+
m = self.message_queue_non_time_critical.get()
|
|
54
|
+
band_info = None
|
|
55
|
+
if self.gui:
|
|
56
|
+
band_info = self.gui.get_band_info()
|
|
57
|
+
if not m['priority']:
|
|
58
|
+
self.gui.display_message(m)
|
|
59
|
+
if band_info:
|
|
60
|
+
if m['their_call'] != 'not':
|
|
61
|
+
if self.history:
|
|
62
|
+
self.history.process_message(m, band_info, self.myCall)
|
|
63
|
+
if self.pskr_upload:
|
|
64
|
+
if float(band_info['time_set']) < time_utils.time() - 10: # bad QRG Guard
|
|
65
|
+
if m['their_call'] != self.myCall:
|
|
66
|
+
self.pskr_upload.add_report(m['their_call'], int(1000000*float(band_info['fMHz'])) + m['fHz'],
|
|
67
|
+
m['their_snr'], 'FT8', 1, int(time_utils.time()))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|