Skip to content

Commit 28f8880

Browse files
committed
Added maidenhead packet parsing.
This patch adds maidenhead location packet parsing.
1 parent afbe99f commit 28f8880

File tree

4 files changed

+332
-5
lines changed

4 files changed

+332
-5
lines changed

aprslib/parsing/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ def detect(x):
5454
'-':'unused',
5555
'.':'reserved',
5656
'?':'general query format',
57-
'[':'maidenhead locator beacon',
5857
'\\':'unused',
5958
']':'unused',
6059
'^':'unused',
@@ -122,7 +121,7 @@ def parse(packet):
122121
packet_type = body[0]
123122
body = body[1:]
124123

125-
if len(body) == 0 and packet_type not in '><$':
124+
if len(body) == 0 and packet_type not in '><$[':
126125
raise ParseError("packet body is empty after packet type character", packet)
127126

128127
# attempt to parse the body
@@ -216,6 +215,12 @@ def _try_toparse_body(packet_type, body, parsed):
216215

217216
body, result = parse_raw_gps(body)
218217

218+
# Maidenhead locator beacon
219+
elif packet_type == '[':
220+
logger.debug("Attempting to parse as maidenhead locator beacon")
221+
222+
body, result = parse_maidenhead_locator(body)
223+
219224
# Item report
220225
elif packet_type == ')':
221226
logger.debug("Attempting to parse as item report")

aprslib/parsing/misc.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
'parse_user_defined',
99
'parse_station_capabilities',
1010
'parse_raw_gps',
11+
'parse_maidenhead_locator',
1112
]
1213

1314

@@ -299,3 +300,107 @@ def parse_raw_gps(body):
299300
parsed['ultimeter_extra'] = hex_data[52:]
300301

301302
return ('', parsed)
303+
304+
305+
# MAIDENHEAD LOCATOR BEACON
306+
#
307+
# [IO91SX]
308+
# [FN31pr]
309+
# [FN31pr45]
310+
# Format: [LOCATOR][SYMBOL][COMMENT]
311+
# LOCATOR: 4, 6, or 8 characters (2 letters + 2 digits + optional 2 letters + optional 2 digits)
312+
def parse_maidenhead_locator(body):
313+
"""
314+
Parses APRS maidenhead locator beacon format: [LOCATOR][SYMBOL][COMMENT]
315+
316+
Format:
317+
- [ indicates maidenhead locator beacon
318+
- LOCATOR: 4, 6, or 8 character maidenhead grid square
319+
- 4 chars: 2 letters + 2 digits (e.g., FN31)
320+
- 6 chars: 2 letters + 2 digits + 2 letters (e.g., FN31pr)
321+
- 8 chars: 2 letters + 2 digits + 2 letters + 2 digits (e.g., FN31pr45)
322+
- Optional symbol table and symbol code
323+
- Optional comment
324+
325+
Returns (remaining_body, parsed_dict)
326+
"""
327+
parsed = {
328+
'format': 'maidenhead-locator',
329+
}
330+
331+
if not body:
332+
return ('', parsed)
333+
334+
# Match maidenhead locator: 2 letters, 2 digits, optionally 2 letters, optionally 2 digits
335+
# Total: 4, 6, or 8 characters
336+
locator_match = re.match(r'^([A-R]{2})([0-9]{2})([A-X]{2})?([0-9]{2})?', body, re.IGNORECASE)
337+
if not locator_match:
338+
raise ParseError("invalid maidenhead locator format")
339+
340+
field = locator_match.group(1).upper() # First 2 letters (field)
341+
square = locator_match.group(2) # Next 2 digits (square)
342+
subsquare = locator_match.group(3).upper() if locator_match.group(3) else None # Optional 2 letters (subsquare)
343+
extended = locator_match.group(4) if locator_match.group(4) else None # Optional 2 digits (extended)
344+
345+
# Build the full locator string
346+
locator = field + square
347+
if subsquare:
348+
locator += subsquare
349+
if extended:
350+
locator += extended
351+
352+
parsed['locator'] = locator
353+
354+
# Determine precision
355+
if extended:
356+
parsed['locator_precision'] = 8
357+
elif subsquare:
358+
parsed['locator_precision'] = 6
359+
else:
360+
parsed['locator_precision'] = 4
361+
362+
# Consume the locator from body
363+
body = body[len(locator):]
364+
365+
# Check for closing bracket right after locator
366+
if body and body[0] == ']':
367+
body = body[1:]
368+
369+
# Check for symbol table and symbol (optional)
370+
# Symbol table is typically / or \ followed by a single symbol character
371+
# If / or \ is followed by text (space, letter, etc.), treat as part of comment
372+
if body and body[0] in '/\\':
373+
symbol_table = body[0]
374+
if len(body) > 1:
375+
next_char = body[1]
376+
# Check if next character looks like a symbol (single printable char, not space)
377+
# Symbols are typically single characters like -, _, ., etc.
378+
if next_char != ' ' and len(body) > 2 and body[2] not in ' ]':
379+
# Looks like text after /, not a symbol - treat / as part of comment
380+
pass # Don't parse as symbol
381+
else:
382+
# Single symbol character
383+
parsed['symbol_table'] = symbol_table
384+
parsed['symbol'] = next_char
385+
body = body[2:]
386+
# Check for closing bracket after symbol
387+
if body and body[0] == ']':
388+
body = body[1:]
389+
else:
390+
# Just symbol table, no symbol
391+
parsed['symbol_table'] = symbol_table
392+
body = body[1:]
393+
# Check for closing bracket
394+
if body and body[0] == ']':
395+
body = body[1:]
396+
397+
# Remaining body is comment
398+
# Strip closing bracket if present at the end
399+
if body:
400+
comment = body.strip(' ')
401+
# Remove trailing closing bracket if present
402+
if comment.endswith(']'):
403+
comment = comment[:-1].rstrip(' ')
404+
parsed['comment'] = comment
405+
406+
return ('', parsed)

