Published on

APRS & AX.25 Demystified: Full Packet Generation and Frame Encoding

Overview


Introduction

Documentation for APRS and AX.25 is very terse. Implementing simple projects can be incredibly difficult if you are not familiar will every little detail of each level. A full implementation requires a bit of knowledge in APRS, AX.25/HDLC, and AFSK. I'm hoping that this example will help someone fill in the final gaps for some while attempting to implement APRS/AX.25 UI frames.

This article series will take a look at a single APRS packet. We'll encode the APRS data and then we'll pack it into an AX.25 UI frame.

aprs-ax25-flowchart

APRS Packet Generation

The APRS portion is incredibly straight forward, it's all printable ASCII.

The specific type of APRS packet that will be used here is the compressed location packet. There any many other types of APRS packets, but most of the information here still applies. To find out more about other packet options check out the full APRS specification document.

Overall, an APRS packet will contain the following information:

Source Call Sign & SSID
Destination Call Sign & SSID
Digipeater Path (optional)
Information Field

APRS Address Fields

First, we need a few bits of information to generate a generic APRS packet.

Source Call Sign & SSID

  • The source call sign is your call sign. For example, my call sign, KD9GDC. In this guide we'll use NOCALL.
  • The SSID can be a number from 0 to 15, checkout the APRS 1.1 SSID Addendum for more information about what SSID to choose.
  • For the SSID, we'll use the generic option of 1.(APRS SSID Recommendations, 2012)

Destination Call Sign & SSID

  • The destination call sign is a bit more complex, For now, we'll just go with APRS1 with an SSID of 0.

Digipeater Path (optional)

  • For the digipeater path, we'll go with WIDE1-1.
  • It's not worth diving into digipeater paths here, checkout this great guide from aprs.fi, posted by Hessu.

APRS Information Field

For the compressed location packet, we need some more information:

  • Desired Map Symbol: Balloon
  • Time in Zulu: 9:23:45
  • Latitude, Longitude: 40.3392208, -73.6247931
  • Speed: 42 Knots
  • Bearing: 176 Degrees
  • Altitude: 88132 Feet
  • Comment: Hello World!

The following table covers the format of the information field. We'll generate this piece by piece.

aprs-location

Data Type Identifier

Every APRS packet's information field starts with a APRS Data Type Identifier. This will be @ as we want to encode the position with a timestamp and APRS message.(APRS 1.0 Specification, 2000, p. 17)

@

Zulu Time

Next, the time needs to be formatted. This fairly simple as the format is HHMMSSz. So 9:23:45 turns into 092345z.(APRS 1.0 Specification, 2000, p. 22)

@092345z

Symbol Table Identifier

This will either be / or \ referring to the primary or alternate symbol table respectively.(APRS Symbol Table 1.1, 2015) The balloon symbol is /O, so this will be /.

Checkout www.aprs.org/symbols.html for the full symbol table.

@092345z/

Compressed Latitude and Longitude

Compressed location data is base 91 encoded, allowing it to fit within 8 ASCII characters.

It's calculated in the following way:

lat_base10 = 380926 x (90 - latitude)

lon_base10 = 190463 x (180 + longitude)

It's then converted into a base 91 number. Each base 91 symbol must be offset by the value 33 so that it will be printable ascii.(APRS 1.0 Specification, 2000, p. 38)

Example:

Latitude:
380926 * (90 - 40.3392208) = 18917081

18917081 / 91^3 = 25, remainder 77806
77806 / 91^2 = 9, remainder 3277
3277 / 91^1 = 36, remainder 1

25 + 33 = :
9 + 33 = *
36 + 33 = E
1 + 33 = "

Final :*E"

Longitude:
190463 * (180 + -73.6247931) = 20260541

20260541 / 91^3 = 26, remainder 667695
667695 / 91^2 = 80, remainder 5215
5215 / 91^1 = 57, remainder 28

