Skip to content

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.

Field Value
Transport UDP only
Default listen port 41000
Authentication None
Talkgroup Not implemented at the wire-protocol level

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.

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.

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.

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.

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.

Client                   Server
  |                        |
  | 0xF0 + callsign        |
  |----------------------->|
  |                        |
  | 0xF0 + echoed callsign |
  |<-----------------------|

The poll acknowledgment echoes the exact callsign bytes the client sent.

  • 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.
DISCONNECTED
  |
  v
CONNECTING (sent 0xF0 poll)
  |
  v
READY (received 0xF0 ack)
  |
  +-- PTT active --> TRANSMITTING
  |
  +-- incoming data frame --> RECEIVING

Terminal conditions: transport failure, session timeout (120 seconds without traffic), inbound 0xF1 disconnect, or user shutdown.

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.

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)

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.

  • 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 0x80 stream-end convention can misfire on data whose first byte happens to match by coincidence.
  1. Implement the 1-byte poll tag (0xF0) plus 10-byte callsign, and confirm the acknowledgment echoes it back.
  2. Implement opaque data forwarding up to 200 bytes.
  3. Add the 0x80 stream-end convention.
  4. Add the 1-byte disconnect tag (0xF1).
  5. Test against a running Vexillum P25 instance before assuming any compatibility with real P25 infrastructure - there isn’t any at the wire-protocol level.