-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathunpack.py
More file actions
280 lines (243 loc) · 8.67 KB
/
Copy pathunpack.py
File metadata and controls
280 lines (243 loc) · 8.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
"""
Stage 4: unpack77 - the inverse of pack77 from ft8_encoder.py.
Uses EXACTLY the same tables (verified bit-exact against the real
ft8code.exe), just in the reverse direction.
"""
import ft8_encoder as fe
_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
# Alphabet used for hashing non-standard callsigns (39 characters:
# space + 0-9 + A-Z + '/'). Verified bit-exact against two authoritative
# examples from the wsjt-devel mailing list (YW18FIFA -> 771524,
# VK0MUCHTOOLONGCALLSIGN -> 1137640).
_HASH_ALPHABET = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/"
# 38-character alphabet for packing a non-standard callsign into the c58
# field (Type 4). The same set as _HASH_ALPHABET (space + 0-9 + A-Z + '/').
_C58_ALPHABET = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/"
def _unpack58(n58):
"""
Unpacks the 58-bit c58 field (Type 4) into a non-standard callsign.
The inverse of WSJT-X's packing: an 11-character string built from
successive remainders of division by 38, from the end. Verified
bit-exact on a real HB10GBT signal from the 15m band (n58=55151927106
-> 'HB10GBT').
"""
chars = []
for _ in range(11):
chars.append(n58 % 38)
n58 //= 38
return ''.join(_C58_ALPHABET[i] for i in reversed(chars)).strip()
def ihashcall(callsign, m=22):
"""
Computes an m-bit callsign hash per the official FT8/FT4 protocol spec
(bit-exact compatibility is required to interoperate correctly with
other stations on the air).
Used to build the hash->callsign table: when we see a FULL callsign in
one frame, we remember its hash, so we can substitute the real text
when the same hash shows up in another (non-standard) frame.
"""
c0 = callsign.upper().strip().ljust(11)[:11]
n8 = 0
for ch in c0:
j = _HASH_ALPHABET.find(ch)
if j < 0:
j = 0
n8 = 38 * n8 + j
prod = (47055833459 * n8) & ((1 << 64) - 1)
return prod >> (64 - m)
class HashCallCache:
"""
A simple hash22 -> callsign table, built up over time (analogous to
real WSJT-X). Not persisted across server restarts — the same
behavior as WSJT-X (the table resets when the program starts).
"""
def __init__(self, max_size=2000):
self._map = {}
self._max_size = max_size
def remember(self, callsign):
if not callsign or callsign.startswith('<') or '_' in callsign:
return
h = ihashcall(callsign, 22)
if len(self._map) >= self._max_size and h not in self._map:
self._map.pop(next(iter(self._map)))
self._map[h] = callsign
def lookup(self, hash_val):
return self._map.get(hash_val)
# Module-global cache (one server process = one listening session, matching
# WSJT-X's behavior)
_hash_cache = HashCallCache()
def _bits_to_int(bits):
v = 0
for b in bits:
v = (v << 1) | b
return v
def _unpack_c28(c28):
"""The inverse of _encode_c28/_packcall. Returns the callsign string (or a special token)."""
if c28 == 0:
return "DE"
if c28 == 1:
return "QRZ"
if c28 == fe._NBASE_CQ:
return "CQ"
if 3 <= c28 < 3 + 1000000:
# CQ + numeric/alphanumeric suffix - rarely used, details skipped
return f"CQ_{c28}"
NTOKENS = 2063592
if NTOKENS <= c28 < NTOKENS + 4194304:
hash_val = c28 - NTOKENS
known = _hash_cache.lookup(hash_val)
if known:
return f"<{known}>"
return "<...>"
if c28 >= fe._STD_CALL_OFFSET:
x = c28 - fe._STD_CALL_OFFSET
# x = a0*36*10*27^3 + a1*10*27^3 + a2*27^3 + a3*27^2 + a4*27 + a5
a5 = x % 27
x //= 27
a4 = x % 27
x //= 27
a3 = x % 27
x //= 27
a2 = x % 10
x //= 10
a1 = x % 36
x //= 36
a0 = x
def ch_pos0(v):
if v == 0:
return ' '
if 1 <= v <= 10:
return str(v - 1)
return _LETTERS[v - 11]
def ch_pos1(v):
if 0 <= v <= 9:
return str(v)
return _LETTERS[v - 10]
def ch_suffix(v):
if v == 0:
return ' '
return _LETTERS[v - 1]
call = ch_pos0(a0) + ch_pos1(a1) + str(a2) + ch_suffix(a3) + ch_suffix(a4) + ch_suffix(a5)
return call.strip()
return f"<unknown:{c28}>"
def _ng_to_grid(ng):
"""The inverse of _grid_to_ng: ng (0..32399) -> a 4-character grid (positional numeral system)."""
c3 = ng % 10
ng //= 10
c2 = ng % 10
ng //= 10
c1 = ng % 18
ng //= 18
c0 = ng % 18
return chr(ord('A') + c0) + chr(ord('A') + c1) + str(c2) + str(c3)
def _unpack_g15(g15):
"""
The inverse of _encode_g15. The encoder encodes (verified against WSJT-X):
4-character grid -> 0..32399
"" -> 32401, "RRR" -> 32402, "RR73" -> 32403, "73" -> 32404
+/-NN report (-30..+49) -> g15 = 32400 + (report + 35) [r_flag=False]
R+/-NN -> g15 = 32400 + (report + 35) [r_flag=True]
The R bit (R1) distinguishes an R-prefixed report from a bare one — it
is handled by the caller (format_message adds R when R1=1); here we
return just the report.
"""
if g15 == 0:
return ""
if g15 < 32400:
return _ng_to_grid(g15)
if g15 == 32401:
return ""
if g15 == 32402:
return "RRR"
if g15 == 32403:
return "RR73"
if g15 == 32404:
return "73"
# numeric reports: g15 = 32400 + (report + 35) => report = g15 - 32435
if 32405 <= g15 <= 32484:
rpt = g15 - 32435
return f"{rpt:+03d}"
return f"<g15:{g15}>"
def unpack77(bits77):
"""
bits77: a list/array of 77 bits (0/1).
Returns a dict: {i3, call_to, call_de, r1, report_or_grid, R1}
"""
bits = list(bits77)
assert len(bits) == 77
data74 = bits[0:74]
i3 = _bits_to_int(bits[74:77])
# --- Type 4: a message with a non-standard (long/compound) callsign ---
# Structure (verified on a real HB10GBT signal, 15m):
# bits[0:12] = h12 - 12-bit hash of the SECOND callsign (recipient/partner)
# bits[12:70] = c58 - 58-bit non-standard callsign (sender)
# bits[70] = iflip- 0: sender=c58; 1: roles swapped
# bits[71:73] = nrpt - content code (0:none,1:RRR,2:RR73,3:73)
# bits[73] = icq - 1: a CQ message
# bits[74:77] = i3=4
if i3 == 4:
h12 = _bits_to_int(bits[0:12])
n58 = _bits_to_int(bits[12:70])
iflip = bits[70]
nrpt = _bits_to_int(bits[71:73])
icq = bits[73]
nonstd_call = _unpack58(n58)
# remember the full non-standard callsign under its 12-bit hash,
# so we can substitute it when it shows up as just a hash in another frame
if nonstd_call and not nonstd_call.startswith('<'):
_hash_cache.remember(nonstd_call)
# h12 refers to the PARTNER; try to find the full callsign from its 12-bit hash
partner = None
for known in list(_hash_cache._map.values()):
if ihashcall(known, 12) == h12:
partner = known
break
partner_txt = partner if partner else (f"<...{h12}>" if h12 else "")
if icq:
call_to, call_de = "CQ", nonstd_call
elif iflip:
call_to, call_de = nonstd_call, partner_txt
else:
call_to, call_de = partner_txt, nonstd_call
rpt_map = {0: "", 1: "RRR", 2: "RR73", 3: "73"}
return {
"i3": i3,
"call_to": call_to,
"call_de": call_de,
"r1_1": 0,
"r1_2": 0,
"R1": 0,
"report_or_grid": rpt_map.get(nrpt, ""),
}
c28_1 = _bits_to_int(data74[0:28])
r1_1 = data74[28]
c28_2 = _bits_to_int(data74[29:57])
r1_2 = data74[57]
R1 = data74[58]
g15 = _bits_to_int(data74[59:74])
call_to = _unpack_c28(c28_1)
call_de = _unpack_c28(c28_2)
grid_or_report = _unpack_g15(g15)
# Train the cache: remember every FULL (non-hash, non-special) callsign
# seen in this frame, so it can be substituted for the same hash in
# future non-standard frames.
for call in (call_to, call_de):
if call and call not in ("CQ", "DE", "QRZ") and not call.startswith("<") and not call.startswith("CQ_"):
_hash_cache.remember(call)
return {
"i3": i3,
"call_to": call_to,
"call_de": call_de,
"r1_1": r1_1,
"r1_2": r1_2,
"R1": R1,
"report_or_grid": grid_or_report,
}
def format_message(parsed):
p = parsed
parts = [p["call_to"], p["call_de"]]
rg = p["report_or_grid"]
if p["R1"] and rg and not rg.startswith("R"):
rg = "R" + rg
if rg:
parts.append(rg)
return " ".join(parts)