guszandy / php-aprs
APRS (Automatic Packet Reporting System) for PHP: packet parser, APRS-IS client and packet encoder, with no runtime dependencies.
Requires
- php: >=8.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.49
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
README
APRS (Automatic Packet Reporting System) for PHP — a packet parser, an APRS-IS client and a packet encoder, with no runtime dependencies beyond PHP itself.
PHP has had no actively maintained APRS library, so anyone building ham radio tooling on it has been hand-rolling packet parsing. This fills that gap.
$packet = (new PacketParser())->parse('YC2UTC-9>APDR16,WIDE1-1:!0748.00S/11022.00E>Driving home'); $packet->latitude(); // -7.8 $packet->longitude(); // 110.366667 $packet->comment; // Driving home
What it does: decodes every position format APRS uses (uncompressed, compressed base91, Mic-E) plus messages, status, weather, objects, items and telemetry; connects to APRS-IS with server-side filtering and automatic reconnection; and builds valid packets for transmission.
What it does not do (yet): AX.25 KISS/TNC framing over serial or a sound modem, digipeater or igate server roles, and base91 telemetry in comments.
Contents
Requirements · Installation · Quickstart · Receiving from APRS-IS · Filters · Building packets · Compressed and Mic-E · Other packet types · Staying connected · Error handling · API reference · Extending the parser · Development · Contributing
Packet type support
APRS defines 22 data type identifiers. This library decodes 13 of them, covering everything in routine use on a live feed:
| Identifier | Decoded as | |
|---|---|---|
| ✅ | ! = / @ position |
PositionPacket |
| ✅ | ` ' Mic-E |
MicEPacket |
| ✅ | : message, ack, bulletin |
MessagePacket |
| ✅ | ; object, ) item |
ObjectPacket |
| ✅ | > status |
StatusPacket |
| ✅ | _ weather |
WeatherPacket |
| ✅ | T telemetry |
TelemetryPacket |
| ✅ | } third-party |
ThirdPartyPacket |
| ⬜ | ? query, < capabilities |
UnknownPacket |
| ⬜ | $ raw GPS, [ Maidenhead beacon |
UnknownPacket |
| ⬜ | # * Peet Bros, % Agrelo DF |
UnknownPacket |
| ⬜ | { user-defined, , test data |
UnknownPacket |
Undecoded types are not errors: you still get the envelope and the payload
verbatim, so nothing is lost. The remaining nine are queries (only relevant if
you answer them), 1990s weather hardware, and ,, which the specification
defines as invalid by design.
Requirements
- PHP 8.1+
- No PHP extensions. The APRS-IS client uses core stream sockets, so
ext-socketsis not required.
Installation
composer require guszandy/php-aprs
Quickstart
use Aprs\Parser\PacketParser; use Aprs\Packet\PositionPacket; use Aprs\Packet\MessagePacket; $parser = new PacketParser(); $packet = $parser->parse('YC2UTC-9>APDR16,WIDE1-1,WIDE2-1:!0748.00S/11022.00E>088/036/A=000123Driving home'); echo $packet->source; // YC2UTC-9 echo $packet->destination; // APDR16 echo $packet->path; // WIDE1-1,WIDE2-1 if ($packet instanceof PositionPacket) { echo $packet->latitude(); // -7.8 echo $packet->longitude(); // 110.366667 echo $packet->course; // 88 echo $packet->speedKmh(); // 66.672 echo $packet->altitudeMeters(); // 37.4904 echo $packet->comment; // Driving home }
Messages, acknowledgements and bulletins come back as MessagePacket:
$packet = $parser->parse('YC2UTC>APRS::YB0ABC-1 :Hello world{003'); if ($packet instanceof MessagePacket) { echo $packet->addressee; // YB0ABC-1 echo $packet->text; // Hello world echo $packet->messageId; // 003 $packet->expectsAck(); // true }
Receiving from APRS-IS
use Aprs\Client\AprsIsClient; use Aprs\Client\ClientOptions; use Aprs\Client\Filter; use Aprs\Packet\Packet; $filter = Filter::create()->range(-7.8, 110.37, 100); // 100 km around Yogyakarta $client = new AprsIsClient(ClientOptions::readOnly('YC2UTC', $filter)); $client->connect(); $client->run(function (Packet $packet) { echo $packet->source, ' ', $packet->payload, PHP_EOL; });
A read-only login needs no passcode and can never transmit — use it for anything that only listens. To inject packets you need a real passcode, which is derived from your callsign:
$client = new AprsIsClient(ClientOptions::authenticated('YC2UTC')); $client->connect(); $client->send('YC2UTC>APRS,TCPIP*:>Beaconing from PHP');
The passcode is not a secret — it is a deterministic hash of the base callsign that anyone can compute, so it proves identity only in the weakest sense. Only log in with a callsign you are licensed to use.
run() takes two more optional callbacks: one for malformed lines (which never
interrupt the loop) and one fired whenever a read times out, which is where you
would put keepalive or shutdown checks.
$client->run( fn (Packet $p) => handle($p), fn (ParseException $e, string $line) => error_log("bad packet: $line"), fn () => $client->stop(), // e.g. after a deadline );
Server comment lines (# banners and keepalives) are not packets and are
routed separately:
$client->onServerMessage(fn (string $line) => error_log($line));
run() propagates a ConnectionException when the link drops, leaving retry
policy to you. If you would rather the client handled it, use
runForever().
Filters
Server-side filters are how you avoid pulling the whole world feed. Clauses combine as a logical OR, so each one you add widens the stream:
Filter::create() ->range(-7.8, 110.37, 100) // r/-7.8/110.37/100 ->budlist('YC2UTC*') // b/YC2UTC* ->type('poimq'); // t/poimq
| Method | Clause | Selects |
|---|---|---|
range($lat, $lon, $km) |
r/ |
Everything within a radius of a point |
within($coordinate, $km) |
r/ |
The same, taking a Coordinate |
area($n, $w, $s, $e) |
a/ |
Everything inside a lat/lon box |
budlist(...$calls) |
b/ |
Named stations, wildcards allowed |
prefix(...$prefixes) |
p/ |
Callsigns starting with a prefix |
object(...$names) |
o/ |
Objects and items by name |
digipeater(...$calls) |
d/ |
Packets relayed by these digipeaters |
entryStation(...$calls) |
e/ |
Packets gated in by these stations |
group(...$calls) |
g/ |
Messages addressed to these stations |
type($letters) |
t/ |
Packet types: position, object, item, message, query, status, telemetry, user-defined, nws, weather |
symbol($primary, ...) |
s/ |
Particular map symbols |
myRange($km) |
m/ |
Near your own last reported position |
friendRange($call, $km) |
f/ |
Near another station's position |
unproto(...$tocalls) |
u/ |
By destination, so by originating software |
qConstruct($letters) |
q/ |
By how the packet entered APRS-IS |
raw($clause) |
— | Anything not modelled above |
Building packets
PositionEncoder builds uncompressed position reports. Every method returns a
new instance, so a configured encoder makes a convenient beacon template:
use Aprs\Encoder\PositionEncoder; $raw = PositionEncoder::atDegrees(-7.8, 110.366667) ->symbol('/', '>') ->messagingCapable() ->courseSpeed(88, 36) ->altitude(123) ->comment('Driving home') ->toTnc2('YC2UTC-9'); // YC2UTC-9>APZPHP,WIDE1-1,WIDE2-1:=0748.00S/11022.00E>088/036/A=000123Driving home
You never pick the Data Type Identifier yourself — it follows from whether a timestamp is set and whether the station is messaging-capable:
| no timestamp | timestamp | |
|---|---|---|
| not messaging | ! |
/ |
| messaging capable | = |
@ |
Course/speed, PHG and range are mutually exclusive — a position report carries at most one data extension — so setting one replaces any previous one.
MessageEncoder handles messages, acks, rejections and bulletins:
use Aprs\Encoder\MessageEncoder; MessageEncoder::message('YB0ABC-1', 'Meet at the repeater', '003'); // :YB0ABC-1 :Meet at the repeater{003 MessageEncoder::ack('YB0ABC-1', '003'); // :YB0ABC-1 :ack003 MessageEncoder::bulletin(3, 'Heavy rain', 'WX'); // :BLN3WX :Heavy rain
Passing a message number is what requests an acknowledgement; omit it for
fire-and-forget messages. Encoding validates as it goes and throws
EncodeException rather than emitting a malformed packet — a bad packet on air
wastes channel time and may be re-digipeated widely before anyone notices.
Sending one is then just:
$client->send($raw);
By default packets go out as SOURCE>APZPHP,WIDE1-1,WIDE2-1:.... APZ is the
reserved tocall prefix for experimental and unregistered software; the default
path is one local plus one wide hop, which is the polite maximum on the
congested 2 m channel.
Compressed and Mic-E positions
Both formats parse into the same PositionPacket you already handle, so code
written against the uncompressed format keeps working:
$packet = $parser->parse('M0XER-4>APRS64,qAR,TF3SUT-2:!/.(M4I^C,O `DXa/A=040849'); $packet->latitude(); // 64.11987368 $packet->compressed; // true $packet->gpsFixCurrent; // true
Compressed reports pack the position into 13 base91 bytes instead of 19 ASCII ones, and are far more precise — about 0.3 m against 18 m. Prefer them unless you need position ambiguity, which the format cannot express.
CompressedPositionEncoder::atDegrees(64.11987, -19.07065) ->symbol('/', 'O') ->courseSpeed(88, 36) ->toTnc2('M0XER-4');
Course and speed share two bytes with the radio range, so only one can be sent, and both come back quantised: course to 4 degrees, speed to about 8%.
Mic-E hides the latitude and several flag bits inside the destination
callsign, which is why a Mic-E packet appears to be addressed to gibberish
like SUSUR1. The encoder therefore generates the destination for you:
$encoder = MicEEncoder::atDegrees(35.586833, 139.701) ->symbol('/', '[') ->courseSpeed(305, 0) ->messageType(MicEMessageType::OffDuty); $encoder->destination(); // SUSUR1 $encoder->toTnc2('YC2UTC-9');
Mic-E adds a message type, including an emergency state worth checking explicitly:
if ($packet instanceof MicEPacket && $packet->isEmergency()) { // ... }
Two quirks are inherent to Mic-E rather than to this library: it has no
timestamp field at all, and some positions encode to bytes below 0x20, which
look like control characters in a TNC monitor but are perfectly valid on air.
Other packet types
| Payload | Class | Notes |
|---|---|---|
> status |
StatusPacket |
Optional zulu timestamp or Maidenhead grid + symbol |
_ weather |
WeatherPacket |
Positionless observation |
; object, ) item |
ObjectPacket |
Check alive before displaying |
T# telemetry |
TelemetryPacket |
Sequence, 5 analog channels, 8 digital bits |
} third-party |
ThirdPartyPacket |
A relayed packet; read inner for the original |
Relayed traffic
An igate passing an RF packet to APRS-IS wraps it in a } envelope. The
envelope names the gateway, not the station that sent it:
if ($packet instanceof ThirdPartyPacket) { $packet->source; // YB0IGT — the gateway $packet->originalSource(); // YC2UTC-9 — who actually sent it $packet->innermost(); // the original packet, fully parsed }
Attributing a position to $packet->source would credit every relayed packet
to the igate. innermost() unwraps nested envelopes, which occur when traffic
crosses more than one gateway; nesting beyond three deep is rejected as a
probable relay loop.
Watch the order of your instanceof checks
MicEPacket and ObjectPacket both extend PositionPacket, so that
everything which works on a position works on them too. The catch is that a
PositionPacket branch placed first will swallow both:
// Wrong — objects and Mic-E never reach their own branches. if ($packet instanceof PositionPacket) { /* ... */ } elseif ($packet instanceof ObjectPacket) { /* unreachable */ } // Right — most specific first. if ($packet instanceof ObjectPacket) { /* ... */ } elseif ($packet instanceof MicEPacket) { /* ... */ } elseif ($packet instanceof PositionPacket) { /* ... */ }
The failure is silent: you get a position with no object name and no message
type, rather than an error. If you only care about coordinates, a single
PositionPacket check is correct and covers all three.
Weather also rides along with positions that use the _ symbol, in which case
it appears on the PositionPacket:
if ($packet->hasWeather()) { $packet->weather->temperatureC(); // 25.0 $packet->weather->windSpeedKmh(); // 6.4374 $packet->weather->humidity; // 50 }
APRS transmits weather in US customary units, so WeatherData keeps the raw
values (temperatureF, windSpeedMph, rainfall in hundredths of an inch) and
offers metric equivalents as methods — nothing is lost to conversion before you
decide what you want. Every field is nullable, because stations report only the
sensors they have.
Objects and items can be killed, meaning receivers should remove them:
if ($packet instanceof ObjectPacket && $packet->isKilled()) { $map->remove($packet->name); }
Staying connected
runForever() reconnects with exponential backoff when the link drops:
$client->runForever( fn (Packet $p) => handle($p), ReconnectPolicy::persistent(), // or ReconnectPolicy::upTo(5) fn (ConnectionException $e, int $n) => error_log("drop #$n: {$e->getMessage()}"), );
A successful reconnection resets the backoff, so a link that fails once an hour retries quickly each time instead of creeping towards the ceiling. Delays are jittered by default — without that, every client that lost the same server reconnects in lockstep and hammers its replacement.
Tests can skip the waiting entirely with setSleeper().
Error handling
Nothing is dropped silently. There are two distinct outcomes for a packet this library cannot fully decode:
- Malformed packet — a
Aprs\Exception\ParseExceptionis thrown. The exception carries the original line in$e->rawand a description in$e->reason, so you can log or re-queue it. - Unsupported payload type — the TNC2 envelope is still returned, as an
Aprs\Packet\UnknownPacketwith the payload preserved verbatim.
In a receive loop, tryParse() returns null instead of throwing:
foreach ($lines as $line) { $packet = $parser->tryParse($line); if ($packet === null) { $malformed++; continue; } // ... }
API reference
Value objects
All immutable; every mutator returns a new instance.
| Class | Purpose |
|---|---|
Aprs\Value\Callsign |
Base callsign + SSID, validation, isSameStation() |
Aprs\Value\Coordinate |
Decimal degrees ⇄ DDMM.MM, position ambiguity, distance/bearing |
Aprs\Value\Digipeater |
One path hop, has-been-repeated flag, alias and q-construct detection |
Aprs\Value\Path |
Ordered, immutable, iterable digipeater path |
Aprs\Value\WeatherData |
An observation in APRS units, with metric accessors |
Packets
Packet is the immutable base type; every packet exposes source,
destination, path, payload, raw and toTnc2().
| Class | Extends | Notes |
|---|---|---|
Aprs\Packet\PositionPacket |
Packet |
Coordinate, symbol, course/speed, altitude, weather |
Aprs\Packet\MicEPacket |
PositionPacket |
Adds message type and isEmergency() |
Aprs\Packet\ObjectPacket |
PositionPacket |
Adds name, alive, isItem |
Aprs\Packet\MessagePacket |
Packet |
Addressee, text, message number, bulletins |
Aprs\Packet\StatusPacket |
Packet |
Text, timestamp or Maidenhead grid |
Aprs\Packet\WeatherPacket |
Packet |
Positionless observation |
Aprs\Packet\TelemetryPacket |
Packet |
Sequence, analog channels, digital bits |
Aprs\Packet\ThirdPartyPacket |
Packet |
Relayed traffic; inner, originalSource() |
Aprs\Packet\UnknownPacket |
Packet |
Envelope parsed, payload not decoded |
Enums: DataType, MessageKind, MicEMessageType.
Parsers
| Class | Handles |
|---|---|
Aprs\Parser\PacketParser |
Entry point: parse(), tryParse(), register() |
Aprs\Parser\PositionParser |
! = / @, compressed and uncompressed |
Aprs\Parser\MicEParser |
` ' |
Aprs\Parser\MessageParser |
: |
Aprs\Parser\ObjectParser |
; ) |
Aprs\Parser\StatusParser |
> |
Aprs\Parser\WeatherParser |
_ |
Aprs\Parser\TelemetryParser |
T |
Aprs\Parser\ThirdPartyParser |
} |
Aprs\Parser\PayloadParser |
Interface for your own parsers |
Aprs\Parser\ParserAware |
Opt-in interface to receive the PacketParser |
Client
| Class | Purpose |
|---|---|
Aprs\Client\AprsIsClient |
Connect, log in, stream packets, send |
Aprs\Client\ClientOptions |
Server, callsign, passcode, filter, timeouts, login line |
Aprs\Client\Filter |
Fluent server-side filter builder |
Aprs\Client\Passcode |
Passcode derivation and verification |
Aprs\Client\ReconnectPolicy |
Backoff, jitter and attempt limits |
Aprs\Client\Connection |
Transport interface — swap in a double for testing |
Aprs\Client\StreamConnection |
TCP transport over core PHP streams |
Encoders
| Class | Purpose |
|---|---|
Aprs\Encoder\PositionEncoder |
Fluent builder for uncompressed position reports |
Aprs\Encoder\CompressedPositionEncoder |
Base91 compressed position reports |
Aprs\Encoder\MicEEncoder |
Mic-E reports, including the destination callsign |
Aprs\Encoder\MessageEncoder |
Messages, acks, rejections and bulletins |
Aprs\Encoder\PacketEncoder |
Wraps a payload in a TNC2 envelope |
Aprs\Encoder\TimestampFormat |
The three APRS timestamp forms |
Exceptions
All implement Aprs\Exception\AprsException, so one catch covers the library.
| Class | Thrown when |
|---|---|
Aprs\Exception\ParseException |
A packet cannot be decoded; carries raw and reason |
Aprs\Exception\EncodeException |
Structured data cannot make a valid packet |
Aprs\Exception\ConnectionException |
Connection, login or send fails |
Aprs\Exception\InvalidCallsignException |
A string is not a valid callsign |
Codec
Aprs\Codec\Base91 — the base91 representation APRS uses for compressed
positions, Mic-E altitude and telemetry.
Position ambiguity
APRS lets a station blank up to four low-order digits to deliberately reduce
precision. Coordinate decodes the position as the centre of the ambiguous
area while remembering how many digits were blanked, so the original text can be
reproduced exactly:
$c = Coordinate::fromAprs('4903.5 N', '07201.75W'); $c->ambiguity; // 1 $c->latitude; // 49.05916667 (centre of 49°03.50'–49°03.60') $c->toAprsLatitude(); // "4903.5 N"
Extending the parser
Payload decoding is pluggable. Implement Aprs\Parser\PayloadParser and
register it — parsers registered later win over the built-in ones, so this is
also how you override a built-in decoder:
use Aprs\Packet\DataType; use Aprs\Parser\PayloadParser; final class QueryParser implements PayloadParser { public function supports(DataType $type): bool { return $type === DataType::Query; } public function parse( DataType $type, Callsign $source, Callsign $destination, Path $path, string $payload, string $raw, ): Packet { // ... build and return your own Packet subclass } } $parser = new PacketParser(); $parser->register(new QueryParser());
If your parser needs to parse packets itself — as third-party traffic does —
also implement Aprs\Parser\ParserAware, and the PacketParser will hand
itself over. That keeps nested packets going through the same set of parsers
rather than a fresh default one.
Roadmap
| Phase | Scope | Status |
|---|---|---|
| 1 | Value objects, TNC2 header, uncompressed position, messages | done |
| 2 | APRS-IS client: connect, login, passcode, read loop, filters | done |
| 3 | Encoder: uncompressed position and message packets | done |
| 4 | Compressed (base91) positions, Mic-E parse and encode | done |
| 5 | Status, weather, object/item, telemetry; reconnect logic | done |
| 6 | Packagist 1.0, documentation, examples | done |
Beyond 1.0, in rough order of usefulness: base91 telemetry in comments, the
!DAO! precision extension, query and station-capability packets, and AX.25
KISS/TNC framing for talking to real radios — which is large enough that it may
belong in a separate package.
Development
composer install php examples/parse-packets.php # decode built-in samples php examples/encode-packets.php # build packets, verify round trips php examples/compressed-and-mice.php # compressed + Mic-E, three encodings php examples/aprs-is-listen.php YC2UTC # live read-only APRS-IS feed composer check # everything below, in order — this is what CI runs composer lint # syntax-check every file composer cs # PSR-12 check composer cs-fix # PSR-12 autofix composer stan # PHPStan level 6 composer test # PHPUnit
CI runs a fast syntax lint first, then the suite on PHP 8.1, 8.2 and 8.3, plus PHPStan level 6 and the PSR-12 check.
The suite is 290 tests and about 2,500 assertions, with no network access
required — the APRS-IS client is tested through a scripted Connection double.
Contributing
See CONTRIBUTING.md. Short version: composer check must
pass, and new test vectors should come from real packets with independently
published decodes rather than from this library's own output.
Bug reports for decoding problems are especially welcome — please include the raw packet exactly as received, since without it there is usually no way to reproduce the issue.
Accuracy and provenance
Decoders are validated against published decodes from independent implementations, not only against this library's own encoders. That approach caught two real bugs before release: a floating-point error in coordinate round-tripping, and a Mic-E longitude encoding fault affecting everything below 10 degrees.
Where the APRS specification and its addenda disagree — and they do, notably on the Mic-E destination table — this library follows real packets and says so in a comment.
License
MIT — see LICENSE.
Test vectors are derived from the APRS 1.0.1 protocol specification, from published decodes of other implementations, and from packets observed on air. No code is ported from GPL-licensed reference implementations such as libfap.