def parse_telemetry_config(body): parsed = {} match = re.findall(r"^(PARM|UNIT|EQNS|BITS)\.(.*)$", body) if match: logger.debug("Attempting to parse telemetry-message packet") form, body = match[0] parsed.update({'format': 'telemetry-message'}) if form in ["PARM", "UNIT"]: vals = body.rstrip().split(',')[:13] for val in vals: if not re.match(r"^(.{1,20}|)$", val): raise ParseError("incorrect format of %s (name too long?)" % form) defvals = [''] * 13 defvals[:len(vals)] = vals parsed.update({ 't%s' % form: defvals }) elif form == "EQNS": eqns = body.rstrip().split(',')[:15] teqns = [0, 1, 0] * 5 for idx, val in enumerate(eqns): if not re.match(r"^([-]?\d*\.?\d+|)$", val): raise ParseError("value at %d is not a number in %s" % (idx+1, form)) else: try: val = int(val) except: val = float(val) if val != "" else 0 teqns[idx] = val # group values in 5 list of 3 teqns = [teqns[i*3:(i+1)*3] for i in range(5)] parsed.update({ 't%s' % form: teqns }) elif form == "BITS": match = re.findall(r"^([01]{8}),(.{0,23})$", body.rstrip()) if not match: raise ParseError("incorrect format of %s (title too long?)" % form) bits, title = match[0] parsed.update({ 't%s' % form: bits, 'title': title.strip(' ') }) return (body, parsed)
def validate_callsign(callsign, prefix=""): prefix = '%s: ' % prefix if bool(prefix) else '' match = re.findall(r"^([A-Z0-9]{1,6})(-(\d{1,2}))?$", callsign) if not match: raise ParseError("%sinvalid callsign" % prefix) callsign, _, ssid = match[0] if bool(ssid) and int(ssid) > 15: raise ParseError("%sssid not in 0-15 range" % prefix)
def parse_compressed(body): parsed = {} if re.match(r"^[\/\\A-Za-j][!-|]{8}[!-{}][ -|]{3}", body): logger.debug("Attempting to parse as compressed position report") if len(body) < 13: raise ParseError( "Invalid compressed packet (less than 13 characters)") parsed.update({'format': 'compressed'}) compressed = body[:13] body = body[13:] symbol_table = compressed[0] symbol = compressed[9] try: latitude = 90 - (base91.to_decimal(compressed[1:5]) / 380926.0) longitude = -180 + (base91.to_decimal(compressed[5:9]) / 190463.0) except ValueError: raise ParseError( "invalid characters in latitude/longitude encoding") # parse csT # converts the relevant characters from base91 c1, s1, ctype = [ord(x) - 33 for x in compressed[10:13]] if c1 == -1: parsed.update({'gpsfixstatus': 1 if ctype & 0x20 == 0x20 else 0}) if -1 in [c1, s1]: pass elif ctype & 0x18 == 0x10: parsed.update({'altitude': (1.002**(c1 * 91 + s1)) * 0.3048}) elif c1 >= 0 and c1 <= 89: parsed.update({'course': 360 if c1 == 0 else c1 * 4}) parsed.update({'speed': (1.08**s1 - 1) * 1.852 }) # mul = convert knts to kmh elif c1 == 90: parsed.update({'radiorange': (2 * 1.08**s1) * 1.609344 }) # mul = convert mph to kmh parsed.update({ 'symbol': symbol, 'symbol_table': symbol_table, 'latitude': latitude, 'longitude': longitude, }) return (body, parsed)
def parse_header(head): """ Parses the header part of packet Returns a dict """ try: (fromcall, path) = head.split('>', 1) except: raise ParseError("invalid packet header") if (not 1 <= len(fromcall) <= 9 or not re.findall(r"^[a-z0-9]{0,9}(\-[a-z0-9]{1,8})?$", fromcall, re.I)): raise ParseError("fromcallsign is invalid") path = path.split(',') if len(path[0]) == 0: raise ParseError("no tocallsign in header") tocall = path[0] path = path[1:] validate_callsign(tocall, "tocallsign") for digi in path: if not re.findall(r"^[A-Z0-9\-]{1,9}\*?$", digi, re.I): raise ParseError("invalid callsign in path") parsed = { 'from': fromcall, 'to': tocall, 'path': path, } viacall = "" if len(path) >= 2 and re.match(r"^q..$", path[-2]): viacall = path[-1] parsed.update({'via': viacall}) return parsed
def parse_weather(body): match = re.match("^(\d{8})c[\. \d]{3}s[\. \d]{3}g[\. \d]{3}t[\. \d]{3}", body) if not match: raise ParseError("invalid positionless weather report format") comment, weather = parse_weather_data(body[8:]) parsed = { 'format': 'wx', 'wx_raw_timestamp': match.group(1), 'comment': comment.strip(' '), 'weather': weather, } return ('', parsed)
def parse_telemetry(body): parsed = {} match = re.match(r'#([0-9.]{3,4}|MIC,?),([0-9.]{3,4}),([0-9.]{3,4}),([0-9.]{3,4}),([0-9.]{3,4}),([0-9.]{3,4}),([0-1]{8,9})(.*)', body, flags=re.I) if not match: raise ParseError("Invalid telemetry format") data = match.groups() parsed.update({ 'format': 'telemetry', 'sequence_number': 0 if data[0].lower().startswith('mic') else int(data[0][:3]), 'analog_values': list(map(float, data[1:6])), 'digital_value': int(data[6], 2) }) return '', parsed
def parse_mice(dstcall, body): parsed = {'format': 'mic-e'} dstcall = dstcall.split('-')[0] # verify mic-e format if len(dstcall) != 6: raise ParseError("dstcall has to be 6 characters") if len(body) < 8: raise ParseError("packet data field is too short") if not re.match(r"^[0-9A-Z]{3}[0-9L-Z]{3}$", dstcall): raise ParseError("invalid dstcall") if not re.match(r"^[&-\x7f][&-a][\x1c-\x7f]{2}[\x1c-\x7d]" r"[\x1c-\x7f][\x21-\x7e][\/\\0-9A-Z]", body): raise ParseError("invalid data format") # get symbol table and symbol parsed.update({ 'symbol': body[6], 'symbol_table': body[7] }) # parse latitude # the routine translates each characters into a lat digit as described in # 'Mic-E Destination Address Field Encoding' table tmpdstcall = "" for i in dstcall: if i in "KLZ": # spaces tmpdstcall += " " elif ord(i) > 76: # P-Y tmpdstcall += chr(ord(i) - 32) elif ord(i) > 57: # A-J tmpdstcall += chr(ord(i) - 17) else: # 0-9 tmpdstcall += i # determine position ambiguity match = re.findall(r"^\d+( *)$", tmpdstcall) if not match: raise ParseError("invalid latitude ambiguity") posambiguity = len(match[0]) parsed.update({ 'posambiguity': posambiguity }) # adjust the coordinates be in center of ambiguity box tmpdstcall = list(tmpdstcall) if posambiguity > 0: if posambiguity >= 4: tmpdstcall[2] = '3' else: tmpdstcall[6 - posambiguity] = '5' tmpdstcall = "".join(tmpdstcall) latminutes = float(("%s.%s" % (tmpdstcall[2:4], tmpdstcall[4:6])).replace(" ", "0")) latitude = int(tmpdstcall[0:2]) + (latminutes / 60.0) # determine the sign N/S latitude = -latitude if ord(dstcall[3]) <= 0x4c else latitude parsed.update({ 'latitude': latitude }) # parse message bits mbits = re.sub(r"[0-9L]", "0", dstcall[0:3]) mbits = re.sub(r"[P-Z]", "1", mbits) mbits = re.sub(r"[A-K]", "2", mbits) parsed.update({ 'mbits': mbits }) # resolve message type if mbits.find("2") > -1: parsed.update({ 'mtype': MTYPE_TABLE_CUSTOM[mbits.replace("2", "1")] }) else: parsed.update({ 'mtype': MTYPE_TABLE_STD[mbits] }) # parse longitude longitude = ord(body[0]) - 28 # decimal part of longitude longitude += 100 if ord(dstcall[4]) >= 0x50 else 0 # apply lng offset longitude += -80 if longitude >= 180 and longitude <= 189 else 0 longitude += -190 if longitude >= 190 and longitude <= 199 else 0 # long minutes lngminutes = ord(body[1]) - 28.0 lngminutes += -60 if lngminutes >= 60 else 0 # + (long hundredths of minutes) lngminutes += ((ord(body[2]) - 28.0) / 100.0) # apply position ambiguity # routines adjust longitude to center of the ambiguity box if posambiguity == 4: lngminutes = 30 elif posambiguity == 3: lngminutes = (math.floor(lngminutes/10) + 0.5) * 10 elif posambiguity == 2: lngminutes = math.floor(lngminutes) + 0.5 elif posambiguity == 1: lngminutes = (math.floor(lngminutes*10) + 0.5) / 10.0 elif posambiguity != 0: raise ParseError("Unsupported position ambiguity: %d" % posambiguity) longitude += lngminutes / 60.0 # apply E/W sign longitude = 0 - longitude if ord(dstcall[5]) >= 0x50 else longitude parsed.update({ 'longitude': longitude }) # parse speed and course speed = (ord(body[3]) - 28) * 10 course = ord(body[4]) - 28 quotient = int(course / 10.0) course += -(quotient * 10) course = course*100 + ord(body[5]) - 28 speed += quotient speed += -800 if speed >= 800 else 0 course += -400 if course >= 400 else 0 speed *= 1.852 # knots * 1.852 = kmph parsed.update({ 'speed': speed, 'course': course }) # the rest of the packet can contain telemetry and comment if len(body) > 8: body = body[8:] # check for optional 2 or 5 channel telemetry match = re.findall(r"^('[0-9a-f]{10}|`[0-9a-f]{4})(.*)$", body) if match: hexdata, body = match[0] hexdata = hexdata[1:] # remove telemtry flag channels = int(len(hexdata) / 2) # determine number of channels hexdata = int(hexdata, 16) # convert hex to int telemetry = [] for i in range(channels): telemetry.insert(0, int(hexdata >> 8*i & 255)) parsed.update({'telemetry': telemetry}) # check for optional altitude match = re.findall(r"^(.*)([!-{]{3})\}(.*)$", body) if match: body, altitude, extra = match[0] altitude = base91.to_decimal(altitude) - 10000 parsed.update({'altitude': altitude}) body = body + extra # attempt to parse comment telemetry body, telemetry = parse_comment_telemetry(body) parsed.update(telemetry) # parse DAO extention body = parse_dao(body, parsed) # rest is a comment parsed.update({'comment': body.strip(' ')}) return ('', parsed)
def parse(packet): """ Parses an APRS packet and returns a dict with decoded data - All attributes are in metric units """ if not isinstance(packet, string_type_parse): raise TypeError("Expected packet to be str/unicode/bytes, got %s", type(packet)) if len(packet) == 0: raise ParseError("packet is empty", packet) # attempt to detect encoding if isinstance(packet, bytes): packet = _unicode_packet(packet) packet = packet.rstrip("\r\n") logger.debug("Parsing: %s", packet) # split into head and body try: (head, body) = packet.split(':', 1) except: raise ParseError("packet has no body", packet) if len(body) == 0: raise ParseError("packet body is empty", packet) parsed = { 'raw': packet, } # parse head try: parsed.update(parse_header(head)) except ParseError as msg: raise ParseError(str(msg), packet) # parse body packet_type = body[0] body = body[1:] if len(body) == 0 and packet_type != '>': raise ParseError("packet body is empty after packet type character", packet) # attempt to parse the body try: _try_toparse_body(packet_type, body, parsed) # capture ParseErrors and attach the packet except (UnknownFormat, ParseError) as exp: exp.packet = packet raise # if we fail all attempts to parse, try beacon packet if 'format' not in parsed: if not re.match(r"^(AIR.*|ALL.*|AP.*|BEACON|CQ.*|GPS.*|DF.*|DGPS.*|" "DRILL.*|DX.*|ID.*|JAVA.*|MAIL.*|MICE.*|QST.*|QTH.*|" "RTCM.*|SKY.*|SPACE.*|SPC.*|SYM.*|TEL.*|TEST.*|TLM.*|" "WX.*|ZIP.*|UIDIGI)$", parsed['to']): raise UnknownFormat("format is not supported", packet) parsed.update({ 'format': 'beacon', 'text': packet_type + body, }) logger.debug("Parsed ok.") return parsed
def parse_position(packet_type, body): parsed = {} if packet_type not in '!=/@;': _, body = body.split('!', 1) packet_type = '!' if packet_type == ';': logger.debug("Attempting to parse object report format") match = re.findall(r"^([ -~]{9})(\*|_)", body) if match: name, flag = match[0] parsed.update({ 'object_name': name, 'alive': flag == '*', }) body = body[10:] else: raise ParseError("invalid format") else: parsed.update({"messagecapable": packet_type in '@='}) # decode timestamp if packet_type in "/@;": body, result = parse_timestamp(body, packet_type) parsed.update(result) if len(body) == 0 and 'timestamp' in parsed: raise ParseError("invalid position report format") # decode body body, result = parse_compressed(body) parsed.update(result) if len(result) > 0: logger.debug("Parsed as compressed position report") else: body, result = parse_normal(body) parsed.update(result) if len(result) > 0: logger.debug("Parsed as normal position report") else: raise ParseError("invalid format") # check comment for weather information # Page 62 of the spec if parsed['symbol'] == '_': logger.debug("Attempting to parse weather report from comment") body, result = parse_weather_data(body) parsed.update({ 'comment': body.strip(' '), 'weather': result, }) else: # decode comment parse_comment(body, parsed) if packet_type == ';': parsed.update({ 'object_format': parsed['format'], 'format': 'object', }) return ('', parsed)
def parse_normal(body): parsed = {} match = re.findall(r"^(\d{2})([0-9 ]{2}\.[0-9 ]{2})([NnSs])([\/\\0-9A-Z])" r"(\d{3})([0-9 ]{2}\.[0-9 ]{2})([EeWw])([\x21-\x7e])(.*)$", body) if match: parsed.update({'format': 'uncompressed'}) ( lat_deg, lat_min, lat_dir, symbol_table, lon_deg, lon_min, lon_dir, symbol, body ) = match[0] # position ambiguity posambiguity = lat_min.count(' ') if posambiguity != lon_min.count(' '): raise ParseError("latitude and longitude ambiguity mismatch") parsed.update({'posambiguity': posambiguity}) # we center the position inside the ambiguity box if posambiguity >= 4: lat_min = "30" lon_min = "30" else: lat_min = lat_min.replace(' ', '5', 1) lon_min = lon_min.replace(' ', '5', 1) # validate longitude and latitude if int(lat_deg) > 89 or int(lat_deg) < 0: raise ParseError("latitude is out of range (0-90 degrees)") if int(lon_deg) > 179 or int(lon_deg) < 0: raise ParseError("longitude is out of range (0-180 degrees)") """ f float(lat_min) >= 60: raise ParseError("latitude minutes are out of range (0-60)") if float(lon_min) >= 60: raise ParseError("longitude minutes are out of range (0-60)") The above is commented out intentionally apparently aprs.fi doesn't bound check minutes and there are actual packets that have >60min i don't even know why that's the case """ # convert coordinates from DDMM.MM to decimal latitude = int(lat_deg) + (float(lat_min) / 60.0) longitude = int(lon_deg) + (float(lon_min) / 60.0) latitude *= -1 if lat_dir in 'Ss' else 1 longitude *= -1 if lon_dir in 'Ww' else 1 parsed.update({ 'symbol': symbol, 'symbol_table': symbol_table, 'latitude': latitude, 'longitude': longitude, }) return (body, parsed)