APRS is widely used and inconsistently implemented. The spec is several documents written over twenty-odd years (APRS 1.0.1, the 1.1 and 1.2 addenda, now merged into APRS12c), real-world packets often bend the rules, and in places the text contradicts itself. Every decoder is somebody’s own reading of all that, and they disagree with each other.

I’ve written five APRS libraries, one each in C#, Rust, Python, TypeScript and C, plus a shared set of test cases. All five give the same answer for every packet I’ve tested them with. They’re all at version 1.0.1.

LanguagePackageRepository
C# (.NET)Packet.Aprs on NuGetpacket-net/aprs-cs
Rust (no_std + alloc)pdn-aprs on crates.iopacket-net/aprs-rs
Python 3.10+pdn-aprs on PyPIpacket-net/aprs-py
TypeScript (Node 20+, browsers)@packet-net/pdn-aprs on npmpacket-net/aprs-ts
C99, no allocationtwo files, from GitHub releasespacket-net/aprs-c
Test casesrelease zipspacket-net/aprs-vectors

Each library decodes and encodes every APRS 1.2 data type (positions plain and compressed, Mic-E, objects, items, weather, messages, telemetry, bulletins, status, queries, third-party, NMEA and the rest), from TNC2 / APRS-IS text or AX.25 frames.

Why it can be used as a reference

The rules are written down

The test cases live in aprs-vectors: 1,979 of them, in JSON, so any implementation in any language can use them. Each case gives a packet, what it means, what is wrong with it, and what a strict and a lenient decoder should each do with it. Each case also says where the answer comes from:

  • from the spec: the text of APRS12c or Understanding APRS Packets, usually an example printed there. Every example packet in both documents has a case.
  • from a recorded decision, where the spec is ambiguous, contradicts itself or is wrong. There are 49 of these, each with its reasoning, in interpretations.md.
  • from a real packet seen on APRS-IS, kept so that behaviour doesn’t change by accident.

When two implementations disagree, there’s a written rule to check them against.

Five implementations checked against each other

Tests written alongside one implementation tend to share its mistakes. Five separate implementations in five languages, held to the same cases and compared with each other, catch far more.

Beyond the test cases, I ran all five over the same input and compared the output byte for byte:

  • 11,094,522 packets captured from the APRS-IS feed;
  • millions of packets made by damaging real ones slightly (a changed byte, a cut-off line);
  • millions of generated records fed through every encoder and packet builder.

Each difference was settled from the spec where the spec covers it, and otherwise by a decision written into interpretations.md. Never by majority: sometimes one library was right and the other four wrong. Each settled difference became a new test case. This was repeated until a run on fresh random input found no differences at all. The final run, this week, was two million damaged packets with all five in agreement.

Faulty packets are reported

Real traffic has faults: lower-case hemisphere letters, object names without their padding, text after the weather data and so on. The libraries decode what they sensibly can and report each fault with a code from a shared list of 64. You choose how strict to be:

  • lenient (the default) accepts every fault it can make sense of, with a warning for each;
  • strict rejects them;
  • or each tolerable fault can be accepted or rejected individually.

Only a header too damaged to use is an error. Anything wrong after the header is reported as a diagnostic.

The encoder doesn’t change what you meant

Before returning a packet, each encoder decodes what it wrote and checks it matches the data it was given. If comment text would be read back as something else, such as an altitude, a frequency or a !DAO!, the encoder writes it in a form that reads back correctly (often just by adding a space) or refuses.

A refusal comes with one of 26 reason codes (too-long, value-out-of-range, invalid-addressee, text-reads-as-field and so on). They are the same in all five libraries, so a program can act on the reason without parsing the message text. The codes were added in 1.0.1.

Stable versions

For 1.0 the public API of each library was reviewed and tidied. The libraries share their version numbers with the test cases: a library at 1.0.x passes the test cases at 1.0. A new set of rulings is a minor release of all six together, a fix to one library is a patch release of that library, and a breaking API change would be a new major version.

Using it

Install from your language’s usual package manager:

dotnet add package Packet.Aprs        # C#
cargo add pdn-aprs                    # Rust
pip install pdn-aprs                  # Python
npm install @packet-net/pdn-aprs      # TypeScript / JavaScript

For C, download pdn_aprs.h and pdn_aprs.c from the latest release and add them to your build (CMake and pkg-config also work). An optional third file holds the device identification table.

The shape is the same everywhere. Decoding gives you a packet with its header, the raw information field, the decoded data and a list of diagnostics. Building starts from your station and says what you’re sending.

C#

using Packet.Aprs;

