PyFT8 2.3.0__tar.gz → 2.4.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-2.3.0 → pyft8-2.4.0}/PKG-INFO +14 -4
- pyft8-2.4.0/PyFT8/callhashes.py +19 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/PyFT8/pskr_upload.py +22 -18
- {pyft8-2.3.0 → pyft8-2.4.0}/PyFT8/pyft8.py +33 -15
- {pyft8-2.3.0 → pyft8-2.4.0}/PyFT8/receiver.py +39 -14
- {pyft8-2.3.0 → pyft8-2.4.0}/PyFT8/transmitter.py +28 -3
- {pyft8-2.3.0 → pyft8-2.4.0}/PyFT8.egg-info/PKG-INFO +14 -4
- {pyft8-2.3.0 → pyft8-2.4.0}/PyFT8.egg-info/SOURCES.txt +1 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/README.md +13 -3
- {pyft8-2.3.0 → pyft8-2.4.0}/pyproject.toml +1 -1
- {pyft8-2.3.0 → pyft8-2.4.0}/LICENSE +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/MANIFEST.in +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/PyFT8/__init__.py +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/PyFT8/gui.py +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/PyFT8/rigctrl.py +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/PyFT8/time_utils.py +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/PyFT8.egg-info/dependency_links.txt +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/PyFT8.egg-info/entry_points.txt +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/PyFT8.egg-info/requires.txt +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/PyFT8.egg-info/top_level.txt +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/setup.cfg +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/tests/dev/osd.py +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/tests/dev/test_generate_wav.py +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/tests/dev/test_loopback_performance.py +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/tests/plot_baseline.py +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/tests/spare.py +0 -0
- {pyft8-2.3.0 → pyft8-2.4.0}/tests/test_batch_and_live.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyFT8
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.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
|
|
@@ -18,7 +18,7 @@ Requires-Dist: pyaudio
|
|
|
18
18
|
Dynamic: license-file
|
|
19
19
|
|
|
20
20
|
# PyFT8 [](https://pepy.tech/projects/pyft8)
|
|
21
|
-
# All-Python FT8 Transceiver
|
|
21
|
+
# All-Python FT8 Transceiver GUI / Command Line Modem
|
|
22
22
|
|
|
23
23
|
This repository contains the source code for PyFT8, an all-Python open source FT8 transceiver that you can run as a basic GUI or from the command line to receive and transmit. Decoding performance (number of decodes) is about 70% of that achieved by WSJT-x in NORM mode, but (tbc) slightly above ft8_lib.
|
|
24
24
|
|
|
@@ -30,11 +30,19 @@ If you're interested in how this works, maybe have a look at [MiniPyFT8](https:/
|
|
|
30
30
|
- Doesn't try to do everything, so launches quickly (~2 seconds on my old Dell Optiplex 790)
|
|
31
31
|
- Use with or without gui (receive and send messages via command line commands)
|
|
32
32
|
- GUI provides simultaneous views of odd and even cycles
|
|
33
|
-
- GUI shows worked-before info on CQ calls
|
|
33
|
+
- GUI shows worked-before info on CQ calls
|
|
34
34
|
- Messages overlaid on waterfall signals that produce them
|
|
35
35
|
- Automatically chooses clearest Tx frequency
|
|
36
36
|
- Modern programming language throughout
|
|
37
37
|
- Finds sound cards by keywords so follows them if windows moves them ...
|
|
38
|
+
- Logs QSOs to ADIF file and all spots to WSJTX-style ALL.txt file
|
|
39
|
+
- Uploads spots to pskreporter
|
|
40
|
+
|
|
41
|
+
To enable uploading of spots to pskreporter, make sure that your .ini file includes
|
|
42
|
+
```
|
|
43
|
+
[pskreporter]
|
|
44
|
+
upload = Y
|
|
45
|
+
```
|
|
38
46
|
|
|
39
47
|
<img width="1003" height="1020" alt="image" src="https://github.com/user-attachments/assets/bf6e3f78-531a-4c9b-ab2b-b51cc04ad980" />
|
|
40
48
|
|
|
@@ -112,6 +120,7 @@ Note - section 9 of the QEX paper states that the above two WSJT-X resources are
|
|
|
112
120
|
**FT8 decoding explorations / explanations**
|
|
113
121
|
- [VK3JPK's FT8 notes](https://github.com/vk3jpk/ft8-notes) including comprehensive [Python source code](https://github.com/vk3jpk/ft8-notes/blob/master/ft8.py)
|
|
114
122
|
- [G4JNT notes on LDPC coding process](http://www.g4jnt.com/WSJT-X_LdpcModesCodingProcess.pdf)
|
|
123
|
+
- [Kristian Glass's 2025-05-06 Understanding the FT8 binary protocol](https://notes.doismellburning.co.uk/notebook/2025-05-06-understanding-the-ft8-binary-protocol/)
|
|
115
124
|
|
|
116
125
|
**FT8 decoding in hardware**
|
|
117
126
|
- [Optimizing the (Web-888) FT8 Skimmer Experience](https://www.rx-888.com/web/design/digi.html) (see also [RX-888 project](https://www.rx-888.com/) )
|
|
@@ -122,7 +131,8 @@ Note - section 9 of the QEX paper states that the above two WSJT-X resources are
|
|
|
122
131
|
- [Post about ft8play](https://groups.io/g/FT8-Digital-Mode/topic/i_made_a_thing_ft8play/107846361)
|
|
123
132
|
|
|
124
133
|
**Browser-based decoder/encoders**
|
|
125
|
-
- [ft8js](https://e04.github.io/ft8js/example/browser/index.html) - source [github](https://github.com/e04/ft8js?tab=readme-ov-file), uses [FT8_lib](https://github.com/kgoba/ft8_lib)
|
|
134
|
+
- [ft8js by e04](https://e04.github.io/ft8js/example/browser/index.html) - source [github](https://github.com/e04/ft8js?tab=readme-ov-file), uses web-assembled version of [FT8_lib](https://github.com/kgoba/ft8_lib)
|
|
135
|
+
- [ft8ts by e04](https://github.com/e04/ft8ts), - pure TypeScript implementation
|
|
126
136
|
- [ChromeFT8 Browser Extension](https://github.com/Transwarp8/ChromeFT8), decoder adapted from [ft8js](https://e04.github.io/ft8js/example/browser/index.html)
|
|
127
137
|
|
|
128
138
|
<script data-goatcounter="https://g1ojs-github.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
call_hashes = {}
|
|
2
|
+
|
|
3
|
+
def add_call_hashes(call):
|
|
4
|
+
global call_hashes
|
|
5
|
+
chars = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/"
|
|
6
|
+
call_padded = (call + " ")[:11]
|
|
7
|
+
hashes = []
|
|
8
|
+
for m in [10,12,22]:
|
|
9
|
+
x = 0
|
|
10
|
+
for c in call_padded:
|
|
11
|
+
x = 38*x + chars.find(c)
|
|
12
|
+
x = x & ((int(1) << 64) - 1)
|
|
13
|
+
x = x & ((1 << 64) - 1)
|
|
14
|
+
x = x * 47055833459
|
|
15
|
+
x = x & ((1 << 64) - 1)
|
|
16
|
+
x = x >> (64 - m)
|
|
17
|
+
hashes.append(x)
|
|
18
|
+
call_hashes[(x, m)] = call
|
|
19
|
+
return hashes
|
|
@@ -11,7 +11,6 @@ class PSKR_upload:
|
|
|
11
11
|
# https://pskreporter.info/cgi-bin/psk-analysis.pl
|
|
12
12
|
def __init__(self, mycall, mygrid, software, tt, console_print):
|
|
13
13
|
self.RxInfoRecDescriptor_CallLocSoft = b"\x00\x03\x00\x24\x99\x92\x00\x03\x00\x01\x80\x02\xFF\xFF\x00\x00\x76\x8F\x80\x04\xFF\xFF\x00\x00\x76\x8F\x80\x08\xFF\xFF\x00\x00\x76\x8F\x00\x00"
|
|
14
|
-
self.SenderInfoRecDescriptor_CallFreqSourceStart = b"\x00\x02\x00\x2C\x99\x93\x00\x05\x80\x01\xFF\xFF\x00\x00\x76\x8F\x80\x05\x00\x04\x00\x00\x76\x8F\x80\x0A\xFF\xFF\x00\x00\x76\x8F\x80\x0B\x00\x01\x00\x00\x76\x8F\x00\x96\x00\x04"
|
|
15
14
|
self.SenderInfoRecDescriptor_SenderFreqSNRiMDModeSourceTime = b"\x00\x02\x00\x3C\x99\x93\x00\x07\x80\x01\xFF\xFF\x00\x00\x76\x8F\x80\x05\x00\x04\x00\x00\x76\x8F\x80\x06\x00\x01\x00\x00\x76\x8F\x80\x07\x00\x01\x00\x00\x76\x8F\x80\x0A\xFF\xFF\x00\x00\x76\x8F\x80\x0B\x00\x01\x00\x00\x76\x8F\x00\x96\x00\x04"
|
|
16
15
|
self.tt = tt
|
|
17
16
|
self.includeDescriptors = 0
|
|
@@ -20,10 +19,12 @@ class PSKR_upload:
|
|
|
20
19
|
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
21
20
|
self.session_id = random.getrandbits(32)
|
|
22
21
|
self.seq = 1
|
|
23
|
-
self.reports
|
|
22
|
+
self.reports = {}
|
|
24
23
|
rx = self._enc_str(mycall) + self._enc_str(mygrid) + self._enc_str(software)
|
|
25
24
|
self.rx_block = self._block(b"\x99\x92", rx)
|
|
26
25
|
self.console_print = console_print
|
|
26
|
+
self.lock = threading.Lock()
|
|
27
|
+
threading.Thread(target = self._check_for_send, daemon = True).start()
|
|
27
28
|
|
|
28
29
|
def _enc_str(self, s):
|
|
29
30
|
b = s.encode("ascii")
|
|
@@ -36,21 +37,21 @@ class PSKR_upload:
|
|
|
36
37
|
blk = block_type + struct.pack("!H", len_with_pad) + payload + b"\x00" * pad_len
|
|
37
38
|
return blk
|
|
38
39
|
|
|
39
|
-
# need to modify this to keep only the latest report for each dxcall
|
|
40
|
-
# also to check if packet is full and if so send
|
|
41
40
|
def add_report(self, dxcall, freq_hz, snr, mode, source, tt):
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
self.reports.append(report)
|
|
45
|
-
self.dxcalls.append(dxcall)
|
|
46
|
-
n_reports = len(self.reports)
|
|
47
|
-
if n_reports >= MAX_REPORTS or (time.time() - self.last_report_time) > 300:
|
|
48
|
-
self.send(includeDescriptors = (time.time() - self.includeDescriptors) > 3600)
|
|
49
|
-
self.includeDescriptors = time.time()
|
|
50
|
-
self.last_report_time = time.time()
|
|
51
|
-
#self.console_print(f"[pskr_upload] Added report {report} (now {len(self.reports)} reports)")
|
|
41
|
+
with self.lock:
|
|
42
|
+
self.reports[dxcall] = (dxcall, freq_hz, snr, mode, source, (tt // 15) * 15)
|
|
52
43
|
|
|
53
|
-
def
|
|
44
|
+
def _check_for_send(self):
|
|
45
|
+
while True:
|
|
46
|
+
time.sleep(60)
|
|
47
|
+
with self.lock:
|
|
48
|
+
n_reports = len(self.reports)
|
|
49
|
+
if n_reports >= MAX_REPORTS or (time.time() - self.last_report_time) > 300:
|
|
50
|
+
self._send(includeDescriptors = (time.time() - self.includeDescriptors) > 3600)
|
|
51
|
+
self.includeDescriptors = time.time()
|
|
52
|
+
self.last_report_time = time.time()
|
|
53
|
+
|
|
54
|
+
def _send(self, includeDescriptors = False):
|
|
54
55
|
if not self.reports:
|
|
55
56
|
return
|
|
56
57
|
ipfx_header = struct.pack("!H", 10) + b"\x00\x00" + struct.pack("!I", self.tt) + struct.pack("!I", self.seq) + struct.pack("!I", self.session_id)
|
|
@@ -58,15 +59,18 @@ class PSKR_upload:
|
|
|
58
59
|
if includeDescriptors:
|
|
59
60
|
header = header + self.RxInfoRecDescriptor_CallLocSoft + self.SenderInfoRecDescriptor_SenderFreqSNRiMDModeSourceTime
|
|
60
61
|
senders = bytearray()
|
|
61
|
-
for dxcall, freq_hz, snr, mode, source, tt in self.reports:
|
|
62
|
+
for dxcall, freq_hz, snr, mode, source, tt in self.reports.values():
|
|
63
|
+
print(f"Sending report {dxcall}, {freq_hz}, {snr}, {mode}, {source}, {tt}")
|
|
62
64
|
sender = self._enc_str(dxcall) + struct.pack("!I", int(freq_hz)) + struct.pack("b", int(snr)) + struct.pack("b", 0) + self._enc_str(mode) + struct.pack("B", source) + struct.pack("!I", tt)
|
|
63
65
|
senders += sender
|
|
64
66
|
packet = bytearray(header + self.rx_block + self._block(b"\x99\x93", senders))
|
|
65
67
|
struct.pack_into("!H", packet, 2, len(packet))
|
|
66
68
|
self.seq += len(self.reports)
|
|
67
69
|
self.sock.sendto(packet, self.addr)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
txt = f"[pskr_upload] Sent packet with {len(self.reports)} reports"
|
|
71
|
+
print(txt)
|
|
72
|
+
self.console_print(txt)
|
|
73
|
+
self.reports = {}
|
|
70
74
|
|
|
71
75
|
|
|
72
76
|
#pskr = PSKReporter('G1OJS', 'IO90ju', software = 'PyFT8', tt = int(time.time()))
|
|
@@ -11,11 +11,12 @@ from PyFT8.transmitter import AudioOut
|
|
|
11
11
|
from PyFT8.time_utils import global_time_utils
|
|
12
12
|
from PyFT8.rigctrl import Rig
|
|
13
13
|
|
|
14
|
+
VER = '2.4.0'
|
|
15
|
+
|
|
14
16
|
MAX_TX_START_SECONDS = 2.5
|
|
15
|
-
T_CYC = 15
|
|
16
17
|
rig, gui, qso, worked_before, pskr_upload = None, None, None, None, None
|
|
17
18
|
|
|
18
|
-
def get_config(
|
|
19
|
+
def get_config():
|
|
19
20
|
import configparser
|
|
20
21
|
global config
|
|
21
22
|
config = configparser.ConfigParser()
|
|
@@ -41,7 +42,7 @@ def parse_from_adif_rec(rec, field):
|
|
|
41
42
|
return rec[p2+1: p2+1+n]
|
|
42
43
|
|
|
43
44
|
class Logging:
|
|
44
|
-
def __init__(self
|
|
45
|
+
def __init__(self):
|
|
45
46
|
self.adif_log_file = f"{config_folder}/PyFT8.adi"
|
|
46
47
|
self.worked_before_file = f"{config_folder}/PyFT8_wb.pkl"
|
|
47
48
|
if(not os.path.exists(self.adif_log_file)):
|
|
@@ -117,6 +118,10 @@ class Message:
|
|
|
117
118
|
def wsjtx_screen_format(self):
|
|
118
119
|
return f"{self.cyclestart['string']} {self.snr:+03d} {self.dt:4.1f} {self.fHz:4.0f} ~ {self.msg}"
|
|
119
120
|
|
|
121
|
+
def wsjtx_all_txt_format(self):
|
|
122
|
+
fMHz = float(qso.band_info['fMHz']) if qso.band_info['fMHz'] is not None else 0
|
|
123
|
+
return f"{self.cyclestart['string']} {fMHz:8.3f} Rx FT8 {self.snr:+03d} {self.dt:4.1f} {self.fHz:4.0f} ~ {self.msg}"
|
|
124
|
+
|
|
120
125
|
|
|
121
126
|
class FT8_QSO:
|
|
122
127
|
def __init__(self, logging):
|
|
@@ -158,14 +163,17 @@ class FT8_QSO:
|
|
|
158
163
|
if self.tx_cycle is None:
|
|
159
164
|
self.tx_cycle = global_time_utils.curr_cycle_from_time()
|
|
160
165
|
self.tx_freq = clear_frequencies[self.tx_cycle]
|
|
161
|
-
console_print(f"[PyFT8] Set tx cycle = {self.tx_cycle} f = {self.tx_freq}")
|
|
162
|
-
console_print(f"Transmitting {self.message_to_transmit} on cycle {self.tx_cycle}")
|
|
166
|
+
console_print(f"[PyFT8] Set tx cycle = {self.tx_cycle} f = {self.tx_freq:5.1f}")
|
|
163
167
|
symbols = audio_out.create_ft8_symbols(self.message_to_transmit)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
if any(symbols):
|
|
169
|
+
console_print(f"Transmitting {self.message_to_transmit} on cycle {self.tx_cycle}")
|
|
170
|
+
audio_data = audio_out.create_ft8_wave(symbols, f_base = self.tx_freq)
|
|
171
|
+
rig.ptt_on()
|
|
172
|
+
audio_out.play_data_to_soundcard(audio_data, output_device_idx)
|
|
173
|
+
rig.ptt_off()
|
|
174
|
+
self.last_tx = self.message_to_transmit
|
|
175
|
+
else:
|
|
176
|
+
console_print(f"Couldn't encode message {self.message_to_transmit}", color = 'red') # move this to earlier by setting tx symbols not tx message
|
|
169
177
|
self.message_to_transmit = None
|
|
170
178
|
|
|
171
179
|
def log(self):
|
|
@@ -234,6 +242,13 @@ def wait_for_keyboard():
|
|
|
234
242
|
except KeyboardInterrupt:
|
|
235
243
|
pass
|
|
236
244
|
|
|
245
|
+
def write_all_txt_row(message):
|
|
246
|
+
all_file = f"{config_folder}/ALL.txt"
|
|
247
|
+
mode = 'w' if not os.path.exists(all_file) else 'a'
|
|
248
|
+
row = message.wsjtx_all_txt_format()
|
|
249
|
+
with open(all_file, mode) as f:
|
|
250
|
+
f.write(f"{row}\n")
|
|
251
|
+
|
|
237
252
|
#============= Callbacks for GUI ==========================================================
|
|
238
253
|
def on_decode(c):
|
|
239
254
|
message = Message(c)
|
|
@@ -242,8 +257,9 @@ def on_decode(c):
|
|
|
242
257
|
if qso.band_info['b'] is not None and pskr_upload is not None:
|
|
243
258
|
dx_call = c.msg_tuple[1]
|
|
244
259
|
if dx_call != 'not':
|
|
245
|
-
pskr_upload.add_report(dx_call, int(1000000*float(qso.band_info['fMHz'])) + c.fHz, c.snr, 'FT8',
|
|
260
|
+
pskr_upload.add_report(dx_call, int(1000000*float(qso.band_info['fMHz'])) + c.fHz, c.snr, 'FT8', 1, int(time.time()))
|
|
246
261
|
print(message.wsjtx_screen_format())
|
|
262
|
+
write_all_txt_row(message)
|
|
247
263
|
|
|
248
264
|
def on_busy_profile(busy_profile, cycle):
|
|
249
265
|
if output_device_idx is None:
|
|
@@ -253,6 +269,8 @@ def on_busy_profile(busy_profile, cycle):
|
|
|
253
269
|
idx = np.argmin(busy_profile[f0_idx:fn_idx])
|
|
254
270
|
clear_frequencies[cycle] = (f0_idx + idx) * audio_in.df
|
|
255
271
|
console_print(f"[on_busy] Set Tx freq to {clear_frequencies[cycle]:6.1f} for cycle {cycle}")
|
|
272
|
+
if qso.band_info['b'] is None:
|
|
273
|
+
console_print(f"[PyFT8] Band not set; please select a band.", color = 'red')
|
|
256
274
|
|
|
257
275
|
def on_control_click(btn_widg):
|
|
258
276
|
btn_def = btn_widg.user_data
|
|
@@ -283,7 +301,7 @@ def console_print(text, color = 'white'):
|
|
|
283
301
|
print(text)
|
|
284
302
|
|
|
285
303
|
def cli():
|
|
286
|
-
global audio_in, audio_out, output_device_idx, rig, gui, qso, config, clear_frequencies, pskr_upload
|
|
304
|
+
global audio_in, audio_out, output_device_idx, rig, gui, qso, config, config_folder, clear_frequencies, pskr_upload
|
|
287
305
|
import time
|
|
288
306
|
parser = argparse.ArgumentParser(prog='PyFT8rx', description = 'Command Line FT8 decoder')
|
|
289
307
|
parser.add_argument('-c', '--config_folder', help = 'Location of config folder e.g. C:/Users/drala/Documents/Projects/GitHub/G1OJS/PyFT8_cfg', default = './')
|
|
@@ -297,12 +315,12 @@ def cli():
|
|
|
297
315
|
|
|
298
316
|
output_device_idx = None
|
|
299
317
|
config_folder = f"{args.config_folder}".strip()
|
|
300
|
-
get_config(
|
|
301
|
-
logging = Logging(
|
|
318
|
+
get_config()
|
|
319
|
+
logging = Logging()
|
|
302
320
|
mc, mg = config['station']['call'], config['station']['grid']
|
|
303
321
|
if mc is not None and 'pskreporter' in config.keys():
|
|
304
322
|
if config['pskreporter']['upload'] == 'Y':
|
|
305
|
-
pskr_upload = PSKR_upload(mc, mg, software =
|
|
323
|
+
pskr_upload = PSKR_upload(mc, mg, software = f"PyFT8 v{VER}", tt = int(time.time()), console_print = console_print) if not mc is None else None
|
|
306
324
|
console_print(f"[PyFT8] Spots will upload to pskreporter")
|
|
307
325
|
qso = FT8_QSO(logging)
|
|
308
326
|
rig = Rig(config)
|
|
@@ -6,6 +6,7 @@ from PyFT8.time_utils import global_time_utils, Ticker
|
|
|
6
6
|
import os
|
|
7
7
|
import pyaudio
|
|
8
8
|
import pickle
|
|
9
|
+
from PyFT8.callhashes import call_hashes, add_call_hashes
|
|
9
10
|
|
|
10
11
|
T_CYC = 15
|
|
11
12
|
HPS = 4
|
|
@@ -31,7 +32,6 @@ HOPS_PER_GRID = 2 * HOPS_PER_CYCLE
|
|
|
31
32
|
|
|
32
33
|
global_time_utils.set_cycle_length(T_CYC)
|
|
33
34
|
|
|
34
|
-
|
|
35
35
|
#=========== Unpacking functions ========================================
|
|
36
36
|
def get_bits(bits, n):
|
|
37
37
|
mask = (1 << n) - 1
|
|
@@ -40,45 +40,70 @@ def get_bits(bits, n):
|
|
|
40
40
|
return out, bits
|
|
41
41
|
|
|
42
42
|
def unpack(bits):
|
|
43
|
+
# print(f"{bits:77b}")
|
|
43
44
|
i3, bits = get_bits(bits,3)
|
|
45
|
+
# print(i3)
|
|
44
46
|
if i3 == 0:
|
|
45
47
|
n3, bits = get_bits(bits,3)
|
|
46
48
|
if n3 == 0:
|
|
47
49
|
return ('Free text','not','implemented')
|
|
48
50
|
else:
|
|
49
51
|
return (['DXpedition','Field Day', 'Field Day', 'Telemetry'][n3-1],'not','implemented')
|
|
50
|
-
elif i3 == 1:
|
|
52
|
+
elif i3 == 1 or i3 == 2: # 1 = Std Msg incl /R 2 = 'EU VHF' = Std Msg incl /P
|
|
51
53
|
gr, bits = get_bits(bits,16)
|
|
52
54
|
cb, bits = get_bits(bits,29)
|
|
53
55
|
ca, bits = get_bits(bits,29)
|
|
54
|
-
return (
|
|
55
|
-
elif i3 == 2:
|
|
56
|
-
return ('EU VHF','not','implemented')
|
|
56
|
+
return (call_28(ca, i3), call_28(cb, i3), decode_grid(gr))
|
|
57
57
|
elif i3 == 3:
|
|
58
58
|
return ('RTTY RU','not','implemented')
|
|
59
59
|
elif i3 == 4:
|
|
60
|
-
|
|
60
|
+
cq_, bits = get_bits(bits,1)
|
|
61
|
+
rrr, bits = get_bits(bits,2)
|
|
62
|
+
swp, bits = get_bits(bits,1)
|
|
63
|
+
c58, bits = get_bits(bits,58)
|
|
64
|
+
hsh, bits = get_bits(bits,12)
|
|
65
|
+
ca = "CQ" if cq_ else call_hashes.get((hsh,12), '<....>')
|
|
66
|
+
cb = call_58(c58)
|
|
67
|
+
(ca, cb) = (cb, ca) if swp else (ca, cb)
|
|
68
|
+
return (ca, cb, ('', 'RRR', 'RR73', '73')[rrr])
|
|
61
69
|
elif i3 == 5:
|
|
62
70
|
return ('EU VHF','not','implemented')
|
|
63
71
|
|
|
64
|
-
def
|
|
72
|
+
def call_58(call_int):
|
|
73
|
+
call = ""
|
|
74
|
+
chars = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/"
|
|
75
|
+
for i in range(12):
|
|
76
|
+
call = chars[call_int % 38] + call
|
|
77
|
+
call_int = call_int // 38
|
|
78
|
+
call = call.strip()
|
|
79
|
+
add_call_hashes(call)
|
|
80
|
+
return call
|
|
81
|
+
|
|
82
|
+
def call_28(call_int, i3):
|
|
83
|
+
def get_table_7(call_int):
|
|
84
|
+
table_7 = {'DE':(0,0),'QRZ':(1,1),'CQ':(2,2), 'CQ nnn':(3,1002),'CQ x':(1004,1029),
|
|
85
|
+
'CQ xx':(1031,1731),'CQ xxxx':(21443,532443),'hash':(2063592,2063592+4194303)}
|
|
86
|
+
for ct, (lo, hi) in table_7.items():
|
|
87
|
+
if lo <= call_int <= hi:
|
|
88
|
+
return ct
|
|
65
89
|
from string import ascii_uppercase as ltrs, digits as digs
|
|
66
|
-
table_7 = {'DE':(0,0),'QRZ':(1,1),'CQ':(2,2), 'CQ nnn':(3,1002),'CQ x':(1004,1029),
|
|
67
|
-
'CQ xx':(1031,1731),'CQ xxxx':(21443,532443),'<....>':(2063592,2063592+4194303)}
|
|
68
90
|
call_fields = [ (' ' + digs + ltrs, 36*10*27**3), (digs + ltrs, 10*27**3), (digs + ' ' * 17, 27**3),
|
|
69
91
|
(' ' + ltrs, 27**2), (' ' + ltrs, 27), (' ' + ltrs, 1) ]
|
|
70
|
-
|
|
92
|
+
portable_rover = call_int & 1
|
|
71
93
|
call_int >>= 1
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
t7 = get_table_7(call_int)
|
|
95
|
+
if t7 is not None:
|
|
96
|
+
return t7 if t7 != 'hash' else call_hashes.get((call_int - 2063592, 22), '<....>')
|
|
75
97
|
call_int -= (2063592 + 4194304)
|
|
76
98
|
chars = []
|
|
77
99
|
for alphabet, div in call_fields:
|
|
78
100
|
idx, call_int = divmod(call_int, div)
|
|
79
101
|
chars.append(alphabet[idx])
|
|
80
102
|
call = ''.join(chars).strip()
|
|
81
|
-
|
|
103
|
+
if portable_rover:
|
|
104
|
+
call = call + ('/P' if i3 == 2 else '/R')
|
|
105
|
+
add_call_hashes(call)
|
|
106
|
+
return call
|
|
82
107
|
|
|
83
108
|
def decode_grid(grid_int):
|
|
84
109
|
g15 = grid_int & 0x7FFF
|
|
@@ -2,6 +2,7 @@ import numpy as np
|
|
|
2
2
|
import wave
|
|
3
3
|
import pyaudio
|
|
4
4
|
import time
|
|
5
|
+
from PyFT8.callhashes import call_hashes, add_call_hashes
|
|
5
6
|
|
|
6
7
|
#==================== AUDIO OUT ================================================================
|
|
7
8
|
|
|
@@ -67,21 +68,45 @@ def _pack_message(c1, c2, gr):
|
|
|
67
68
|
c28a, p1a = pack_ft8_c28(c1)
|
|
68
69
|
c28b, p1b = pack_ft8_c28(c2)
|
|
69
70
|
g15, ir = pack_ft8_g15(gr)
|
|
70
|
-
i3 = 1
|
|
71
|
+
i3 = 2 if p1a or p1b else 1
|
|
71
72
|
n3 = 0
|
|
72
73
|
symbols, bits77 = [], 0
|
|
73
74
|
if(c28a>=0 and c28b>=0):
|
|
74
75
|
bits77 = (c28a<<28+1+1+1+15+3) | (p1a<<28+1+1+15+3) | (c28b<<1+1+15+3) | (p1b <<1+15+3) | (ir<<15+3) | (g15<< 3) | (i3)
|
|
75
76
|
symbols = encode_bits77(bits77)
|
|
77
|
+
if not any(symbols):
|
|
78
|
+
i3 = 4
|
|
79
|
+
full_call = c1 if c28b>0 else c2
|
|
80
|
+
hash_call = c2 if c28b>0 else c1
|
|
81
|
+
c58 = pack_ft8_c58(full_call)
|
|
82
|
+
hashes = add_call_hashes(hash_call)
|
|
83
|
+
swp = 1 if hash_call == c2 else 0
|
|
84
|
+
rrr, cq_ = 0, 0
|
|
85
|
+
if gr in ('RRR', 'RR73', '73'):
|
|
86
|
+
rrr = ('', 'RRR', 'RR73', '73').index(gr)
|
|
87
|
+
bits77 = (hashes[1]<<58+1+2+1+3) | (c58 <<1+2+1+3) | (swp<<2+1+3) | (rrr<<1+3) | (cq_<<3) | (i3)
|
|
88
|
+
symbols = encode_bits77(bits77)
|
|
76
89
|
return symbols, bits77
|
|
77
90
|
|
|
91
|
+
|
|
92
|
+
def pack_ft8_c58(call):
|
|
93
|
+
chars = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/"
|
|
94
|
+
n58=0
|
|
95
|
+
call = (call + " ")[:11]
|
|
96
|
+
for i in range(0,11):
|
|
97
|
+
n58 = n58*38 + chars.index(call[i])
|
|
98
|
+
return n58
|
|
99
|
+
|
|
78
100
|
def pack_ft8_c28(call):
|
|
101
|
+
if '/' in call and not call.endswith("P") and not call.endswith("R"): return -1, 0
|
|
79
102
|
tkns = ['DE','QRZ','CQ']
|
|
80
103
|
if (call in tkns):
|
|
81
104
|
c28, p1 = tkns.index(call), 0
|
|
82
105
|
else:
|
|
83
106
|
p1 = 1 if call[-2:] == '/P' else 0
|
|
84
107
|
call = call.replace('/P','')
|
|
108
|
+
if len(call) > 6:
|
|
109
|
+
return -1, 0
|
|
85
110
|
prepend_space = '' if call[2].isdigit() else ' '
|
|
86
111
|
call = (prepend_space + call + ' ')[:6]
|
|
87
112
|
a = ' 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
@@ -160,13 +185,13 @@ def append_crc(bits77_int):
|
|
|
160
185
|
if __name__ == "__main__":
|
|
161
186
|
OK = True
|
|
162
187
|
msgs = [("G1OJS/P", "G1OJS/P", "IO90"),("WM3PEN","EA6VQ","+08"),("E67A/P","EA6VQ","R-08"),
|
|
163
|
-
("CQ","CT7ARQ/P","RRR"), ("EC5A","9A5E","RR73"), ("EC5A","9A5E","73")]
|
|
188
|
+
("CQ","CT7ARQ/P","RRR"), ("EC5A","9A5E","RR73"), ("EC5A/P","9A5E","73"), ("EC5A/MM","9A5E","73")]
|
|
164
189
|
for msg_tx in msgs:
|
|
165
190
|
symbols, bits77 = _pack_message(*msg_tx)
|
|
166
191
|
from PyFT8.receiver import unpack
|
|
167
192
|
msg_rx = unpack(bits77)
|
|
168
193
|
print(f"\n{msg_tx}\n{msg_rx}")
|
|
169
|
-
OK = OK and (msg_tx == msg_rx)
|
|
194
|
+
OK = OK and (msg_tx == msg_rx) or 'implemented' in msg_rx
|
|
170
195
|
#print(''.join([str(s) for s in symbols]))
|
|
171
196
|
print("\nPASSED" if OK else "\nFAILED")
|
|
172
197
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyFT8
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.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
|
|
@@ -18,7 +18,7 @@ Requires-Dist: pyaudio
|
|
|
18
18
|
Dynamic: license-file
|
|
19
19
|
|
|
20
20
|
# PyFT8 [](https://pepy.tech/projects/pyft8)
|
|
21
|
-
# All-Python FT8 Transceiver
|
|
21
|
+
# All-Python FT8 Transceiver GUI / Command Line Modem
|
|
22
22
|
|
|
23
23
|
This repository contains the source code for PyFT8, an all-Python open source FT8 transceiver that you can run as a basic GUI or from the command line to receive and transmit. Decoding performance (number of decodes) is about 70% of that achieved by WSJT-x in NORM mode, but (tbc) slightly above ft8_lib.
|
|
24
24
|
|
|
@@ -30,11 +30,19 @@ If you're interested in how this works, maybe have a look at [MiniPyFT8](https:/
|
|
|
30
30
|
- Doesn't try to do everything, so launches quickly (~2 seconds on my old Dell Optiplex 790)
|
|
31
31
|
- Use with or without gui (receive and send messages via command line commands)
|
|
32
32
|
- GUI provides simultaneous views of odd and even cycles
|
|
33
|
-
- GUI shows worked-before info on CQ calls
|
|
33
|
+
- GUI shows worked-before info on CQ calls
|
|
34
34
|
- Messages overlaid on waterfall signals that produce them
|
|
35
35
|
- Automatically chooses clearest Tx frequency
|
|
36
36
|
- Modern programming language throughout
|
|
37
37
|
- Finds sound cards by keywords so follows them if windows moves them ...
|
|
38
|
+
- Logs QSOs to ADIF file and all spots to WSJTX-style ALL.txt file
|
|
39
|
+
- Uploads spots to pskreporter
|
|
40
|
+
|
|
41
|
+
To enable uploading of spots to pskreporter, make sure that your .ini file includes
|
|
42
|
+
```
|
|
43
|
+
[pskreporter]
|
|
44
|
+
upload = Y
|
|
45
|
+
```
|
|
38
46
|
|
|
39
47
|
<img width="1003" height="1020" alt="image" src="https://github.com/user-attachments/assets/bf6e3f78-531a-4c9b-ab2b-b51cc04ad980" />
|
|
40
48
|
|
|
@@ -112,6 +120,7 @@ Note - section 9 of the QEX paper states that the above two WSJT-X resources are
|
|
|
112
120
|
**FT8 decoding explorations / explanations**
|
|
113
121
|
- [VK3JPK's FT8 notes](https://github.com/vk3jpk/ft8-notes) including comprehensive [Python source code](https://github.com/vk3jpk/ft8-notes/blob/master/ft8.py)
|
|
114
122
|
- [G4JNT notes on LDPC coding process](http://www.g4jnt.com/WSJT-X_LdpcModesCodingProcess.pdf)
|
|
123
|
+
- [Kristian Glass's 2025-05-06 Understanding the FT8 binary protocol](https://notes.doismellburning.co.uk/notebook/2025-05-06-understanding-the-ft8-binary-protocol/)
|
|
115
124
|
|
|
116
125
|
**FT8 decoding in hardware**
|
|
117
126
|
- [Optimizing the (Web-888) FT8 Skimmer Experience](https://www.rx-888.com/web/design/digi.html) (see also [RX-888 project](https://www.rx-888.com/) )
|
|
@@ -122,7 +131,8 @@ Note - section 9 of the QEX paper states that the above two WSJT-X resources are
|
|
|
122
131
|
- [Post about ft8play](https://groups.io/g/FT8-Digital-Mode/topic/i_made_a_thing_ft8play/107846361)
|
|
123
132
|
|
|
124
133
|
**Browser-based decoder/encoders**
|
|
125
|
-
- [ft8js](https://e04.github.io/ft8js/example/browser/index.html) - source [github](https://github.com/e04/ft8js?tab=readme-ov-file), uses [FT8_lib](https://github.com/kgoba/ft8_lib)
|
|
134
|
+
- [ft8js by e04](https://e04.github.io/ft8js/example/browser/index.html) - source [github](https://github.com/e04/ft8js?tab=readme-ov-file), uses web-assembled version of [FT8_lib](https://github.com/kgoba/ft8_lib)
|
|
135
|
+
- [ft8ts by e04](https://github.com/e04/ft8ts), - pure TypeScript implementation
|
|
126
136
|
- [ChromeFT8 Browser Extension](https://github.com/Transwarp8/ChromeFT8), decoder adapted from [ft8js](https://e04.github.io/ft8js/example/browser/index.html)
|
|
127
137
|
|
|
128
138
|
<script data-goatcounter="https://g1ojs-github.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# PyFT8 [](https://pepy.tech/projects/pyft8)
|
|
2
|
-
# All-Python FT8 Transceiver
|
|
2
|
+
# All-Python FT8 Transceiver GUI / Command Line Modem
|
|
3
3
|
|
|
4
4
|
This repository contains the source code for PyFT8, an all-Python open source FT8 transceiver that you can run as a basic GUI or from the command line to receive and transmit. Decoding performance (number of decodes) is about 70% of that achieved by WSJT-x in NORM mode, but (tbc) slightly above ft8_lib.
|
|
5
5
|
|
|
@@ -11,11 +11,19 @@ If you're interested in how this works, maybe have a look at [MiniPyFT8](https:/
|
|
|
11
11
|
- Doesn't try to do everything, so launches quickly (~2 seconds on my old Dell Optiplex 790)
|
|
12
12
|
- Use with or without gui (receive and send messages via command line commands)
|
|
13
13
|
- GUI provides simultaneous views of odd and even cycles
|
|
14
|
-
- GUI shows worked-before info on CQ calls
|
|
14
|
+
- GUI shows worked-before info on CQ calls
|
|
15
15
|
- Messages overlaid on waterfall signals that produce them
|
|
16
16
|
- Automatically chooses clearest Tx frequency
|
|
17
17
|
- Modern programming language throughout
|
|
18
18
|
- Finds sound cards by keywords so follows them if windows moves them ...
|
|
19
|
+
- Logs QSOs to ADIF file and all spots to WSJTX-style ALL.txt file
|
|
20
|
+
- Uploads spots to pskreporter
|
|
21
|
+
|
|
22
|
+
To enable uploading of spots to pskreporter, make sure that your .ini file includes
|
|
23
|
+
```
|
|
24
|
+
[pskreporter]
|
|
25
|
+
upload = Y
|
|
26
|
+
```
|
|
19
27
|
|
|
20
28
|
<img width="1003" height="1020" alt="image" src="https://github.com/user-attachments/assets/bf6e3f78-531a-4c9b-ab2b-b51cc04ad980" />
|
|
21
29
|
|
|
@@ -93,6 +101,7 @@ Note - section 9 of the QEX paper states that the above two WSJT-X resources are
|
|
|
93
101
|
**FT8 decoding explorations / explanations**
|
|
94
102
|
- [VK3JPK's FT8 notes](https://github.com/vk3jpk/ft8-notes) including comprehensive [Python source code](https://github.com/vk3jpk/ft8-notes/blob/master/ft8.py)
|
|
95
103
|
- [G4JNT notes on LDPC coding process](http://www.g4jnt.com/WSJT-X_LdpcModesCodingProcess.pdf)
|
|
104
|
+
- [Kristian Glass's 2025-05-06 Understanding the FT8 binary protocol](https://notes.doismellburning.co.uk/notebook/2025-05-06-understanding-the-ft8-binary-protocol/)
|
|
96
105
|
|
|
97
106
|
**FT8 decoding in hardware**
|
|
98
107
|
- [Optimizing the (Web-888) FT8 Skimmer Experience](https://www.rx-888.com/web/design/digi.html) (see also [RX-888 project](https://www.rx-888.com/) )
|
|
@@ -103,7 +112,8 @@ Note - section 9 of the QEX paper states that the above two WSJT-X resources are
|
|
|
103
112
|
- [Post about ft8play](https://groups.io/g/FT8-Digital-Mode/topic/i_made_a_thing_ft8play/107846361)
|
|
104
113
|
|
|
105
114
|
**Browser-based decoder/encoders**
|
|
106
|
-
- [ft8js](https://e04.github.io/ft8js/example/browser/index.html) - source [github](https://github.com/e04/ft8js?tab=readme-ov-file), uses [FT8_lib](https://github.com/kgoba/ft8_lib)
|
|
115
|
+
- [ft8js by e04](https://e04.github.io/ft8js/example/browser/index.html) - source [github](https://github.com/e04/ft8js?tab=readme-ov-file), uses web-assembled version of [FT8_lib](https://github.com/kgoba/ft8_lib)
|
|
116
|
+
- [ft8ts by e04](https://github.com/e04/ft8ts), - pure TypeScript implementation
|
|
107
117
|
- [ChromeFT8 Browser Extension](https://github.com/Transwarp8/ChromeFT8), decoder adapted from [ft8js](https://e04.github.io/ft8js/example/browser/index.html)
|
|
108
118
|
|
|
109
119
|
<script data-goatcounter="https://g1ojs-github.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|