26 + 33 = ;
80 + 33 = q
57 + 33 = Z
28 + 33 = =

Final ;qZ=

Here's a semi-readable Python script to encode the position: aprs_lat_lon_encode.py

Now that we have our compressed latitude and longitude, which are both 4 characters wide, we can put this into our packet.

@092345z/:*E";qZ=

Symbol Code

Going back to the symbol that we chose earlier, /O, we now need to add the O.

@092345z/:*E";qZ=O

Course & Speed

The next 2 bytes contain the course and speed, 1 character for each.2

Course: (heading / 4) + 33

Speed: log(knots + 1, 1.08) + 33

course:
176 / 4 = 44
44 + 33 = 77
77 = 'M'

speed:
log(42 + 1, 1.08) + 33 = 82
82 = 'R'

The information field now looks like this:

@092345z/:*E";qZ=OMR

Compression Type

The next byte is the compression type byte.(APRS 1.0 Specification, 2000, p. 39)

It's worth detailing here, but '0b00100010', or 341034_{10} will be used.

3410+33=6734_{10} + 33 = 67

67 = C

@092345z/:*E";qZ=OMRC

The Comment Field

The comment field has a maximum size of 40 characters. It can contain clear-text comments, along with some extra data. In this example we will stick with altitude data and a clear-text note.

Altitude in the Comment Field

The altitude is not compressed, and also not part of the main location data, so it can be omitted. It is located in the comment field. The altitude (in feet), is encoded as /A=aaaaaa.(APRS 1.0 Specification, 2000, p. 26)

With the altitude being 88132, the information field now looks like this:

@092345z/:*E";qZ=OMRC/A=088132

Clear-Text Message in the Comment Field

We already have /A=088132 in the comment field so we only have 31 characters remaining. The comment can contain any printable ASCII.(APRS 1.0 Specification, 2000, p. 20)

With the comment appended, we have:

@092345z/:*E";qZ=OMRC/A=088132Hello World!

The Complete APRS Packet

Source Call Sign & SSIDNOCALL-1
Destination Call Sign & SSIDAPRS-0
Digipeater Path (optional)WIDE1-1
Information Field@092345z/:*E";qZ=OMRC/A=088132Hello World!

This is the 'complete' APRS packet. It can now be passed on to generate the AX.25 frame.

APRS Text Format

When looking at raw APRS packets online you'll often see it in a specific format, all in one line. This packet would look like this(APRS 1.0 Specification, 2000, Chapter 17):

NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!

In this format, an SSID of 0 is omitted all together.

This concludes the APRS portion of the dive down the APRS stack!


AX.25 Frame Generation

APRS specifically uses only AX.25 UI frames(APRS 1.0 Specification, 2000, p. 12), which makes things nice and simple!

AX.25 Frame

If you've looked at the official AX.25 specification you may become overwhelmed by all of the extra information not related to UI frames. Although intimidating, this specification document is very complete and incredibly helpful at times.


A Procedural Example of Generating an AX.25 Frame

We will generate this frame procedurally, byte by byte. This will be represented as a list of hexadecimal values.

Flag[]
Dst[]
Src/Digi[]
Control[]
PID[]
Info[]
FCS[]
Flag[]

Flag

The flag bytes at the beginning and end of the frame are easy, they're just the byte 0x73, this is at the start and end of all frames.

Flag[0x73]
Dst[]
Src/Digi[]
Src/Digi[]
Control[]
PID[]
Info[]
FCS[]
Flag[0x73]

Addresses and SSIDs

