Skip to content

Commit 3dcceef

Browse files
committed
Added raw gps, item report and station
This patch adds raw gps parsing, item report parsing and station capabilities parsing.
1 parent 7776146 commit 3dcceef

File tree

6 files changed

+967
-9
lines changed

6 files changed

+967
-9
lines changed

aprslib/parsing/__init__.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,13 @@ def detect(x):
4646

4747
unsupported_formats = {
4848
'#':'raw weather report',
49-
'$':'raw gps',
5049
'%':'agrelo',
5150
'&':'reserved',
5251
'(':'unused',
53-
')':'item report',
5452
'*':'complete weather report',
5553
'+':'reserved',
5654
'-':'unused',
5755
'.':'reserved',
58-
'<':'station capabilities',
5956
'?':'general query format',
6057
'[':'maidenhead locator beacon',
6158
'\\':'unused',
@@ -125,7 +122,7 @@ def parse(packet):
125122
packet_type = body[0]
126123
body = body[1:]
127124

128-
if len(body) == 0 and packet_type != '>':
125+
if len(body) == 0 and packet_type not in '><$':
129126
raise ParseError("packet body is empty after packet type character", packet)
130127

131128
# attempt to parse the body
@@ -207,6 +204,24 @@ def _try_toparse_body(packet_type, body, parsed):
207204

208205
body, result = parse_telemetry_report(body)
209206

207+
# Station capabilities
208+
elif packet_type == '<':
209+
logger.debug("Attempting to parse as station capabilities")
210+
211+
body, result = parse_station_capabilities(body)
212+
213+
# Raw GPS
214+
elif packet_type == '$':
215+
logger.debug("Attempting to parse as raw GPS")
216+
217+
body, result = parse_raw_gps(body)
218+
219+
# Item report
220+
elif packet_type == ')':
221+
logger.debug("Attempting to parse as item report")
222+
223+
body, result = parse_position(packet_type, body)
224+
210225
# postion report (regular or compressed)
211226
elif (packet_type in '!=/@;' or
212227
0 <= body.find('!') < 40): # page 28 of spec (PDF)

aprslib/parsing/misc.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
'parse_status',
77
'parse_invalid',
88
'parse_user_defined',
9+
'parse_station_capabilities',
10+
'parse_raw_gps',
911
]
1012

1113