AprsPacket packet = AprsPacket.Decode("N0CALL-9>APZ001,WIDE1-1,qAR,M0LTE-10:!5130.00N/00007.00W>088/036/A=001234Hello");
if (packet.Data is AprsPositionReport p)
{
    Console.WriteLine(p.Symbol.Description);                          // Car
    Console.WriteLine($"{p.CourseDegrees} deg at {p.SpeedKnots} kn"); // 88 deg at 36 kn
}

AprsPacket beacon = AprsStation.From("M0LTE-9").Via("WIDE1-1", "WIDE2-1")
    .Position(51.4543, -0.9781)
    .Symbol(AprsSymbol.Car)
    .CourseDegrees(88).SpeedKnots(36).AltitudeFeet(120)
    .Comment("Mobile")
    .Build();
Console.WriteLine(beacon); // M0LTE-9>APZ001,WIDE1-1,WIDE2-1:!5127.26N/00058.69W>088/036/A=000120Mobile

Python

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

Strict, lenient, or lenient except for one defect:

>>> line = "N1EOE>APN391,N1NCI-3*,WIDE2-1:!4216.95n/07243.20w#phg6230/ Easthampton MA"
>>> [str(d) for d in aprs.decode_tnc2(line).diagnostics]
['warning:lowercase-hemisphere', 'warning:lowercase-hemisphere']
>>> aprs.decode_tnc2(line, aprs.ParseOptions.strict()).data.kind
'unrecognized'

TypeScript

import { Aprs, AprsEncodeError, Symbols } from '@packet-net/pdn-aprs';

const packet = Aprs.from('M0LTE-9')
  .via('WIDE1-1', 'WIDE2-1')
  .position(51.45, -0.98)
  .symbol(Symbols.car)
  .courseDegrees(88)
  .speedKnots(36)
  .comment('Mobile')
  .build();
console.log(packet.toTnc2()); // M0LTE-9>APZ001,WIDE1-1,WIDE2-1:!5127.00N/00058.80W>088/036Mobile
const frame = packet.toKiss(); // ready for a KISS TNC

try {
  Aprs.from('M0LTE').message('N0CALL', 'x'.repeat(68)).build();
} catch (e) {
  if (e instanceof AprsEncodeError) console.log(e.code); // too-long
}

Rust

use pdn_aprs::{Data, Packet, ParseOptions};

let packet = Packet::decode_tnc2(b"N0CALL-9>APZ001,WIDE1-1,qAR,M0LTE-10:=5130.00N/00007.00W>088/036Mobile", ParseOptions::default())?;
let Data::Position(report) = &packet.data else { panic!("a position") };
assert_eq!(report.fields.course_degrees, Some(88.0));
assert!(packet.diagnostics.is_empty());

It’s no_std with alloc and has no unsafe, so it runs on microcontrollers as well as servers.

C

No allocation, no global state, nothing locale-dependent; every input is a pointer and a length. It’s meant to be added to an existing node, IGate or digipeater.

pdn_aprs_station me = {.source = "M0LTE-9", .path = "WIDE1-1,WIDE2-1"};
pdn_aprs_position_args pos = {.messaging = 1, .latitude = 51.5, .longitude = -0.1167,
                              .symbol = PDN_APRS_SYMBOL_CAR, .comment = "Mobile"};
pdn_aprs_encoded result;
char line[600];

int n = pdn_aprs_build_position(&me, &pos, line, sizeof line, &result);
if (n < 0)
    printf("refused (%s): %s\n", pdn_aprs_refusal_name(result.refusal), result.reason);
else
    printf("%s\n", line);

Each library’s README has more, including worked examples for plotting an APRS-IS feed on a map, messaging with acks, telemetry, weather, Mic-E and the rest.

Using the test cases in your own implementation

If you maintain your own APRS decoder, you can use the test cases too. Add it as a git submodule, pinned to a release, and point your test runner at cases/ and codes.json:

git submodule add https://github.com/packet-net/aprs-vectors vectors

The README explains the case format and the neutral data form, and tools/ has the scripts I used to make damaged packets and compare implementations. If your implementation disagrees with a case, please open an issue: either the case is wrong, or the spec needs another decision written down.

Licences

The C#, Rust, Python and TypeScript libraries and the test cases are AGPL-3.0-or-later. The C library is MIT, so it can go into closed binaries; its optional device table is data from aprs-deviceid under CC BY-SA 2.0 and can be left out.

How it was built

Most of the code was written by Claude, with one agent per language, a separate reviewer on every pull request, and the comparison runs on my own machines. Most of the effort went into the rulings rather than the code: reading what the spec says, writing the decision down and turning it into a test case. The working notes from each round are on the exact-bytes-notes branch of aprs-vectors.

The C# library is already used by packet.net and the ax25 tools. Issues and pull requests are welcome on any of the repositories.