aprslib/parsing/telemetry.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,15 +215,15 @@ def parse_telemetry_report(body):
215215
binary_match = re.match(r'^([01]+)', digital_field)
216216
if binary_match:
217217
binary_str = binary_match.group(1)
218-
218+
219219
# If there are non-binary characters after binary digits, require at least 4 binary digits
220220
# This prevents false positives like "123" (1 binary + 2 non-binary)
221221
if len(binary_str) < len(digital_field):
222222
# Has non-binary characters following
223223
if len(binary_str) < 4:
224224
# Too few binary digits before non-binary - likely invalid
225225
raise ParseError("telemetry digital I/O must be binary digits")
226-
226+
227227
# Extract leading binary digits
228228
if len(binary_str) < 8:
229229
# Pad shorter binary strings to 8 digits
@@ -233,7 +233,7 @@ def parse_telemetry_report(body):
233233
else:
234234
# Longer than 8, use first 8
235235
digital_str = binary_str[:8]
236-
236+
237237
# If there's non-binary content after the binary digits, treat as comment if no comment yet
238238
if len(binary_str) < len(digital_field) and not comment:
239239
remaining = digital_field[len(binary_str):]

tests/test_parse_maidenhead.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import unittest
2+
from aprslib.parsing import parse
3+
from aprslib.parsing.misc import parse_maidenhead_locator
4+
from aprslib.exceptions import ParseError
5+
6+
7+
class ParseMaidenheadLocator(unittest.TestCase):
8+
"""Test suite for APRS maidenhead locator beacon parsing"""
9+
10+
def test_valid_4_char_locator(self):
11+
"""Test 4-character maidenhead locator (field + square)"""
12+
packet = "TEST>APRS:[FN31]"
13+
result = parse(packet)
14+
15+
self.assertEqual(result['format'], 'maidenhead-locator')
16+
self.assertEqual(result['locator'], 'FN31')
17+
self.assertEqual(result['locator_precision'], 4)
18+
19+
def test_valid_6_char_locator(self):
20+
"""Test 6-character maidenhead locator (field + square + subsquare)"""
21+
packet = "TEST>APRS:[IO91SX]"
22+
result = parse(packet)
23+
24+
self.assertEqual(result['format'], 'maidenhead-locator')
25+
self.assertEqual(result['locator'], 'IO91SX')
26+
self.assertEqual(result['locator_precision'], 6)
27+
28+
def test_valid_8_char_locator(self):
29+
"""Test 8-character maidenhead locator (field + square + subsquare + extended)"""
30+
packet = "TEST>APRS:[FN31pr45]"
31+
result = parse(packet)
32+
33+
self.assertEqual(result['format'], 'maidenhead-locator')
34+
self.assertEqual(result['locator'], 'FN31PR45')
35+
self.assertEqual(result['locator_precision'], 8)
36+
37+
def test_locator_with_symbol(self):
38+
"""Test locator with symbol table and symbol"""
39+
packet = "TEST>APRS:[IO91SX/-]"
40+
result = parse(packet)
41+
42+
self.assertEqual(result['format'], 'maidenhead-locator')
43+
self.assertEqual(result['locator'], 'IO91SX')
44+
self.assertEqual(result['locator_precision'], 6)
45+
self.assertEqual(result['symbol_table'], '/')
46+
self.assertEqual(result['symbol'], '-')
47+
48+
def test_locator_with_comment(self):
49+
"""Test locator with comment"""
50+
packet = "TEST>APRS:[IO91SX]Test comment"
51+
result = parse(packet)
52+
53+
self.assertEqual(result['format'], 'maidenhead-locator')
54+
self.assertEqual(result['locator'], 'IO91SX')
55+
self.assertEqual(result['comment'], 'Test comment')
56+
57+
def test_locator_with_symbol_and_comment(self):
58+
"""Test locator with symbol and comment"""
59+
packet = "TEST>APRS:[IO91SX/-]Test comment"
60+
result = parse(packet)
61+
62+
self.assertEqual(result['format'], 'maidenhead-locator')
63+
self.assertEqual(result['locator'], 'IO91SX')
64+
self.assertEqual(result['symbol_table'], '/')
65+
self.assertEqual(result['symbol'], '-')
66+
self.assertEqual(result['comment'], 'Test comment')
67+
68+
def test_locator_with_slash_in_comment(self):
69+
"""Test locator where / is part of comment, not symbol table"""
70+
packet = "TEST>APRS:[FN31pr/Test comment]"
71+
result = parse(packet)
72+
73+
self.assertEqual(result['format'], 'maidenhead-locator')
74+
self.assertEqual(result['locator'], 'FN31PR')
75+
self.assertNotIn('symbol', result)
76+
self.assertEqual(result['comment'], '/Test comment')
77+
78+
def test_locator_case_insensitive(self):
79+
"""Test that locator letters are case-insensitive and normalized to uppercase"""
80+
packet = "TEST>APRS:[io91sx]"
81+
result = parse(packet)
82+
83+
self.assertEqual(result['locator'], 'IO91SX')
84+
self.assertEqual(result['locator_precision'], 6)
85+
86+
def test_locator_mixed_case(self):
87+
"""Test locator with mixed case letters"""
88+
packet = "TEST>APRS:[Fn31Pr]"
89+
result = parse(packet)
90+
91+
self.assertEqual(result['locator'], 'FN31PR')
92+
self.assertEqual(result['locator_precision'], 6)
93+
94+
def test_locator_with_backslash_symbol_table(self):
95+
"""Test locator with backslash symbol table"""
96+
packet = "TEST>APRS:[IO91SX\\-]"
97+
result = parse(packet)
98+
99+
self.assertEqual(result['format'], 'maidenhead-locator')
100+
self.assertEqual(result['locator'], 'IO91SX')
101+
self.assertEqual(result['symbol_table'], '\\')
102+
self.assertEqual(result['symbol'], '-')
103+
104+
def test_locator_no_closing_bracket(self):
105+
"""Test locator without closing bracket"""
106+
packet = "TEST>APRS:[IO91SX"
107+
result = parse(packet)
108+
109+
self.assertEqual(result['format'], 'maidenhead-locator')
110+
self.assertEqual(result['locator'], 'IO91SX')
111+
112+
def test_locator_empty_body(self):
113+
"""Test locator with empty body"""
114+
packet = "TEST>APRS:["
115+
result = parse(packet)
116+
117+
self.assertEqual(result['format'], 'maidenhead-locator')
118+
self.assertNotIn('locator', result)
119+
120+
def test_invalid_locator_too_short(self):
121+
"""Test that invalid locator (too short) raises error"""
122+
packet = "TEST>APRS:[FN3]"
123+
124+
with self.assertRaises(ParseError) as context:
125+
parse(packet)
126+
127+
self.assertIn("invalid maidenhead locator format", str(context.exception))
128+
129+
def test_invalid_locator_wrong_format(self):
130+
"""Test that invalid locator format raises error"""
131+
packet = "TEST>APRS:[1234]"
132+
133+
with self.assertRaises(ParseError) as context:
134+
parse(packet)
135+
136+
self.assertIn("invalid maidenhead locator format", str(context.exception))
137+
138+
def test_invalid_locator_letters_out_of_range(self):
139+
"""Test that locator with letters outside A-R range raises error"""
140+
packet = "TEST>APRS:[ZZ31]"
141+
142+
with self.assertRaises(ParseError) as context:
143+
parse(packet)
144+
145+
self.assertIn("invalid maidenhead locator format", str(context.exception))
146+
147+
def test_parse_maidenhead_locator_function_direct(self):
148+
"""Test parse_maidenhead_locator function directly"""
149+
body = "IO91SX/-]Comment"
150+
remaining, result = parse_maidenhead_locator(body)
151+
152+
self.assertEqual(remaining, '')
153+
self.assertEqual(result['format'], 'maidenhead-locator')
154+
self.assertEqual(result['locator'], 'IO91SX')
155+
self.assertEqual(result['locator_precision'], 6)
156+
self.assertEqual(result['symbol_table'], '/')
157+
self.assertEqual(result['symbol'], '-')
158+
self.assertEqual(result['comment'], 'Comment')
159+
160+
def test_parse_maidenhead_locator_4_char_direct(self):
161+
"""Test parse_maidenhead_locator function with 4-char locator"""
162+
body = "FN31]"
163+
remaining, result = parse_maidenhead_locator(body)
164+
165+
self.assertEqual(remaining, '')
166+
self.assertEqual(result['format'], 'maidenhead-locator')
167+
self.assertEqual(result['locator'], 'FN31')
168+
self.assertEqual(result['locator_precision'], 4)
169+
170+
def test_parse_maidenhead_locator_8_char_direct(self):
171+
"""Test parse_maidenhead_locator function with 8-char locator"""
172+
body = "FN31pr45]"
173+
remaining, result = parse_maidenhead_locator(body)
174+
175+
self.assertEqual(remaining, '')
176+
self.assertEqual(result['format'], 'maidenhead-locator')
177+
self.assertEqual(result['locator'], 'FN31PR45')
178+
self.assertEqual(result['locator_precision'], 8)
179+
180+
def test_locator_with_symbol_only_no_comment(self):
181+
"""Test locator with symbol but no comment"""
182+
packet = "TEST>APRS:[IO91SX/-]"
183+
result = parse(packet)
184+
185+
self.assertEqual(result['format'], 'maidenhead-locator')
186+
self.assertEqual(result['locator'], 'IO91SX')
187+
self.assertEqual(result['symbol_table'], '/')
188+
self.assertEqual(result['symbol'], '-')
189+
self.assertNotIn('comment', result)
190+
191+
def test_locator_with_trailing_bracket_in_comment(self):
192+
"""Test locator where closing bracket appears in comment"""
193+
packet = "TEST>APRS:[IO91SX/- Test]"
194+
result = parse(packet)
195+
196+
self.assertEqual(result['format'], 'maidenhead-locator')
197+
self.assertEqual(result['locator'], 'IO91SX')
198+
self.assertEqual(result['symbol_table'], '/')
199+
self.assertEqual(result['symbol'], '-')
200+
self.assertEqual(result['comment'], 'Test')
201+
202+
def test_real_world_winlink_packet(self):
203+
"""Test real-world packet from error log: WinLink RMS Packet Node"""
204+
packet = "J73GPG-10>WL2K,STSHD,WIDE2*,qAR,J73Z-10:[FK95HI] J73GPG-10 WinLink RMS Packet Node"
205+
result = parse(packet)
206+
207+
self.assertEqual(result['format'], 'maidenhead-locator')
208+
self.assertEqual(result['locator'], 'FK95HI')
209+
self.assertEqual(result['locator_precision'], 6)
210+
self.assertEqual(result['comment'], 'J73GPG-10 WinLink RMS Packet Node')
211+
# Verify header parsing
212+
self.assertEqual(result['from'], 'J73GPG-10')
213+
self.assertEqual(result['to'], 'WL2K')
214+
215+
216+
if __name__ == '__main__':
217+
unittest.main()

0 commit comments

Comments
 (0)