@@ -45,3 +47,255 @@ def parse_user_defined(body):
4547
'type': body[1],
4648
'body': body[2:],
4749
})
50+
51+
52+
# STATION CAPABILITIES
53+
#
54+
# <IGATE,MSG_CNT=n,LOC_CNT=n
55+
# Format: <TOKEN,TOKEN=VALUE,TOKEN=VALUE,...
56+
def parse_station_capabilities(body):
57+
"""
58+
Parses APRS station capabilities format: <TOKEN,TOKEN=VALUE,TOKEN=VALUE,...
59+
60+
Format:
61+
- < indicates station capabilities packet
62+
- Comma-separated list of capabilities
63+
- Each capability can be:
64+
- TOKEN (e.g., "IGATE")
65+
- TOKEN=VALUE (e.g., "MSG_CNT=123", "LOC_CNT=5")
66+
67+
Returns (remaining_body, parsed_dict)
68+
"""
69+
parsed = {
70+
'format': 'station-capabilities',
71+
'capabilities': {}
72+
}
73+
74+
if not body:
75+
return ('', parsed)
76+
77+
# Split by comma to get individual capabilities
78+
capabilities = body.split(',')
79+
80+
for cap in capabilities:
81+
cap = cap.strip()
82+
if not cap:
83+
continue
84+
85+
# Check if it's TOKEN=VALUE format
86+
if '=' in cap:
87+
parts = cap.split('=', 1)
88+
if len(parts) != 2:
89+
continue
90+
91+
token = parts[0].strip()
92+
value = parts[1].strip()
93+
94+
# Try to convert value to number if possible
95+
try:
96+
# Try integer first
97+
if value.isdigit() or (value.startswith('-') and value[1:].isdigit()):
98+
value = int(value)
99+
else:
100+
# Try float
101+
try:
102+
value = float(value)
103+
except ValueError:
104+
pass # Keep as string
105+
except (ValueError, AttributeError):
106+
pass # Keep as string
107+
108+
parsed['capabilities'][token] = value
109+
else:
110+
# It's just a TOKEN (boolean capability)
111+
parsed['capabilities'][cap] = True
112+
113+
return ('', parsed)
114+
115+
116+
# RAW GPS
117+
#
118+
# $GPRMC,...
119+
# $GPGGA,...
120+
# $ULTW...
121+
# Format: $<FORMAT><DATA>
122+
def parse_raw_gps(body):
123+
"""
124+
Parses APRS raw GPS format: $<FORMAT><DATA>
125+
126+
Format:
127+
- $ indicates raw GPS/data packet
128+
- Can be NMEA sentences (e.g., $GPRMC, $GPGGA, $GPGLL)
129+
- Can be proprietary formats (e.g., $ULTW for weather)
130+
- Data can be comma-separated (NMEA) or hex-encoded
131+
132+
Returns (remaining_body, parsed_dict)
133+
"""
134+
parsed = {
135+
'format': 'raw-gps',
136+
'raw_data': body
137+
}
138+
139+
if not body:
140+
return ('', parsed)
141+
142+
# Try to identify the format
143+
# NMEA sentences start with GP, GL, GN, etc. followed by sentence type (note: $ is already stripped)
144+
nmea_match = re.match(r'^([A-Z]{2})([A-Z]{3,5})(,.*)?$', body)
145+
if nmea_match:
146+
talker_id = nmea_match.group(1) # GP, GL, GN, etc.
147+
sentence_type = nmea_match.group(2) # RMC, GGA, GLL, etc.
148+
data = nmea_match.group(3) if nmea_match.group(3) else ''
149+
150+
# Clean the data (remove leading comma, strip checksum if present)
151+
clean_data = data.lstrip(',') if data else ''
152+
# Remove checksum if present (format: *HH)
153+
if '*' in clean_data:
154+
clean_data = clean_data[:clean_data.rfind('*')]
155+
156+
parsed.update({
157+
'nmea_talker': talker_id,
158+
'nmea_sentence': sentence_type,
159+
'nmea_data': clean_data
160+
})
161+
162+
# Try to parse common NMEA sentences
163+
if sentence_type == 'RMC' and clean_data:
164+
# Recommended Minimum Course
165+
# Format: $GPRMC,hhmmss.ss,A,llll.ll,a,yyyyy.yy,a,x.x,x.x,ddmmyy,x.x,a*hh
166+
parts = clean_data.split(',')
167+
if len(parts) >= 10:
168+
try:
169+
time_str = parts[0] if parts[0] else None
170+
status = parts[1] if len(parts) > 1 else None
171+
lat_str = parts[2] if len(parts) > 2 else None
172+
lat_dir = parts[3] if len(parts) > 3 else None
173+
lon_str = parts[4] if len(parts) > 4 else None
174+
lon_dir = parts[5] if len(parts) > 5 else None
175+
speed = parts[6] if len(parts) > 6 else None
176+
course = parts[7] if len(parts) > 7 else None
177+
date_str = parts[8] if len(parts) > 8 else None
178+
179+
if lat_str and lat_dir and lon_str and lon_dir:
180+
# Parse latitude (DDMM.MMMM format)
181+
lat_deg = float(lat_str[:2])
182+
lat_min = float(lat_str[2:])
183+
latitude = lat_deg + lat_min / 60.0
184+
if lat_dir == 'S':
185+
latitude = -latitude
186+
187+
# Parse longitude (DDDMM.MMMM format)
188+
lon_deg = float(lon_str[:3])
189+
lon_min = float(lon_str[3:])
190+
longitude = lon_deg + lon_min / 60.0
191+
if lon_dir == 'W':
192+
longitude = -longitude
193+
194+
parsed.update({
195+
'latitude': latitude,
196+
'longitude': longitude,
197+
})
198+
199+
if speed:
200+
try:
201+
parsed['speed'] = float(speed) * 1.852 # knots to km/h
202+
except (ValueError, TypeError):
203+
pass
204+
205+
if course:
206+
try:
207+
parsed['course'] = float(course)
208+
except (ValueError, TypeError):
209+
pass
210+
211+
if time_str and date_str:
212+
try:
213+
# Combine date and time for timestamp
214+
# Format: hhmmss.ss and ddmmyy
215+
parsed['nmea_time'] = time_str
216+
parsed['nmea_date'] = date_str
217+
except (ValueError, TypeError):
218+
pass
219+
220+
if status:
221+
parsed['gps_status'] = status # A = valid, V = invalid
222+
except (ValueError, IndexError, AttributeError):
223+
pass # If parsing fails, just store raw data
224+
225+
elif sentence_type == 'GGA' and clean_data:
226+
# Global Positioning System Fix Data
227+
# Format: $GPGGA,hhmmss.ss,llll.ll,a,yyyyy.yy,a,x,xx,x.x,x.x,M,x.x,M,x.x,xxxx*hh
228+
parts = clean_data.split(',')
229+
if len(parts) >= 10:
230+
try:
231+
time_str = parts[0] if parts[0] else None
232+
lat_str = parts[1] if len(parts) > 1 else None
233+
lat_dir = parts[2] if len(parts) > 2 else None
234+
lon_str = parts[3] if len(parts) > 3 else None
235+
lon_dir = parts[4] if len(parts) > 4 else None
236+
fix_quality = parts[5] if len(parts) > 5 else None
237+
num_satellites = parts[6] if len(parts) > 6 else None
238+
hdop = parts[7] if len(parts) > 7 else None
239+
altitude = parts[8] if len(parts) > 8 else None
240+
altitude_units = parts[9] if len(parts) > 9 else None
241+
242+
if lat_str and lat_dir and lon_str and lon_dir:
243+
# Parse latitude
244+
lat_deg = float(lat_str[:2])
245+
lat_min = float(lat_str[2:])
246+
latitude = lat_deg + lat_min / 60.0
247+
if lat_dir == 'S':
248+
latitude = -latitude
249+
250+
# Parse longitude
251+
lon_deg = float(lon_str[:3])
252+
lon_min = float(lon_str[3:])
253+
longitude = lon_deg + lon_min / 60.0
254+
if lon_dir == 'W':
255+
longitude = -longitude
256+
257+
parsed.update({
258+
'latitude': latitude,
259+
'longitude': longitude,
260+
})
261+
262+
if altitude and altitude_units == 'M':
263+
try:
264+
parsed['altitude'] = float(altitude)
265+
except (ValueError, TypeError):
266+
pass
267+
268+
if fix_quality:
269+
try:
270+
parsed['gps_fix_quality'] = int(fix_quality)
271+
except (ValueError, TypeError):
272+
pass
273+
274+
if num_satellites:
275+
try:
276+
parsed['gps_satellites'] = int(num_satellites)
277+
except (ValueError, TypeError):
278+
pass
279+
except (ValueError, IndexError, AttributeError):
280+
pass
281+
282+
# Check for proprietary formats like ULTW (4-letter format ID)
283+
elif re.match(r'^[A-Z]{4}', body) and len(body) >= 4:
284+
format_id = body[0:4]
285+
hex_data = body[4:] if len(body) > 4 else ''
286+
287+
parsed.update({
288+
'format_id': format_id,
289+
'hex_data': hex_data
290+
})
291+
292+
# ULTW is Ultimeter weather format
293+
if format_id == 'ULTW' and hex_data:
294+
parsed['ultimeter_format'] = True
295+
# ULTW data is 52 hex characters (13 fields of 4 hex chars each)
296+
if len(hex_data) >= 52:
297+
parsed['ultimeter_data'] = hex_data[:52]
298+
if len(hex_data) > 52:
299+
parsed['ultimeter_extra'] = hex_data[52:]
300+
301+
return ('', parsed)

