Skip to main content

pdn-aprs

CI PyPI

An APRS (Automatic Packet Reporting System) decoder and encoder for Python, checked against the language-neutral conformance vectors in packet-net/aprs-vectors.

  • Decodes TNC2 / APRS-IS lines (str or bytes), AX.25 UI frames and KISS frames, into a packet with its header, the raw information field, the decoded data and diagnostics.
  • Every data type in the APRS 1.2 specification: positions (plain, compressed, with timestamps, weather, DF, storms, area objects), Mic-E, objects, items, messages, acks, bulletins, telemetry and its metadata, status, queries, capabilities, NMEA, raw weather, third-party, user-defined.
  • Lenient by default, strict on request, and every tolerable defect can be accepted or rejected on its own.
  • An encoder for every type that writes only what the specification allows, and a Station builder for the packets an application typically sends.
  • Every symbol by name, and device identification from the aprs-deviceid database.
  • Pure Python 3.10+, no dependencies, fully typed.

It is one of several independent implementations that cross-check each other: Packet.Aprs (C#, in packet-net/packet.net) and pdn-aprs for Rust (packet-net/aprs-rs) pass the same vectors and agree with this one on every packet of a 6.9-million-packet APRS-IS capture.

Install

pip install pdn-aprs

The import name is pdn_aprs.

Decoding

>>> import pdn_aprs as aprs
>>> packet = aprs.decode("M0LTE-9>APDR16,WIDE1-1,qAR,M0LTE-10:=5127.00N/00058.80W>088/036/A=000123Mobile")
>>> packet.source, packet.destination, packet.q_construct
('M0LTE-9', 'APDR16', QConstruct(construct='qAR', station='M0LTE-10'))
>>> packet.data
PositionReport(latitude=51.45, longitude=-0.98, symbol=Symbol('/>'), course_degrees=88, speed_knots=36, altitude_feet=123, comment='Mobile', messaging=True)
>>> packet.data.symbol.description
'Car'
>>> print(packet.device)
Open Source APRSdroid

Fields keep the units APRS sends, with the unit in the name (speed_knots, altitude_feet, temperature_f). packet.data is always there: when nothing could be decoded it is an Unrecognized, and the diagnostics say why.

>>> packet = aprs.decode("N0CALL>APZ001:@092345z4903.50N/07201.75W_220/004g005t-07r000p000P000h50b09900wRSW")
>>> weather = packet.data.weather
>>> weather.wind_direction_degrees, weather.wind_speed_mph, weather.temperature_f, weather.pressure_mbar
(220, 4, -7, 990)

Mic-E reports carry half their position in the destination address; the device that sent one is identified by its type code and suffix:

>>> packet = aprs.decode("N1JCM-9>TRQP7T,WA1PLE-4*:`c'wl|+>/`\"4-}_%")
>>> packet.data.latitude, packet.data.longitude, packet.data.mic_e_message
(42.179, -71.1985, <MicEMessage.OFF_DUTY: 'off-duty'>)
>>> print(packet.device)
Yaesu FTM-400DR

AX.25 frames (the KISS payload, without flags or FCS) and whole KISS frames decode too:

>>> frame = bytes.fromhex("82A0B4606062E09A6098A88A40729A6098A88A40E2AE92888A64406303F03E68656C6C6F")
>>> print(aprs.decode_ax25(frame))
M0LTE-9>APZ001,M0LTE-1*,WIDE2-1:>hello

Defects: lenient, strict, or in between

Real packets have defects. By default the decoder accepts every defect it can make sense of, with a warning; a strict decoder rejects the packet instead. Each defect has a code from the vectors' codes.json, and each tolerable one can be accepted or rejected on its own.

>>> line = "N1EOE>APN391,N1NCI-3*,WIDE2-1:!4216.95n/07243.20w#phg6230/ Easthampton MA"
>>> packet = aprs.decode(line)
>>> packet.data.latitude, [str(d) for d in packet.diagnostics]
(42.2825, ['warning:lowercase-hemisphere', 'warning:lowercase-hemisphere'])
>>> strict = aprs.decode(line, aprs.ParseOptions.strict())
>>> strict.data, [str(d) for d in strict.diagnostics]
(Unrecognized(reason=<UnrecognizedReason.MALFORMED: 'malformed'>), ['error:lowercase-hemisphere'])
>>> options = aprs.ParseOptions.lenient().without(aprs.DiagnosticCode.LOWERCASE_HEMISPHERE)
>>> aprs.decode(line, options).data.kind
'unrecognized'

A header that cannot be read at all raises HeaderError, whose diagnostics say why.

Building packets

A Station holds what stays the same from packet to packet, and has a method for each kind of packet an application typically sends. Each returns an encoded Packet: str(packet) is its TNC2 line, packet.to_kiss() its KISS frame.

>>> from pdn_aprs import Station, Symbol
>>> m0lte = Station("M0LTE-9", via="WIDE1-1,WIDE2-1", symbol=Symbol.CAR, messaging=True)
>>> print(m0lte.position(51.45, -0.98, course=88, speed=36, comment="Mobile"))
M0LTE-9>APZ001,WIDE1-1,WIDE2-1:=5127.00N/00058.80W>088/036Mobile
>>> print(m0lte.position(51.45, -0.98, compressed=True, frequency=145.5, tone=77))
M0LTE-9>APZ001,WIDE1-1,WIDE2-1:=/4M<<N7Jo> sT145.500MHz T077
>>> print(m0lte.mic_e(51.4575, -0.9811, speed=12, course=94))
M0LTE-9>U1RWTU,WIDE1-1,WIDE2-1:`vVsm4z>/`
>>> print(m0lte.message("G4ABC", "See you at the club", message_id="42"))
M0LTE-9>APZ001,WIDE1-1,WIDE2-1::G4ABC    :See you at the club{42
>>> print(m0lte.ack("G4ABC", "7"))
M0LTE-9>APZ001,WIDE1-1,WIDE2-1::G4ABC    :ack7
>>> print(m0lte.object("LEADER", 49.0583, -72.0292, timestamp=aprs.Timestamp.dhm(9, 23, 45), course=88, speed=36))
M0LTE-9>APZ001,WIDE1-1,WIDE2-1:;LEADER   *092345z4903.50N/07201.75W>088/036
>>> wx = Station("M0LTE-13")
>>> print(wx.weather(51.45, -0.98, wind_direction=220, wind_speed=4, gust=5, temperature=58, humidity=81, pressure=1013.2))
M0LTE-13>APZ001:!5127.00N/00058.80W_220/004g005t058h81b10132
>>> print(wx.telemetry(5, [199, 0, 255, 73, 123], "01101001"))
M0LTE-13>APZ001:T#005,199,000,255,073,123,01101001
>>> print(wx.telemetry_names(["Battery", "Temp"]))
M0LTE-13>APZ001::M0LTE-13 :PARM.Battery,Temp

The destination defaults to APZ001, the experimental tocall; give your application's own with Station(..., destination="APxxxx"). Quantities are in APRS's units: degrees, knots and feet, and for weather mph, degrees Fahrenheit, inches and millibars.

Encoding

Any decoded or constructed data can be encoded. The encoder writes only what the specification allows and raises EncodeError otherwise. It checks free text by decoding what it wrote: a comment that would read back as something else gets a / delimiter, or is refused.

>>> from pdn_aprs import PositionReport, encode_info
>>> report = PositionReport(latitude=49.0583333, longitude=-72.0291667, symbol=Symbol.HOUSE, comment="123/456")
>>> encode_info(report)
b'!4903.50N/07201.75W-/123/456'
>>> encode_info(PositionReport(latitude=49.0583333, longitude=-72.0291667, symbol=Symbol.HOUSE, comment="/A=001234"))
Traceback (most recent call last):
    ...
pdn_aprs.errors.EncodeError: the text '/A=001234' would not read back as written
>>> encode_info(aprs.Message("N0CALL", "x" * 68))
Traceback (most recent call last):
    ...
pdn_aprs.errors.EncodeError: message text is over 67 characters

build_packet(source, data, destination=..., path=...) gives a whole packet, and computes a Mic-E report's destination.

Symbols

Every symbol the APRS symbol tables define is available by name, with its description; the names match Packet.Aprs's.

>>> Symbol.CAR, Symbol.WEATHER_STATION, Symbol.DIGIPEATER
(Symbol('/>'), Symbol('/_'), Symbol('/#'))
>>> Symbol.OVERLAY_DIGIPEATER.with_overlay("S")
Symbol('S#')
>>> Symbol.parse("S#").description
'Digi (green star)'
>>> Symbol.from_name("fire truck")
Symbol('/f')

The neutral form

pdn_aprs.neutral converts data to and from the conformance vectors' neutral form: plain dicts and lists, ready for JSON.

>>> from pdn_aprs.neutral import to_neutral
>>> to_neutral(aprs.decode("N0CALL>APZ001::WU2Z     :Testing{003").data)
{'type': 'message', 'addressee': 'WU2Z', 'text': 'Testing', 'message_id': '003'}

Conformance

The vectors are a git submodule at vectors/, and tests/test_vectors.py runs every check their README defines for every case, one parametrised test per check with the case id as the test id: the lenient decoding, the strict one, the single-tolerance check, the re-encoding, and the encode cases. All 5,574 checks pass. A check that should be skipped goes in tests/known_differences.json with its reason; there are none.

tools/diff_dump.py decodes a whole capture and writes the JSONL that the vectors' tools/compare.py reads, spread over all cores (a 6.9-million-line capture takes about two minutes):

python3 tools/diff_dump.py lines.hex.gz py.jsonl.gz
python3 vectors/tools/compare.py py.jsonl.gz rs.jsonl.gz --names Python Rust --lines lines.hex.gz

On the 6,879,893-packet APRS-IS capture the other implementations were compared on, this one agrees with both on every packet: lenient and strict decoding and re-encoding.

Development

git clone --recurse-submodules https://github.com/packet-net/aprs-py
cd aprs-py
python3 -m venv .venv && . .venv/bin/activate
pip install -e . pytest mypy ruff
pytest                # the vectors, the unit tests and this README's examples
ruff check . && ruff format --check .
mypy

tools/generate_devices.py regenerates the device database from an aprs-deviceid checkout.

Licence

AGPL-3.0-or-later; see LICENSE. The device identification data comes from the aprs-deviceid database, under CC BY-SA 2.0; see THIRD-PARTY-NOTICES.md.

Metadata

Release files for pdn-aprs 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pdn-aprs 0.1.1
File Size Uploaded
pdn_aprs-0.1.1.tar.gz 123.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pdn-aprs 0.1.1
File Interpreter ABI Platform
pdn_aprs-0.1.1-py3-none-any.whl Python 3 none any Details

Total release size: 216.4 kB

Release files / pdn_aprs-0.1.1.tar.gz

Download URL pdn_aprs-0.1.1.tar.gz
Size 123.0 kB
Tags Source
SHA-256 checksum
How to use checksums
53ba69721b11d145a7939eef3d31d9eae3713567e9fe270f20d1af3fff21ed73
BLAKE2b-256 checksum
How to use checksums
b3e13628ec4ff9d29cfe00dc97aaa4cafdb8aff01c4eb82b3cbe69db9874da0d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

Release files / pdn_aprs-0.1.1-py3-none-any.whl

Download URL pdn_aprs-0.1.1-py3-none-any.whl
Size 93.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f5ae2610dd4c14fb4ba0b16f9df9b9d0c7836f27d092812e8f3b5c9e52dcf187
BLAKE2b-256 checksum
How to use checksums
92fdd4ee43bf17795bb47380e2c6ccdf4017727e17300d4c8823ef0046fd447f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

Release history Release notifications | RSS feed

0.2.0

2 release files

This release

0.1.1 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page