What AllStarLink is, and why add it
AllStarLink (ASL) is a network of amateur radio
repeaters, remote bases, and hotspots tied together over the internet. Under the
hood every AllStar node is an Asterisk PBX running
the app_rpt application — the software that turns a telephone switch into a
repeater controller. Nodes link to each other by placing calls: node 2000
"dials" node 1998, Asterisk bridges the audio, and now two repeaters (and
everyone listening on them) share a conversation. There are thousands of nodes
online, many bridged to busy hubs and reflectors.
HTCommander already treats the internet as just another place audio comes from and goes to. It decodes Bluetooth SBC audio from Benshi handhelds, resamples, records, transcribes — and, since EchoLink, it speaks one internet-linking protocol already. AllStarLink is the natural next one. So we added it as a first-class radio you can switch to, sitting right next to your Bluetooth handhelds and EchoLink.
There's a catch that shapes the whole design: AllStarLink doesn't have its own protocol. It uses IAX2 — the Inter-Asterisk eXchange protocol, version 2 (RFC 5456) — which is a complete, general-purpose VoIP signaling and media protocol. Where EchoLink is a couple of UDP packet formats you can implement in an afternoon, IAX2 is a call-control protocol with authentication, reliability, keepalives, codec negotiation, and a state machine. That's the interesting part, and it's what most of this post is about.
Two ways to join AllStar (and the one we picked)
Before any code, a product decision. There are two established ways for software to participate in AllStarLink:
- Full node participation. You register with AllStarLink's servers, get your own node number, resolve other nodes' addresses through ASL's HTTP/DNS directory, and can dial any public node by number. This is what a real ASL node does. It's heavy: registration keepalives, a node database, and you have to be a node.
- Client-to-node, the way phone apps like DVSwitch Mobile and desktop
iaxRPT work. You point the app at one specific node you're allowed to use
— host, port, an IAX username and secret, and the node number to connect to —
and place a single IAX2 call into it.
app_rpton the far end answers and bridges you onto its system. No registration infrastructure required.
We went with client-to-node for the first release. It's how most operators actually connect a mobile client, and it avoids reimplementing ASL's registration and node-lookup machinery. The heavy lifting — the IAX2 protocol itself — is identical either way, so full node participation can be layered on later without touching the protocol core.
The shape of it: a radio that isn't a radio
Inside HTCommander every connected radio has a numeric device ID. Physical Benshi handhelds start at 1; EchoLink is device 200; APRS-IS is 201. AllStarLink is registered as device 202 — another pseudo-radio. It never joins the real radio stack (no Bluetooth, no transmit-lock arbitration, no hardware channels), but it publishes the same kind of state onto the app's internal event bus (the "Data Broker") so the rest of the UI can treat it like any other radio: a display panel, an RSSI/TX bar, a node grid, and the Comms tab all light up for it.
flowchart LR
subgraph App["HTCommander"]
UI["Radio panel / Comms tab"]
Broker["Data Broker\n(event bus)"]
Mgr["AllStarManager\n(device 202)"]
Client["AllStarClient"]
Call["Iax2Call\n(call state machine)"]
end
Net["dart:io UDP\n(one socket)"]
Node["AllStar node\napp_rpt / Asterisk"]
UI <--> Broker <--> Mgr <--> Client <--> Call <--> Net
Net <-->|UDP 4569| Node
The layering, top to bottom, deliberately mirrors the EchoLink stack so the two share the app's audio pipeline:
AllStarManager— the bridge to the app. It turns UI commands (go online, connect, disconnect, transmit) into client calls, re-emits received audio as recordable/transcribable events, resamples between the app's engine and the node, and reads/writes the saved node list.AllStarClient— orchestration. Opens the transport and manages the lifecycle:offline → online → connecting → inCall.Iax2Call— the IAX2 call state machine for a single call. Transport- and timer-injectable so it can be unit-tested without real sockets.DartIoIax2Network— the actualdart:ioUDP socket.
The whole thing is pure Dart. There is no libiax or Asterisk code
underneath — the subset of IAX2 needed to place an outbound app_rpt call is
small enough to implement directly, and it keeps the protocol layer
dependency-free and unit-testable.
IAX2 in one breath: one port, two frame shapes
The single best thing about IAX2 — and the reason Mark Spencer invented it — is that everything goes over one UDP port (4569): signaling and media, both directions. Compare that to SIP+RTP (separate signaling and media, on separate ports, often separate addresses) or EchoLink (two UDP ports plus a TCP directory). One port means one NAT pinhole. Place an outbound call and the return audio flows back through the same hole your first packet opened. For a client behind a home router, that's close to magic — no port forwarding required.
Everything on that port is a frame, and there are two kinds:
| Frame | Header | Reliable? | Carries |
|---|---|---|---|
| Full frame | 12 bytes | Yes (ACKed) | Signaling — and occasionally media |
| Mini frame | 4 bytes | No | Media only (voice) |
A full frame is the workhorse of call setup. Its 12-byte header packs the source and destination call numbers (15 bits each), a 32-bit millisecond timestamp, outbound/inbound sequence numbers, a frame type, and a subclass:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|F| Source Call Number |R| Destination Call Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| timestamp |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| OSeqno | ISeqno | Frame Type |C| Subclass |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Information Elements (type/length/value) ... |
The top bit (F) is set to mark a full frame. A mini frame clears that bit
and shrinks the header to four bytes: the source call number and the low 16
bits of the timestamp, followed straight by codec payload. Once a call is up,
voice mostly travels as mini frames — four bytes of overhead per packet instead
of twelve — and that's the bandwidth win IAX2 is designed around.
A small implementation wrinkle lives in that last header byte. The subclass is
seven bits, but media formats are a 32-bit bitmask (GSM is 0x2, μ-law is 0x4,
…). IAX2 squares that circle with a flag bit (C): when set, the subclass value
is a power of two, transmitted as its exponent. So GSM (0x2 = 2¹) goes on the
wire as 0x81. Our frame codec handles that encode/decode in one place so the
rest of the code just deals with plain format constants.
The call: NEW → AUTHREQ → AUTHREP → ACCEPT → ANSWER
Placing a call is a short conversation of full frames. Every reliable frame carries a sequence number and must be acknowledged; the diagram omits the bare ACKs for clarity.
sequenceDiagram participant App as HTCommander participant Node as AllStar node (app_rpt) App->>Node: NEW (version, called number, username, capability, format) Node-->>App: AUTHREQ (auth methods = MD5, challenge) App->>Node: AUTHREP (MD5 result = md5(challenge + secret)) Node-->>App: ACCEPT (chosen format, e.g. GSM) App->>Node: ACK Node-->>App: ANSWER (control frame) App->>Node: ACK Note over App,Node: call is UP — voice flows as mini frames App-->>Node: PING / PONG keepalive every ~20 s App->>Node: HANGUP (on disconnect)
Walking it:
- NEW kicks off the call. It carries a set of information elements (IEs)
— type/length/value triples appended to the frame. The
VERSIONIE must come first; then theCALLED NUMBER(the node you're dialing), yourUSERNAME, and the codec negotiation IEs (CAPABILITY= the codecs you can do,FORMAT= the one you'd prefer). - AUTHREQ is the node saying prove it. It names the authentication methods it accepts and includes a random challenge string.
- AUTHREP answers with the MD5 response — more on that below.
- ACCEPT means you're in. It names the codec the node actually chose (which must be one you offered).
- ANSWER — a control frame, a different frame type from the IAX signaling frames — means the far end has picked up. Now the call is up and media flows.
If anything goes wrong you get a REJECT or HANGUP (with a Q.931 cause code lifted straight from the telephone world — user busy, call rejected, normal clearing), and the call tears down.
Authentication: MD5 challenge-response
IAX2's common authentication is an MD5 challenge-response, and it's refreshingly simple: the node sends a random challenge string, and you reply with the hex-encoded MD5 digest of the challenge concatenated with your shared secret:
MD5 RESULT = md5( challenge + secret )
The secret never crosses the wire — only the digest does — and because the
challenge is fresh every call, a captured digest can't be replayed. (IAX2 also
defines RSA auth and AES call encryption; app_rpt client links use MD5 in
practice, so that's what we implement.) We compute the digest with the app's
existing crypto package, and a couple of unit tests pin it against known MD5
vectors so an accidental change to the concatenation order can't slip through.
The codecs: GSM (again) and μ-law
Here's where the EchoLink work paid a dividend. IAX2 negotiates a codec, and
app_rpt links speak GSM 06.10 Full Rate — the exact same 8 kHz codec
HTCommander already implemented for EchoLink. So the receive and transmit codec
was, quite literally, already in the repo.
We added one more for breadth: G.711 μ-law (PCMU), the classic telephone codec — one byte per sample, table-driven companding, effectively free to run. It gives us a fallback when a node insists on μ-law.
| Codec | Rate | Frame | On the wire |
|---|---|---|---|
| GSM 06.10 | 8 kHz mono | 160 samples → 33 bytes | one GSM frame per packet |
| G.711 μ-law | 8 kHz mono | 1 byte per sample | raw μ-law bytes |
One subtlety worth calling out: EchoLink packs 80 ms (four GSM frames) into
each voice packet; IAX2/app_rpt uses 20 ms frames — a single 160-sample GSM
frame per packet. Lower latency, more packets. And unlike EchoLink, IAX2 voice
payload has no RTP-style header of its own — the mini-frame header is the
framing, and the codec is implied by the last full voice frame's subclass.
The rest of the audio path is shared with EchoLink and the radios. HTCommander's
engine runs at 32 kHz; the node runs at 8 kHz; the same linear resampler
(with cross-chunk continuity, so no clicks at frame boundaries) bridges the two.
Received audio is played through the shared PcmPlayer and re-emitted onto the
Data Broker as AudioData* events, so the existing recorder and speech-to-text
engine pick up AllStar audio with zero extra work.
flowchart TB
subgraph RX["Receive (node → speaker)"]
R1["mini/full voice frame"] --> R2["GSM or μ-law decode\n→ samples @ 8kHz"]
R2 --> R3["Resample 8k → 32k"] --> R4["PcmPlayer + record/STT"]
end
subgraph TX["Transmit (mic → node)"]
T1["32 kHz PCM"] --> T2["Resample 32k → 8k"]
T2 --> T3["Encode to 20 ms frames"] --> T4["mini frame + UDP send"]
end
Reliability without TCP: sequence numbers, ACKs, and a resync trick
IAX2 rides on UDP, but call setup can't afford to lose a NEW or an AUTHREP. So it
builds just enough reliability on top: every full frame carries an outbound
sequence number (OSeqno) and the inbound number it's acknowledging
(ISeqno). Signaling frames must be ACKed; if an ACK doesn't come, the sender
retransmits (with a "this is a retransmit" bit set) a few times before giving up
and tearing the call down. Media mini frames are deliberately unreliable —
a lost 20 ms of audio is better re-sent as silence than arriving late.
Our Iax2Call tracks pending reliable frames in a small map keyed by sequence
number, clears them as the peer's ISeqno advances, and retransmits the rest on
a timer. Bare ACKs, PONGs, and a couple of other frame types deliberately don't
advance the counters (per the RFC), which is the kind of detail that's invisible
until it isn't — get it wrong and the peer starts NAKing.
Two timers keep a live call healthy:
- PING/PONG every ~20 seconds of idle proves the link is still there; a node's PING is answered with a PONG, and its LAGRQ (lag request) with a LAGRP.
- A connect timeout fails the call if ACCEPT/ANSWER never arrive.
And a genuinely clever bit from the spec: mini frames only carry the low 16 bits
of the timestamp, which wraps every 65.536 seconds. To keep both ends' clocks in
step across that wrap, IAX2 requires a full voice frame whenever the timestamp
crosses a multiple of 0x8000 (32.768 s). Our sender watches for that boundary
and promotes the next voice packet from a mini frame to a full frame
automatically — and always sends a full frame first, to establish the codec
before the minis (which have no codec field of their own) start flowing.
Using it
Configuration lives in Settings → AllStarLink. Add a node with the details
from that node's iax.conf:
| Field | Where it comes from |
|---|---|
| Name | Anything you like — shown on the tile |
| Host / Port | The node's address (port defaults to 4569) |
| IAX Username / Secret | The user entry configured for you in iax.conf |
| Node Number | The AllStarLink node you want to connect to |
Once at least one node is saved, AllStarLink appears in the radio connection
dialog (as an Internet radio, right below EchoLink). Connect to go online —
which, for AllStar, just means opening the UDP socket and appearing in the radio
switcher — then tap a node tile in the panel to place the call. The display shows
Disconnected → Connecting → Connected, the node number, and an Internet tag;
tap the connected tile again to hang up. From there it behaves like any other
radio: PTT transmits, received audio plays and is recorded/transcribed, and it
shows up in the switcher for one-tap source changes.
If you close the app mid-call, it remembers (a WasOnline flag plus the last
node) and reconnects on the next launch — the same auto-reconnect behavior the
Bluetooth radios and EchoLink already have.
Testing a protocol with no radio in the room
IAX2 is a state machine, and state machines are a joy to unit-test if you keep
the sockets and clocks out of the core. Iax2Call emits datagrams through a
callback and receives them through a plain method, so a test can play the part of
the node: feed it a crafted AUTHREQ and assert the AUTHREP carries the right MD5;
feed it ACCEPT + ANSWER and assert the call goes up; feed it a GSM voice frame
and assert PCM comes out; feed it a HANGUP and assert a clean teardown. The
AllStarClient gets the same treatment over a fake in-memory transport.
Twenty-six tests cover the frame and IE round-trips, the μ-law and GSM codecs, the MD5 vectors, and the full handshake — none of which need a live node, a real socket, or a repeater across town. That's the payoff of keeping the protocol pure and the I/O at the edges.
What's next
The first release is deliberately scoped to place an outbound call to a node you own or are permitted to use. Some IAX2 corners are intentionally left for later:
- DTMF control. On a real AllStar system you connect and disconnect other
nodes by sending
app_rptDTMF commands (e.g.*3<node>). IAX2 carries DTMF as its own frame type; wiring the app's keypad to it is a natural follow-up. - Full node participation — ASL registration and node-number lookup — so you can dial any public node by number rather than configuring each one.
- Native AES encryption, RSA auth, and IAX2 trunking — none of which a single client link needs, but all of which the protocol supports.
For now, HTCommander speaks enough IAX2 to walk up to an AllStarLink node, prove who it is, and start talking — all in pure Dart, sharing the same audio pipeline that already drives your handheld.
See also: Radio Over the Internet: Adding EchoLink to HTCommander · Planning APRS-IS for HTCommander