aprslib/parsing/position.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,22 @@
1515
def parse_position(packet_type, body):
1616
parsed = {}
1717

18-
if packet_type not in '!=/@;':
19-
_, body = body.split('!', 1)
20-
packet_type = '!'
18+
# Handle item reports first (before the ! split logic)
19+
if packet_type == ')':
20+
logger.debug("Attempting to parse item report format")
21+
# Item name is 3-9 characters, followed by ! (live) or _ (kill)
22+
match = re.findall(r"^([!-~]{3,9})([!_])", body)
23+
if match:
24+
name, flag = match[0]
25+
parsed.update({
26+
'item_name': name,
27+
'alive': flag == '!',
28+
})
2129

22-
if packet_type == ';':
30+
body = body[len(name) + 1:]
31+
else:
32+
raise ParseError("invalid item report format")
33+
elif packet_type == ';':
2334
logger.debug("Attempting to parse object report format")
2435
match = re.findall(r"^([ -~]{9})(\*|_)", body)
2536
if match:
@@ -32,11 +43,14 @@ def parse_position(packet_type, body):
3243
body = body[10:]
3344
else:
3445
raise ParseError("invalid format")
46+
elif packet_type not in '!=/@':
47+
_, body = body.split('!', 1)
48+
packet_type = '!'
3549
else:
3650
parsed.update({"messagecapable": packet_type in '@='})
3751

3852
# decode timestamp
39-
if packet_type in "/@;":
53+
if packet_type in "/@;)":
4054
body, result = parse_timestamp(body, packet_type)
4155
parsed.update(result)
4256

@@ -80,6 +94,11 @@ def parse_position(packet_type, body):
8094
'object_format': parsed['format'],
8195
'format': 'object',
8296
})
97+
elif packet_type == ')':
98+
parsed.update({
99+
'item_format': parsed['format'],
100+
'format': 'item',
101+
})
83102

84103
return ('', parsed)
85104

0 commit comments

Comments
 (0)