Skip to content

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.

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

A client should handle:

  • CONN / ACKN / NACK module connect handshake
  • LSTN / ACKN / NACK listen-only connect handshake - optional, only needed by clients that want to monitor a module without ever transmitting
  • PING / PONG keepalive, in both directions
  • "M17 " voice stream frames, including CRC validation
  • M17P packet-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
  • DISC for 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.

M17 callsigns are packed into 6 bytes using a base-40 alphabet, rather than sent as plain ASCII.

Alphabet, indexed 0-39:

index:  0    1    2    3    4    5    6    7    8    9
char:  ' '  'A'  'B'  'C'  'D'  'E'  'F'  'G'  'H'  'I'

index: 10   11   12   13   14   15   16   17   18   19
char:  'J'  'K'  'L'  'M'  'N'  'O'  'P'  'Q'  'R'  'S'

index: 20   21   22   23   24   25   26   27   28   29
char:  'T'  'U'  'V'  'W'  'X'  'Y'  'Z'  '0'  '1'  '2'

index: 30   31   32   33   34   35   36   37   38   39
char:  '3'  '4'  '5'  '6'  '7'  '8'  '9'  '-'  '/'  '.'

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.

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:

function m17_crc(data):
    crc = 0xFFFF
    for each byte b in data:
        crc = crc XOR (b << 8)
        repeat 8 times:
            if (crc AND 0x8000) != 0:
                crc = (crc << 1) XOR 0x5935
            else:
                crc = crc << 1
        # crc kept as an unsigned 16-bit value throughout
    return crc

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.

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.

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.

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).

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.

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.

A module connect (CONN) is only accepted if:

  1. The requested module byte is an ASCII letter A-Z, and
  2. 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.

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 CONN session.
  • It receives the reflector’s PING and may send PING/expect PONG exactly like a transmitting client.
  • It receives every voice frame forwarded on its module, exactly like a transmitting client would.
  • It is disconnected via DISC exactly 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.

Client                           Server
  |                                |
  | CONN                           |
  | callsign = base40(client call) |
  | module   = 'A'                 |
  |------------------------------->|
  |                                |
  | ACKN (bare 4-byte tag)         |
  |<-------------------------------|

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.

Client                              Server
  |                                   |
  | PING                              |
  | callsign = client callsign        |
  |---------------------------------->|
  |                                   |
  | PONG                              |
  | callsign = echoed client callsign |
  |<----------------------------------|

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.

  • 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 FrameNumber has 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 StreamID for 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.

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 M17P frame is only accepted from an address with an existing, non listen-only session (i.e. one that connected via CONN, not LSTN). A listen-only session’s M17P frames 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 M17P frame 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.

DISCONNECTED
  |
  v
CONNECTING (sent CONN)
  |
  +-- NACK --> DISCONNECTED
  |
  +-- ACKN --> READY
                |
                +-- PTT active --> TRANSMITTING
                |
                +-- incoming "M17 " frame --> RECEIVING

Terminal conditions: transport failure, session timeout (no traffic seen for 60 seconds), inbound DISC, or user shutdown.

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.

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.

The voice frame magic is 4 bytes: M, 1, 7, and a literal space. A 3-byte "M17" comparison will never match.

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.

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.

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

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.

  • 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/NACK replies carry no callsign field, which may surprise clients written against other M17 reflector implementations.
  1. Implement base-40 callsign encode/decode and check it against the known-good vectors above.
  2. Implement the CRC-16 function and verify it against a hand-built voice frame.
  3. Add CONN / ACKN / NACK handling.
  4. Add LSTN handling as a listen-only variant of step 3 - same parsing, same handshake, just a different outbound tag and no future voice transmission.
  5. Add PING / PONG in both directions.
  6. Add voice frame parsing and forwarding, including the frame-number end-of-stream check.
  7. Add DISC.
  8. If your client needs non-voice data, add M17P parsing/generation last - it’s independent of the voice path and optional for a voice-only client.
  9. Test against a running Vexillum M17 instance before assuming compatibility with any other M17 reflector implementation.