M17 Reflector Protocol
M17 support provides Vexillum’s implementation of a reflector for the open M17 digital voice protocol. A client connects over UDP, selects a module, and exchanges control, voice, and packet-mode data directly with the reflector - there is no authentication step and no transport option beyond plain UDP.
Status
Section titled “Status”| Field | Value |
|---|---|
| Transport | UDP only |
| Default listen port | 17000 |
| Authentication | None |
| Callsign encoding | Base-40, packed into 6 bytes |
| Voice payload | 16-byte Codec2 frame inside a 54-byte M17 stream frame |
| Packet-mode payload | 0-824 application bytes inside a 36-860 byte M17P frame |
Design overview
Section titled “Design overview”A client should handle:
CONN/ACKN/NACKmodule connect handshakeLSTN/ACKN/NACKlisten-only connect handshake - optional, only needed by clients that want to monitor a module without ever transmittingPING/PONGkeepalive, in both directions"M17 "voice stream frames, including CRC validationM17Ppacket-mode data frames, including both CRC validations - optional, only needed by clients that send or receive non-voice application data (see Packet mode below)- Base-40 callsign encoding and decoding
- Stream end detection via the frame number’s top bit
DISCfor explicit disconnect
Vexillum’s M17 reflector behaves as a set of independent modules (channels),
each named by a single letter A-Z. Within one module, the reflector admits
one active voice stream at a time and forwards it to every other session
connected to that same module; packet-mode frames are relayed the same way but
without that one-at-a-time restriction, since each is a single, complete
datagram rather than part of a continuous stream. Sessions on different
modules never hear each other, for either kind of traffic.
Base-40 callsign encoding
Section titled “Base-40 callsign encoding”M17 callsigns are packed into 6 bytes using a base-40 alphabet, rather than sent as plain ASCII.
Alphabet, indexed 0-39:
Encoding rules:
- A callsign is at most 9 characters. Longer input is truncated to the first 9 characters before encoding.
- Each character maps to its base-40 alphabet index (unrecognized characters map
to index
0, a space). - The characters are packed most-significant-character-first into a base-40 integer, then written out as 6 big-endian bytes (the top 2 bytes of the full 8-byte accumulator are always zero, since 40^9 fits in 48 bits).
- Decoding reverses the process: unpack the 6 bytes into an integer, repeatedly take the value modulo 40 to recover characters least-significant-first, then trim trailing spaces.
Known-good encoding vectors (from this implementation’s tests):
| Callsign | Encoded bytes |
|---|---|
N0CALL |
0x00 0x00 0x4B 0x13 0xD1 0x06 |
W2FBI |
0x00 0x00 0x01 0x61 0xAE 0x1F |
These are worth hard-coding into your own test suite; if your encoder doesn’t reproduce them, it isn’t compatible with Vexillum’s M17 implementation.
CRC-16 computation
Section titled “CRC-16 computation”Voice stream frames carry a CRC-16 over everything except the trailing 2 CRC bytes.
| Field | Value |
|---|---|
| Polynomial | 0x5935 |
| Initial value | 0xFFFF |
| Coverage | First 52 bytes of the 54-byte voice frame |
| Bit order | MSB-first, one bit at a time |
Reference implementation:
A received voice frame whose trailing 2 bytes don’t match m17_crc(frame[0:52])
must be dropped. Vexillum’s reflector rejects such frames at parse time and
never forwards or acts on them - including ignoring the frame number entirely,
so a corrupted “final frame” does not end a stream.
Packet format
Section titled “Packet format”All integer fields are big-endian.
Control packets: CONN, LSTN, DISC, PING, PONG, ACKN, NACK
Section titled “Control packets: CONN, LSTN, DISC, PING, PONG, ACKN, NACK”| Offset | Bytes | Field | Notes |
|---|---|---|---|
0 |
4 |
Tag | ASCII CONN, LSTN, DISC, PING, PONG, ACKN, or NACK |
4 |
6 |
Callsign | Base-40 encoded, present on CONN/LSTN/DISC/PING/PONG |
10 |
1 |
Module | CONN/LSTN only - ASCII module letter A-Z |
CONN and LSTN are each 11 bytes total and byte-for-byte identical in
layout - LSTN is a listen-only connect request, distinguished from CONN
only by its tag. See Listen-only (LSTN) clients
below. DISC, PING, and PONG are each 10 bytes (tag + callsign, no module
byte). Vexillum’s own ACKN/NACK replies are a special case - see
Session handshake below.
Voice stream frame: "M17 "
Section titled “Voice stream frame: "M17 "”Note the trailing space in the 4-byte magic - "M17 ", not "M17".
| Offset | Bytes | Field | Notes |
|---|---|---|---|
0 |
4 |
Magic | ASCII M17 (with trailing space) |
4 |
2 |
StreamID | Unsigned 16-bit integer |
6 |
28 |
LICH | Link Information Channel data |
34 |
2 |
FrameNumber | Unsigned 16-bit integer; top bit signals end of stream |
36 |
16 |
Payload | Codec2 voice data |
52 |
2 |
CRC | CRC-16 over bytes 0-51, see above |
Total length is fixed at 54 bytes. Any frame that isn’t exactly 54 bytes, or whose CRC doesn’t match, is rejected.
Packet-mode data frame: M17P
Section titled “Packet-mode data frame: M17P”| Offset | Bytes | Field | Notes |
|---|---|---|---|
0 |
4 |
Magic | ASCII M17P |
4 |
6 |
DST | Destination address, base-40 encoded |
10 |
6 |
SRC | Source address, base-40 encoded |
16 |
2 |
TYPE | LSF type field - P/S bit is 0 for packet mode; CAN bits; rest reserved |
18 |
14 |
META | LSF metadata |
32 |
2 |
LSF CRC | CRC-16 (see above) over bytes 4-31 (DST+SRC+TYPE+META) |
34 |
N |
Payload | Application packet data, 0 to 824 bytes |
34+N |
2 |
Payload CRC | CRC-16 over bytes 34 through 33+N (the payload only) |
Total frame length is 36 + N bytes, where N is the payload length - so the
smallest valid frame is 36 bytes (zero-length payload) and the largest is 860
bytes (824-byte payload). A frame outside [36, 860], or whose LSF CRC or
payload CRC doesn’t match, is rejected. Unlike the voice frame’s single CRC
covering the whole frame, M17P has two independent CRCs - one over the
LSF fields, one over the payload - and both must be valid.
Vexillum’s reflector treats the payload as opaque: it does not interpret DST,
SRC, TYPE, or META, and does not reassemble anything - each M17P datagram
your client sends is expected to already be one complete application packet
(the RF-side reassembly of a multi-frame Packet Mode burst into a single blob
is a client/gateway concern, not the reflector’s).
Packet types
Section titled “Packet types”| Tag | Length | Direction | Purpose |
|---|---|---|---|
CONN |
11 bytes | Client to server | Connect to a module |
LSTN |
11 bytes | Client to server | Connect to a module, listen-only |
ACKN |
4 bytes (bare tag) | Server to client | Connect accepted |
NACK |
4 bytes (bare tag) | Server to client | Connect rejected |
PING |
10 bytes | Client to server | Keepalive |
PONG |
10 bytes | Server to client | Keepalive reply |
PING |
10 bytes | Server to client | Reflector-driven keepalive to clients |
DISC |
10 bytes | Either direction | Disconnect |
"M17 " |
54 bytes | Both directions | Voice stream frame |
M17P |
36-860 bytes | Both directions | Packet-mode data frame |
ACKN and NACK received by the reflector (i.e. sent by another reflector or a
misbehaving client) are counted as inbound control traffic and otherwise
ignored.
Session behavior
Section titled “Session behavior”| Parameter | Value |
|---|---|
| Session timeout | 60 seconds |
| Cleanup tick interval | 30 seconds |
| Reflector keepalive interval | 5 seconds |
| Stream idle timeout | 3 seconds |
| Session cap | 4096 |
Follows the general session/stream lifecycle described in the
reference overview: a session
is created on CONN, refreshed by any subsequent packet from that address, and
reaped by the cleanup tick if idle past the session timeout. Unlike DMR, DExtra,
and the other simplified modes, M17 keepalives flow in both directions
independently: clients may send PING and expect PONG, and the reflector
separately sends its own PING (using its own reflector callsign) to every
connected session every 5 seconds regardless of whether the client pings first.
Module gating
Section titled “Module gating”A module connect (CONN) is only accepted if:
- The requested module byte is an ASCII letter
A-Z, and - That letter is in the reflector’s configured set of enabled modules.
If either check fails, the reflector replies NACK and does not create a
session. If no modules are explicitly configured, the reflector defaults to
enabling all 26 modules A-Z.
Listen-only (LSTN) clients
Section titled “Listen-only (LSTN) clients”LSTN is a listen-only variant of CONN: identical 11-byte wire format,
identical module-gating rules, and the same bare ACKN/NACK reply. A client
sends LSTN instead of CONN when it wants to receive a module’s traffic
without ever transmitting to it - useful for monitor or SWL-style
applications.
A listen-only session is a full session in every other respect:
- It is created, tracked, and reaped by the same session table, timeout, and
session cap as a normal
CONNsession. - It receives the reflector’s
PINGand may sendPING/expectPONGexactly like a transmitting client. - It receives every voice frame forwarded on its module, exactly like a transmitting client would.
- It is disconnected via
DISCexactly like a transmitting client.
The one difference: any "M17 " voice frame sent by a listen-only session
is silently dropped. It never opens that module’s active stream, is never
forwarded to anyone, and does not affect any stream already in progress on
that module - a listen-only client’s voice traffic, if it sends any (which
would indicate a misbehaving client), is invisible to every other session.
Session handshake
Section titled “Session handshake”If the module is invalid or disabled, the server replies NACK (also a bare
4-byte tag) instead, and no session is created.
Unlike PING/PONG/DISC, the ACKN/NACK replies Vexillum’s reflector
sends are exactly the 4-byte tag with nothing appended - no callsign field,
even though ACKN/NACK are structurally 10-byte control packets elsewhere in
the M17 ecosystem. A client must not expect 6 callsign bytes after ACKN or
NACK from this reflector.
Keepalive behavior
Section titled “Keepalive behavior”Independently, every 5 seconds the server sends its own PING (10 bytes,
reflector’s own callsign) to every connected session. Clients should treat
either direction of PING/PONG traffic as proof the session is alive and
should not assume they must be the one to initiate keepalives.
Stream behavior and ending a transmission
Section titled “Stream behavior and ending a transmission”- The first accepted voice frame for a module opens that module’s active stream.
- While a stream is active, only frames from that same sender are accepted on that module; frames from other senders are silently ignored.
- A stream ends when a voice frame’s
FrameNumberhas its top bit set:FrameNumber & 0x8000 != 0. - A stream is also released if idle for 3 seconds with no new frame.
- Accepted voice frames are forwarded verbatim (all 54 bytes, including the original CRC) to every other session on the same module. The sender never receives its own frames back.
Recommended behavior for a transmitting client:
- Use a stable
StreamIDfor the duration of one transmission. - Set the top bit of
FrameNumber(0x8000) on the final frame of a transmission. - Recompute the CRC over the final 52 bytes after setting the frame number, not before.
Packet mode
Section titled “Packet mode”M17P frames carry one-shot application data - APRS-style position beacons,
text/SMS-style messages, telemetry, and similar - rather than a continuous
voice stream. Routing reuses the same module/session state CONN/LSTN
already built:
- An
M17Pframe is only accepted from an address with an existing, non listen-only session (i.e. one that connected viaCONN, notLSTN). A listen-only session’sM17Pframes are silently dropped, exactly like its voice frames - it is still counted as received traffic internally, but never relayed. - An accepted frame is forwarded verbatim (including its original CRCs) to every other session - transmitting or listen-only - on the sender’s module. The sender never receives its own frame back.
- Packet-mode frames never open, extend, or close a module’s active voice
stream, and are not subject to the “one active stream per module” gating
described above - a client can send an
M17Pframe on a module in the middle of someone else’s voice transmission, and it is relayed immediately. - There is no packet-mode equivalent of the stream idle timeout: each frame is independent, so there is no multi-frame state to expire.
A client that only cares about voice doesn’t need to implement M17P at all;
it’s purely additive.
Minimal client state machine
Section titled “Minimal client state machine”Terminal conditions: transport failure, session timeout (no traffic seen for 60
seconds), inbound DISC, or user shutdown.
Common implementation mistakes
Section titled “Common implementation mistakes”Assuming LSTN needs different handling than CONN
Section titled “Assuming LSTN needs different handling than CONN”Aside from the tag itself, LSTN and CONN are wire-identical - same length,
same callsign/module field layout, same ACKN/NACK reply. A client
implementing LSTN support doesn’t need new parsing logic, just a different
outbound tag. Likewise, a listen-only client that mistakenly sends voice
frames won’t get an error back - the reflector drops them silently, so a bug
here will not surface as a NACK or any other visible failure.
Expecting a callsign in ACKN/NACK
Section titled “Expecting a callsign in ACKN/NACK”This reflector’s ACKN and NACK replies are bare 4-byte tags. Don’t allocate
a 10-byte receive buffer expecting a callsign field on these two packet types
specifically - PING, PONG, and DISC do carry one, ACKN/NACK do not.
Forgetting the trailing space in "M17 "
Section titled “Forgetting the trailing space in "M17 "”The voice frame magic is 4 bytes: M, 1, 7, and a literal space. A 3-byte
"M17" comparison will never match.
Computing CRC over the wrong range
Section titled “Computing CRC over the wrong range”The CRC covers bytes 0-51 only. Including the 2 CRC bytes themselves in the computation, or excluding the LICH or frame number fields, produces a CRC that this reflector will reject as invalid and silently drop.
Computing the M17P CRC over the wrong range
Section titled “Computing the M17P CRC over the wrong range”M17P has two separate CRCs, not one. The LSF CRC covers only bytes 4-31
(DST+SRC+TYPE+META); the payload CRC covers only the payload bytes, not the
LSF and not itself. Reusing the voice frame’s “CRC over everything but the
last 2 bytes” logic for M17P will always fail the LSF CRC check.
Truncating callsigns incorrectly
Section titled “Truncating callsigns incorrectly”Base-40 encoding truncates to the first 9 characters of the callsign, not the last 9. A callsign encoder that truncates from the wrong end will silently produce a different, valid-looking, but wrong callsign.
Assuming the client must initiate keepalives
Section titled “Assuming the client must initiate keepalives”The reflector sends its own PING to every session every 5 seconds independent
of client behavior. A client that only ever waits passively for the reflector’s
PING and never itself times out is fine; a client that assumes it must
actively PING to stay alive is also fine. Either can rely on the 60-second
session timeout being reset by any recognized packet, not specifically by a
client-initiated PING.
Example packet values
Section titled “Example packet values”A CONN packet for callsign N0CALL, module A:
| Field | Value |
|---|---|
| Tag | CONN |
| Callsign | 0x00 0x00 0x4B 0x13 0xD1 0x06 |
| Module | A (0x41) |
A voice frame with stream ID 0x1234, frame number 0x8002 (top bit set -
final frame), and payload bytes 0xA0-0xAF:
| Field | Value |
|---|---|
| Magic | M17 |
| StreamID | 0x1234 |
| LICH | 28 bytes of link information |
| FrameNumber | 0x8002 (bit 0x8000 set: end of stream) |
| Payload | 0xA0 0xA1 ... 0xAF (16 bytes) |
| CRC | CRC-16 of the preceding 52 bytes |
Relationship to Vexillum
Section titled “Relationship to Vexillum”M17 is implemented as a Vexillum mode runtime (internal/modes/m17). The wire
conventions - tag names, base-40 callsigns, CRC polynomial - track the M17
project’s own conventions; the session timeouts, module-gating rules, and
session caps described above are Vexillum’s own operational choices.
Current limitations
Section titled “Current limitations”- No authentication of any kind.
- No transport beyond plain UDP - no TLS/DTLS equivalent.
- One active stream per module; no simultaneous multi-stream mixing.
ACKN/NACKreplies carry no callsign field, which may surprise clients written against other M17 reflector implementations.
Development recommendations
Section titled “Development recommendations”- Implement base-40 callsign encode/decode and check it against the known-good vectors above.
- Implement the CRC-16 function and verify it against a hand-built voice frame.
- Add
CONN/ACKN/NACKhandling. - Add
LSTNhandling as a listen-only variant of step 3 - same parsing, same handshake, just a different outbound tag and no future voice transmission. - Add
PING/PONGin both directions. - Add voice frame parsing and forwarding, including the frame-number end-of-stream check.
- Add
DISC. - If your client needs non-voice data, add
M17Pparsing/generation last - it’s independent of the voice path and optional for a voice-only client. - Test against a running Vexillum M17 instance before assuming compatibility with any other M17 reflector implementation.