What APRS-IS is, and why put it in a radio app
APRS-IS (the APRS Internet Service) is the worldwide network of servers that ties the APRS world together over IP. Every position beacon, weather report, telemetry frame, and text message that a gateway hears on the air can be injected into APRS-IS, and from there it fans out to every other connected client on the planet — the map on aprs.fi, the app on someone's phone, a home weather dashboard, or another gateway a thousand kilometres away. It is, in effect, the internet side of APRS: a firehose of amateur telemetry you can both drink from and pour into.
HTCommander already speaks APRS fluently on the RF side. It parses incoming packets into structured positions, symbols, messages, telemetry, and weather; it builds and sends messages with authentication; it knows callsigns, SSIDs, and paths. What it doesn't do yet is reach the internet half of that world. Adding APRS-IS turns the app from a station that only hears what's on 2 m into one that can see the whole network — and, as a gateway, contribute what its radio hears back to it.
Crucially, we already did the hardest research for free. APRS-IS logins require a
passcode computed from your callsign, and the algorithm that generates it is
deliberately not published on the APRS-IS site — you're supposed to email the
author for it. HTCommander already carries a working port of that algorithm
(AprsUtil.aprsValidationCode), left over from earlier APRS work. That removes
the one gatekept dependency that usually trips up new APRS-IS clients.
The shape of it: another pseudo-radio
We're following a pattern the app already uses. When we added EchoLink, we didn't bolt it onto the physical radio stack — we registered it as a pseudo-radio with its own numeric device ID (200) that publishes state onto the app's internal event bus (the "Data Broker") without touching Bluetooth or the transmit-lock arbitration. APRS-IS gets the same treatment: a self-contained internet service on its own device ID (201), wired into the app through the Data Broker, with a clean web stub so browser builds compile even though raw TCP sockets don't exist there.
flowchart LR
subgraph App["HTCommander"]
UI["APRS tab / status"]
Broker["Data Broker\n(event bus)"]
Aprs["AprsHandler\n(device 1)"]
Mgr["AprsIsManager\n(device 201)"]
Client["AprsIsClient\n(pure logic)"]
Codec["TNC2 <-> AX.25\ncodec"]
end
Radio["Benshi radio\n(RF, device 1)"]
Net["dart:io TCP\n(TCP_NODELAY)"]
Server["APRS-IS server\nrotate.aprs2.net:14580"]
Radio <--> Broker
UI <--> Broker <--> Aprs
Broker <--> Mgr <--> Client <--> Codec
Mgr <--> Net <--> Server
The layering mirrors EchoLink almost one-for-one:
AprsIsClient— pure, testable logic with nodart:ioin sight: the login handshake, line framing, and the gating rules that decide what crosses between RF and the internet.AprsIsNetworkIo— the thindart:iolayer: open the TCP socket, turn off Nagle's algorithm (more on that below), stream lines in and out, and reconnect with backoff when the link drops.AprsIsManager— the bridge to the app: read settings, subscribe to RF packets to gate them upward, inject internet packets so they appear in the APRS tab, and ask the radio to transmit the messages we gate downward.tnc2_codec— the one genuinely new piece: translating between the internet's text format and the app's binary frames.
Logging in: a single line of text
Connecting to APRS-IS is refreshingly old-school. You open a TCP socket to a
server — the community-recommended round-robin pool is rotate.aprs2.net, and
port 14580 is the user-defined filter port — and send a single login line:
user N0CALL-10 pass 12345 vers HTCommander 0.1.21 filter r/47.6/-122.3/50
That's the whole authentication handshake. The pieces:
user N0CALL-10— your callsign and SSID.pass 12345— the passcode fromaprsValidationCode(). The special value-1means receive-only: you can listen but never transmit. That's the perfect first-run mode for testing.vers HTCommander 0.1.21— software name and version, so sysops can see what's connecting.filter …— an optional server-side filter (here, everything within 50 km of a point). Since we chose a user-configurable filter string, this is whatever the operator types in settings.
The server answers with lines that start with # — a banner, a login
acknowledgement, and periodic keep-alives. Our line parser treats every
#-prefixed line as a comment and ignores it. Everything else is a packet in
TNC2 format, terminated by CR/LF, never longer than 512 bytes. One socket
convention matters for a two-way client: turn off Nagle's algorithm
(TCP_NODELAY), or the small message packets we send can get buffered and
delayed by seconds.
The one thing we have to build: TNC2 ⟷ AX.25
Here's the impedance mismatch at the heart of the project. APRS-IS speaks TNC2, a flat text line that looks like this:
N0CALL-9>APRS,WIDE1-1,WIDE2-1:!4737.14N/12220.09W>Testing
That reads as source > destination , digipeater path : information
field. HTCommander, on the other hand, works internally in AX25Packet
objects — the same structured frames it decodes off the air. Everything
downstream (the parser, the APRS tab, the packet store) expects that object, not
a string.
So the bridge is small but load-bearing. We confirmed the app stores addresses in
AX.25 wire order — addresses[0] is the destination, addresses[1] is the
source, and the rest are digipeaters — which lines up exactly with how TNC2
writes SRC>DEST,digis. The codec becomes almost mechanical:
- Inbound (internet → app): split the line on
>,,, and:; parse each hop withAX25Address.parse; buildAX25Packet.ui([DEST, SRC, …digis], info); and hand it to the existingAprsPacket.parse— which now needs zero changes. - Outbound (app → internet): reverse it —
addresses[1] > addresses[0] , digis : dataStr.
flowchart TB Line["TNC2 text line\nSRC>DEST,path:info"] Addr["AX25Address.parse\n(each hop)"] Pkt["AX25Packet.ui\n[DEST, SRC, ...digis]"] Parse["AprsPacket.parse\n(unchanged)"] Tab["APRS tab / map / store"] Line --> Addr --> Pkt --> Parse --> Tab
Because the codec funnels internet packets into the same AprsPacket pipeline
the radio already feeds, an internet position beacon and an over-the-air one land
in the app looking identical — which is exactly what we want.
Being a good IGate: the rules of the gate
We opted for a full IGate — a bidirectional gateway — rather than a listen-only client. An IGate has two jobs, and the second one is where the etiquette lives.
RF → Internet (gating up). When the radio hears a valid packet, we forward it to APRS-IS. But not blindly — the community rules are specific:
- Only gate genuine UI frames (AX.25 control
0x03, PID0xF0) that didn't originate on the internet in the first place, and that aren't generic queries (which could trigger a flood of responses). - Skip anything with
TCPIP,TCPXX,NOGATE, orRFONLYin its path — those markers mean "don't send me back to the internet," and honouring them is what prevents packets looping forever between RF and IP. - Third-party packets (data type
}) get special handling: if the third-party header already containsTCPIP/TCPXX, they came from the internet and must not go back; otherwise we strip the RF header and gate the inner packet. - The one edit we're allowed to make is appending
,qAR,MYCALLto the path. TheqAR"q-construct" tells every downstream server "this entered APRS-IS from RF via this gateway." The data portion is never touched.
Internet → RF (gating down). This is where a careless gateway floods the local frequency and makes enemies. The rule is restraint: only message packets, and only when they're addressed to a station we've actually heard on RF recently (the convention is within the last hour). To do that we keep a small heard-list — callsign plus timestamp — updated every time the radio decodes a packet. When a message for one of those stations arrives from the internet, we gate it down and also forward that station's next position report so the recipient has context. Generic queries never get gated to RF, and we keep the transmit path to the minimum hops needed.
flowchart LR
subgraph RF
R["Benshi radio"]
end
subgraph Gate["AprsIsManager (IGate)"]
Up["Gate up?\n(not looped, not query,\nno NOGATE)"]
Heard["Heard-list\n(callsign + time)"]
Down["Gate down?\n(message to a\nlocally-heard station)"]
end
subgraph IS["APRS-IS"]
S["Server"]
end
R -->|decoded packet| Up
R -->|update| Heard
Up -->|append qAR,MYCALL| S
S -->|message| Down
Heard --> Down
Down -->|transmit| R
Being a full IGate means we need a verified login (a real passcode, not
-1), because only verified connections may inject packets. We'll ship
receive-only as the safe default and let the operator opt into gating once
they've entered a valid callsign and confirmed their coverage.
Where it plugs into the app
Almost everything we need already exists:
- Settings live on the Data Broker's device-0 store (persisted via
SharedPreferences), surfaced in the Servers tab of the settings dialog — right where EchoLink, Home Assistant, and AGWPE already sit. We'll add: enabled flag, server, port, filter string, a gate-to-RF toggle, and a read-only passcode field derived automatically from the callsign. - The APRS tab needs no rework: injected internet packets ride the same
UniqueDataFrameevents theAprsHandleralready consumes, so they show up in the message list and on the map with no new UI code. (An open question we'll settle during implementation: whether to visually distinguish internet-sourced packets from RF ones, or let them blend in.) - Registration in
main.dartuses the same conditional-import trick as EchoLink — the realdart:iomanager on desktop and mobile, a no-op stub on web — so the browser build keeps compiling.
What we'll test, and in what order
The plan has a deliberately gentle on-ramp:
- Receive-only first. Connect to
rotate.aprs2.net:14580with passcode-1, apply a local filter, and confirm internet packets appear in the APRS tab. This exercises the socket, the login, the line framing, and the TNC2 codec end-to-end without transmitting a single byte. - Send a message. With a verified login, send a text message and watch it appear on aprs.fi. Now the outbound codec and the verified connection are proven.
- Gate up. Confirm a packet the radio hears shows up on aprs.fi tagged
qAR,MYCALL. - Gate down. Confirm a message from the internet, addressed to a station the radio just heard, gets transmitted on RF.
Unit tests cover the parts that don't need a radio at all: TNC2 round-tripping and
the login/gating decision logic in AprsIsClient, mirroring the existing EchoLink
tests.
What we already know, and what's left to decide
The encouraging finding from this investigation is that we don't need any more documentation to start. The protocol, the filter syntax, the IGate etiquette, and the q-construct semantics are all published on aprs-is.net, and the one normally-gatekept piece — the passcode algorithm — is already in the codebase. The architecture is a known quantity too, because EchoLink blazed the pseudo-radio trail we're following.
A few choices remain open, and they're the kind we'll firm up as the code takes shape rather than up front: the exact default server, how strict the "recently heard" window should be for down-gating, and whether internet packets deserve their own visual treatment in the APRS tab. None of them block the first receive-only milestone — which is exactly where we'll begin.
Next post: the implementation itself, with the parts of the plan that survived contact with a live server — and the parts that didn't.