guszandy/php-aprs

APRS (Automatic Packet Reporting System) for PHP: packet parser, APRS-IS client and packet encoder, with no runtime dependencies.

Maintainers

Package info

github.com/GusZandy/php-aprs

pkg:composer/guszandy/php-aprs

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-11 14:49 UTC

This package is auto-updated.

Last update: 2026-08-11 14:52:43 UTC


README

Tests Packagist PHP License

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-sockets is 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\ParseException is thrown. The exception carries the original line in $e->raw and 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\UnknownPacket with 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.

References