APRS

A pure Elixir library for parsing APRS (Automatic Packet Reporting System) packets.

It handles the full range of common APRS formats — position reports (uncompressed and compressed), Mic-E, weather, telemetry, objects, items, messages, status reports, PHG/DF data, NMEA and more — and returns a flat, predictable map for each packet. There are no runtime dependencies.

Installation

Add :aprs to your dependencies in mix.exs:

def deps do
[
{:aprs, "~> 2.0"}
]
end

Requires Elixir 1.17 or later.

Usage

Aprs.parse/1 takes a TNC2-format packet string and returns {:ok, packet} or {:error, reason}:

{:ok, packet} = Aprs.parse("N0CALL>APRS,TCPIP*,qAC,T2TEST:=4903.50N/07201.75W-Test message")
packet.data_type #=> :position_with_message
packet.latitude #=> 49.05833333333333
packet.longitude #=> -72.02916666666667
packet.comment #=> "Test message"

Every packet map carries the same envelope fields, the type-specific fields under :data_extended, and those same type-specific fields flattened into the top level for convenience:

%{
id: "d00797958416d5581386", # per-VM random prefix plus a counter
sender: "N0CALL",
path: "TCPIP*,qAC,T2TEST",
destination: "APRS",
information_field: "=4903.50N/07201.75W-Test message",
data_type: :position_with_message,
base_callsign: "N0CALL",
ssid: "0",
received_at: ~U[2026-09-01 17:03:51.259500Z],
resultcode: "success",
resultmsg: "OK",
data_extended: %{
data_type: :position_with_message,
latitude: 49.05833333333333,
longitude: -72.02916666666667,
comment: "Test message",
symbol_table_id: "/",
symbol_code: "-",
aprs_messaging?: true,
compressed?: false,
format: :uncompressed,
has_position: true,
position_ambiguity: 0,
posambiguity: 0,
posresolution: 18.52,
messaging: 0,
timestamp: nil,
altitude: nil,
course: nil,
speed: nil,
phg: nil,
radiorange: nil,
dao: nil,
wx: nil
},
# ... flattened data_extended fields plus reference-parser aliases ...
}

Errors are returned rather than raised:

Aprs.parse("not a packet")
#=> {:error, :invalid_packet}

Coordinates are plain floats. Every packet format reports the same units, whichever way the packet encoded them: speed in knots, altitude in feet, course in degrees (1-360, where 360 is due north and 0 or a missing value means unknown) and posresolution in metres.

Reference-parser compatibility fields

Alongside the snake_case fields above, each packet includes field names matching the common reference APRS parsers, so output can be consumed by existing tooling:

srccallsign, dstcallsign, body, origpacket, header, alive, type, digipeaters, posambiguity, format, messaging, symboltable, symbolcode, daodatumbyte, gpsfixstatus, mbits, message, phg, wx, radiorange, itemname. Packets that carry a position also get posresolution.

type is the standard type string for the packet ("location", "wx", "object", "item", "message", "messageack", "messagerej", "telemetry", "telemetry-message", "status", "capabilities", …), and digipeaters is a list of %{call: String.t(), wasdigied: 0 | 1}.

format is how the position was encoded — :uncompressed, :compressed, :mice, :nmea, :maidenhead, or :unknown.

More examples

Compressed position:

{:ok, packet} = Aprs.parse("N0CALL>APRS,TCPIP*:!/5L!!<*e7> sTComment")
packet.data_type #=> :position
packet.format #=> :compressed
packet.latitude #=> 49.5
packet.longitude #=> -72.75000393777269
packet.data_extended.compression_type #=> "T"

Mic-E:

{:ok, packet} = Aprs.parse(~s(KD8XYZ-9>T2SXTS,WIDE1-1:`(_fn"Oj/]"4H}Testing Mic-E))
packet.data_type #=> :mic_e
packet.latitude #=> 42.6405
packet.longitude #=> -112.129
packet.data_extended.speed #=> 20.0
packet.data_extended.course #=> 251
packet.data_extended.altitude #=> 160.761154855
packet.data_extended.message_type #=> :standard

Weather:

{:ok, packet} =
Aprs.parse("N0CALL>APRS,TCPIP*,qAC,T2TEST:_12345678c000s000g000t000r000p000P000h00b00000")
packet.data_type #=> :weather
packet.wx
#=> %{
# wind_direction: 0, wind_speed: 0, wind_gust: 0, temperature: 0,
# rain_1h: 0.0, rain_24h: 0.0, rain_since_midnight: 0.0,
# humidity: 100, pressure: 0.0, luminosity: nil, snow: nil,
# timestamp: "12345678", raw_weather_data: "c000s000g000t000r000p000P000h00b00000"
# }

A field the report leaves out is nil, so a station that sends no snow gauge reading is distinguishable from one reporting no snow.

Weather data embedded in a position comment is detected and extracted automatically into the same :wx field, and the comment is cleaned of it.

Telemetry:

{:ok, packet} = Aprs.parse("N0CALL>APRS,TCPIP*:T#005,199,000,255,073,123,01101001")
packet.data_type #=> :telemetry
packet.data_extended.telemetry
#=> %{seq: "005", vals: [199.0, 0.0, 255.0, 73.0, 123.0], bits: "01101001"}

Message:

{:ok, packet} = Aprs.parse("N0CALL>APRS,TCPIP*,qAC,T2TEST::WU2Z :Hello there{001")
packet.data_type #=> :message
packet.data_extended.addressee #=> "WU2Z"
packet.data_extended.message_text #=> "Hello there"
packet.data_extended.message_number #=> "001"

Object:

{:ok, packet} = Aprs.parse("N0CALL>APRS,TCPIP*:;LEADER *092345z4903.50N/07201.75W>088/036")
packet.data_type #=> :object
packet.data_extended.object_name #=> "LEADER"
packet.data_extended.live_killed #=> "*"
packet.data_extended.course #=> 88
packet.data_extended.speed #=> 36.0

Supported packet types

Indicatordata_typeNotes
!:positionUncompressed or compressed
=:position_with_message
/:timestamped_position
@:timestamped_position_with_message
`:mic_ePosition, course/speed, altitude, message bits
':mic_e_oldOld Mic-E
0x1C0x1D:mic_e / :mic_e_oldRev-0 (current and old) Mic-E
_:weatherFull weather field set
;:objectLive/killed, course/speed, altitude, RNG, DAO, weather
):itemLive/killed, course/speed, altitude, RNG, DAO, weather
::messageAddressee, text, message number, reply-ack, ack/rej
>:statusTimestamp, Maidenhead locator, beam heading/ERP
T#:telemetry1-5 analog values, digital bits, sequence
[:maidenhead_gridGrid locator beacon, decoded to a position
%:agrelo_dfjrAgrelo DFJr / MicroFinder
#PHG:phg_dataPower, height, gain, directivity
#DFS:df_report
$:raw_gps_ultimeterNMEA (RMC, GGA, GLL, VTG, WPL) and $ULTW
<:station_capabilitiesDecoded into a capability map
?:query
{:user_defined{{ is the spec's experimental form
}:third_party_traffic
#*:peet_loggingPeet Bros U-II weather station
,:invalid_test_data

The legacy rule that ! may appear anywhere in the first 40 bytes of the information field is honoured when the leading byte is not itself a data type indicator.

Additional handling for position-bearing packets, following the same order as the reference Ham::APRS::FAP parser:

Other public helpers

Development

mix deps.get
mix test # full suite (unit + StreamData property tests)
mix test --stale # only what your changes affect
mix format # formats and applies Styler
mix credo # static analysis
mix dialyzer # type analysis
mix docs # generate HexDocs output

Elixir/OTP versions are pinned in .tool-versions. A Nix flake dev shell is provided — nix develop, or automatically via direnv (.envrc). CI runs the format check, Credo, and the test suite on GitHub Actions.

Contributions should go on a feature branch and be opened as a pull request against main.

License

GNU General Public License v3.0 or later — see LICENSE.