This is where the textual representation of APRS packets starts to fall apart (but it's there for a reason!). In the textual representation the source address comes first, but in the actual AX.25 frame we can see that the destination actually comes first.

NOCALL-1>APRS,WIDE1-1

Address

Addresses are always 7 bytes long, the first 6 being the printable characters and the last being the SSID. If an address contains less than 6 characters, it is padded by trailing spaces. Each character of the address fields is ASCII, however they are shifted left by 1 bit. This is only done in the address field, not the information field.

SSID

Now for the 7th byte of the destination address, the SSID.

The SSID byte of the destination address is encoded as 111SSID0, where SSID contains the value of the SSID.

The SSID byte of the source address and digipeater addresses is encoded as 111SSIDL, where SSID is the same as above. L is set to 1 if it is the last address, otherwise it is set to 0.(Beech et al., 1998, Chapter 3.12.2)

Example

Starting with the destination address APRS, there is an SSID of -0 so it's omitted from the textual representation. To be clear, the character - from the textual packet does not appear in the AX.25 frame.

CharacterValueShifted
A0x410x82
P0x500xa0
R0x520xa4
S0x530xa6
space0x200x40
space0x200x40

SSID 0, so 0b11100000 / 0xe0 Encoded Address: [0x82, 0xa0, 0xa4, 0xa6, 0x40, 0x40, 0xe0]

CharacterValueShifted
N0x4e0x9c
O0x4f0x9e
C0x430x86
A0x410x82
L0x4c0x98
L0x4c0x98

SSID 1, and not the last address, so 0b11100010 / 0xe2 Encoded Address: [0x9c, 0x9e, 0x86, 0x82, 0x98, 0x98, 0xe2]

CharacterValueShifted
W0x570xae
I0x490x92
D0x440x88
E0x450x8a
10x310x62
space0x200x40

SSID 1, and the last address, so 0b11100011 / 0xe3 Encoded Address: [0xae, 0x92, 0x88, 0x8a, 0x62, 0x40, 0xe3]

All the addresses are encoded, we can pack them into the frame.

Flag[0x73]
Dst[0x82, 0xa0, 0xa4, 0xa6, 0x40, 0x40, 0xe0]
Src/Digi[0x9c, 0x9e, 0x86, 0x82, 0x98, 0x98, 0xe2]
Src/Digi[0xae, 0x92, 0x88, 0x8a, 0x62, 0x40, 0xe3]
Control[]
PID[]
Info[]
FCS[]
Flag[0x73]

Control & Protocol ID

The control field contains only 0x03 as this is a UI AX.25 frame. The Protocol ID field contains only 0xf0 as there is no layer 3 protocol used.(APRS 1.0 Specification, 2000, p. 12)

Flag[0x73]
Dst[0x82, 0xa0, 0xa4, 0xa6, 0x40, 0x40, 0xe0]
Src/Digi[0x9c, 0x9e, 0x86, 0x82, 0x98, 0x98, 0xe2]
Src/Digi[0xae, 0x92, 0x88, 0x8a, 0x62, 0x40, 0xe3]
Control[0x03]
PID[0xf0]
Info[]
FCS[]
Flag[0x73]

Info

The info field will contain the raw data from the APRS info field. The bytes of this field are not left shifted like they are in the address field.

This means that we simply need to pack the values of @092345z/:*E";qZ=OMRC/A=088132Hello World!, which are all ASCII, into our frame.

Flag[0x73]
Dst[0x82, 0xa0, 0xa4, 0xa6, 0x40, 0x40, 0xe0]
Src/Digi[0x9c, 0x9e, 0x86, 0x82, 0x98, 0x98, 0xe2]
Src/Digi[0xae, 0x92, 0x88, 0x8a, 0x62, 0x40, 0xe3]
Control[0x03]
PID[0xf0]
Info[0x40, 0x30, 0x39, 0x32, 0x33, 0x34, 0x35, 0x7a, 0x2f, 0x3a, 0x2a, 0x45, 0x22, 0x3b, 0x71, 0x5a, 0x3d, 0x4f, 0x4d, 0x52, 0x43, 0x2f, 0x41, 0x3d, 0x30, 0x38, 0x38, 0x31, 0x33, 0x32, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21]
FCS[]
Flag[0x73]

FCS

The AX.25 FCS is a CRC16 (ISO 3309) that starts at the first byte of the destination field and stops at the last byte of the information field.(Beech et al., 1998, Chapter 3.7) It's a standard CRC16 so you can find many implementations online. My personal favorite is this one by Andres Vahter.

Here is my modified version, but still based on the one made by Andres.

uint16_t calculateFcs(const std::vector<uint8_t> &input_data) {
  uint16_t crc = 0xFFFF;
  uint16_t crc16_table[] = {0x0000, 0x1081, 0x2102, 0x3183, 0x4204, 0x5285,
                            0x6306, 0x7387, 0x8408, 0x9489, 0xa50a, 0xb58b,
                            0xc60c, 0xd68d, 0xe70e, 0xf78f};

  size_t length = input_data.size();
  size_t iterator = 0;
  while (length--) {
    crc =
        (crc >> 4) ^ crc16_table[(crc & 0xf) ^ (input_data.at(iterator) & 0xf)];
    crc =
        (crc >> 4) ^ crc16_table[(crc & 0xf) ^ (input_data.at(iterator) >> 4)];
    iterator++;
  }

  return (~crc);
}

(Vahter, 2020)

Below is the full AX.25 frame.

Flag[0x73]
Dst[0x82, 0xa0, 0xa4, 0xa6, 0x40, 0x40, 0xe0]
Src/Digi[0x9c, 0x9e, 0x86, 0x82, 0x98, 0x98, 0xe2]
Src/Digi[0xae, 0x92, 0x88, 0x8a, 0x62, 0x40, 0xe3]
Control[0x03]
PID[0xf0]
Info[0x40, 0x30, 0x39, 0x32, 0x33, 0x34, 0x35, 0x7a, 0x2f, 0x3a, 0x2a, 0x45, 0x22, 0x3b, 0x71, 0x5a, 0x3d, 0x4f, 0x4d, 0x52, 0x43, 0x2f, 0x41, 0x3d, 0x30, 0x38, 0x38, 0x31, 0x33, 0x32, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21]
FCS[0xa2, 0x48]
Flag[0x73]

Each of these lists just need to be put together, in this order, to form the full binary frame.

[0x73, 0x82, 0xa0, 0xa4, 0xa6, 0x40, 0x40, 0xe0, 0x9c, 0x9e, 0x86, 0x82, 0x98, 0x98, 0xe2, 0xae, 0x92, 0x88, 0x8a, 0x62, 0x40, 0xe3, 0x03, 0xf0, 0x40, 0x30, 0x39, 0x32, 0x33, 0x34, 0x35, 0x7a, 0x2f, 0x3a, 0x2a, 0x45, 0x22, 0x3b, 0x71, 0x5a, 0x3d, 0x4f, 0x4d, 0x52, 0x43, 0x2f, 0x41, 0x3d, 0x30, 0x38, 0x38, 0x31, 0x33, 0x32, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21, 0xa2, 0x48, 0x73]


AX.25 Frame Encoding

With a full AX.25 frame generated, there are only a few more steps to implement prior to AFSK modulation. I'll through a quick

Preamble Postamble - Using the Flag Byte

One thing worth mentioning now, prior to bit stuffing, is the preamble and postamble. In order for a receiving station to synchronize, often times adding a preamble helps. Along with this, I've found that with some software, adding a short postamble is helpful to decode the last few bits.

The flag byte is said to be standard for a preamble according to the AX.25 specification.(Beech et al., 1998, Chapter 3.11) However, the flag byte is the worst binary series if doing NRZ-I as it's the longest series of 1s.(Finnegan & Benson, 2014) This isn't defined in the APRS specification, but Kenneth Finnegan makes a solid argument for using 0s instead in his paper.

Also worth noting, only a single flag needs to be between two frames.(Beech et al., 1998, Chapter 3.1)

Bit Stuffing

Bit stuffing is done between the flag bytes, but not on the flag bytes themselves. To do bit stuffing, each bit of the frame needs to be looked at, in order from the first destination field bytes.

The bits of the FCS need to be taken in most significant bit first (Big-endian). The bits of all other fields need to be taken in least significant bit first (Little-endian).(Beech et al., 1998, Chapter 3.8)

Every time there are 5 consecutive 1s, an additional 0 needs to be inserted. You'll notice that the flag byte has 6 consecutive 1s, this is how you can recognize and synchronize the reception of an AX.25 frame. The flag bytes will be the only place where you will find more than 5 consecutive 1s.(Beech et al., 1998, Chapter 3.6)

After bit stuffing, the frame will look like this:

[0x7e, 0x41, 0x05, 0x25, 0x65, 0x02, 0x02, 0x07, 0x39, 0x79, 0x61, 0x41, 0x19, 0x19, 0x47, 0x75, 0x49, 0x11, 0x51, 0x46, 0x02, 0xc7, 0xc0, 0x07, 0x81, 0x06, 0x4e, 0x26, 0x66, 0x16, 0x56, 0x2f, 0x7a, 0x2e, 0x2a, 0x51, 0x22, 0x6e, 0x47, 0x2d, 0x5e, 0x79, 0x59, 0x25, 0x61, 0x7a, 0x41, 0x5e, 0x06, 0x0e, 0x0e, 0x46, 0x66, 0x26, 0x09, 0x53, 0x1b, 0x1b, 0x7b, 0x02, 0x75, 0x7b, 0x27, 0x1b, 0x13, 0x42, 0x22, 0x89, 0x3f, 0x3f]


Bell 202 AFSK

There is so little documentation related to the Bell 202 modem that is used in APRS. The closest thing to this is a paper titled "Clarifying the Amateur Bell 202 Modem" Finnegan & Benson (2014).

The Bell 202 modem operates at 1200 baud, using audio frequencies 1200hz and 2200hz.

NRZ-I : Non-return To Zero Inverted Encoding

NRZ-I will sometimes get packed into AX.25, but it's part of the Bell202 specification.

When encoding the bit stream one bit at a time, a 0 is encoded by switching from one frequency, to the other (1200hz -> 2200hz or 2200hz -> 1200hz). A 1 is encoded by not changing the frequency.

Implementation

The goal of this article is not to go in-depth into a Bell 202 modem. There are plenty of resources, much better than I could create, related to this topic.

By far, the most complete resource is the Not Black Magic - AFSK guide.

This would be a bit more of a challenge, but you can reference my implementation here.


References

APRS 1.0 Specification. (2000). The APRS Working Group. http://www.aprs.org/doc/APRS101.PDF
APRS SSID Recommendations. (2012). The APRS Working Group. https://web.archive.org/web/20240331041159/http://www.aprs.org/aprs11/SSIDs.txt
APRS Symbol Table 1.1. (2015). The APRS Working Group. https://web.archive.org/web/20230630185528/http://www.aprs.org/symbols/symbolsX.txt
Beech, W. A., Nielsen, D. E., & Taylor, J. (1998). AX.25 2.2 Link Access Protocol. Tucson Amateur Packet Radio Corporation. https://www.tapr.org/pdf/AX25.2.2.pdf
Finnegan, K., & Benson, B. (2014). Clarifying the Amateur Bell 202 Modem. https://archive.org/details/dcc-2014-amateur-bell-202-modem-w-6-kwf-and-bridget-benson
Vahter, A. (2020). ax25_crc16.c. GitHub Gist. https://gist.github.com/andresv/4611897#file-ax25_crc16-c

Footnotes

  1. This is not the most appropriate choice of destination address, but it seems to be commonly used. Checkout chapter 4 of the APRS spec.

  2. These two bytes can also be used to encode the radio range or compressed altitude.