P25 Reflector Protocol
Vexillum’s P25 mode is the simplest of the six protocols documented here: a 1-byte poll tag, a 1-byte disconnect tag, and everything else treated as an opaque data frame relayed byte-for-byte to every other connected session.
Status
Section titled “Status”| Field | Value |
|---|---|
| Transport | UDP only |
| Default listen port | 41000 |
| Authentication | None |
| Talkgroup | Not implemented at the wire-protocol level |
Design overview
Section titled “Design overview”A client should handle:
- A 1-byte poll tag (
0xF0) followed by a 10-byte callsign - A 1-byte disconnect tag (
0xF1), valid on its own with no further payload - Everything else, up to 200 bytes, treated as an opaque data/voice frame
- A single stream-end marker: the data frame’s first byte equal to
0x80
There is no NID (Network ID), DUID (Data Unit ID), or talkgroup field parsed anywhere in this implementation. The operator-facing configuration for a P25 instance does include a talkgroup value, but it is not passed into the wire protocol runtime at all - it has no effect on packet acceptance, forwarding, or gating. Every session shares a single global stream, the same shape used by NXDN and YSF.
Packet format
Section titled “Packet format”| Tag | Length | Layout | Purpose |
|---|---|---|---|
0xF0 (poll) |
11 |
[0] tag 0xF0, [1:11] callsign (10 bytes) |
Poll / login |
0xF1 (disconnect) |
≥ 1 |
[0] tag 0xF1; a bare single byte is sufficient |
Disconnect |
| (anything else) | 1 - 200 |
Opaque frame; only byte 0 is inspected, for stream end |
Data/voice frame |
A packet is classified in this order: first, is byte 0 exactly 0xF0 and is
the packet at least 11 bytes? Then it’s a poll. Is byte 0 exactly 0xF1? Then
it’s a disconnect, regardless of length. Otherwise, if the whole packet is 200
bytes or fewer, it’s an opaque data frame. Anything longer than 200 bytes is
rejected outright.
Stream-end marker
Section titled “Stream-end marker”A data frame whose first byte is 0x80 is treated as an explicit
end-of-transmission marker. This is an arbitrary convention chosen by this
implementation, not a real P25 protocol field - a genuine P25 data unit whose
first byte happens to be 0x80 for unrelated reasons would also be
(mis)interpreted as an end-of-transmission marker by this reflector.
Session behavior
Section titled “Session behavior”| Parameter | Value |
|---|---|
| Session timeout | 120 seconds |
| Cleanup tick interval | 30 seconds |
| Stream idle timeout | 3 seconds |
| Session cap | 4096 |
Follows the general session/stream lifecycle described in the reference overview.
Single shared stream
Section titled “Single shared stream”Like NXDN, Vexillum’s P25 reflector admits one active stream across the entire
instance. There is no talkgroup, module, or other partitioning of any kind -
the first sender to have a data frame accepted holds the stream exclusively
until it ends explicitly (0x80 as the first byte) or goes idle for 3 seconds.
Session handshake
Section titled “Session handshake”The poll acknowledgment echoes the exact callsign bytes the client sent.
Stream behavior and ending a transmission
Section titled “Stream behavior and ending a transmission”- The first accepted data frame (from a session that has already polled) opens the reflector’s single active stream.
- While a stream is active, only frames from that same sender are forwarded; frames from other senders are dropped.
- A stream ends when a data frame’s first byte is
0x80, or when the stream is idle for 3 seconds. - Accepted frames are forwarded verbatim, unmodified, to every other polled session. The sender never receives its own frames back.
Minimal client state machine
Section titled “Minimal client state machine”Terminal conditions: transport failure, session timeout (120 seconds without
traffic), inbound 0xF1 disconnect, or user shutdown.
Common implementation mistakes
Section titled “Common implementation mistakes”Expecting NID/DUID/talkgroup gating
Section titled “Expecting NID/DUID/talkgroup gating”There is none. Any client that polls successfully can send any opaque payload up to 200 bytes and have it forwarded, regardless of what a real P25 NID or DUID embedded in that payload would say. If you need talkgroup-based access control, it must be implemented above this protocol, not by it.
Sending a data frame that accidentally starts with 0x80
Section titled “Sending a data frame that accidentally starts with 0x80”Because the stream-end check only looks at the first byte, any data frame -
voice or otherwise - that happens to start with 0x80 will be treated as ending
the current transmission, whether or not that was the sender’s intent.
Assuming a 0-length or partial poll is valid
Section titled “Assuming a 0-length or partial poll is valid”A poll must be at least 11 bytes (0xF0 plus the full 10-byte callsign field)
to be recognized. A shorter buffer starting with 0xF0 is not accepted as a
poll and falls through to being treated as an oversized-check candidate.
Example packet values
Section titled “Example packet values”A poll for callsign W1AW:
| Field | Value |
|---|---|
| Tag | 0xF0 |
| Callsign | W1AW······ (space-padded to 10) |
A minimal opaque data frame:
| Field | Value |
|---|---|
| Bytes | 0x65 0x00 0x00 0x01 0xAB 0xCD (6 bytes, opaque) |
An explicit end-of-transmission marker:
| Field | Value |
|---|---|
| Bytes | 0x80 (single byte is sufficient) |
Relationship to Vexillum
Section titled “Relationship to Vexillum”P25 is implemented as a Vexillum mode runtime (internal/modes/p25). It is the
least protocol-specific of the six modes documented here - essentially a bare
poll/disconnect/relay shim with a single arbitrary stream-end byte convention
layered on top.
Current limitations
Section titled “Current limitations”- No NID, DUID, or talkgroup parsing of any kind.
- The configured talkgroup value has no effect on wire-level behavior.
- One active stream reflector-wide.
- No authentication.
- The
0x80stream-end convention can misfire on data whose first byte happens to match by coincidence.
Development recommendations
Section titled “Development recommendations”- Implement the 1-byte poll tag (
0xF0) plus 10-byte callsign, and confirm the acknowledgment echoes it back. - Implement opaque data forwarding up to 200 bytes.
- Add the
0x80stream-end convention. - Add the 1-byte disconnect tag (
0xF1). - Test against a running Vexillum P25 instance before assuming any compatibility with real P25 infrastructure - there isn’t any at the wire-protocol level.