-
Notifications
You must be signed in to change notification settings - Fork 0
Receiver Guide
How to build a compatible Voicetastic reassembler — protocol-level checklist plus reference-implementation pointers.
You'll be receiving Meshtastic Data packets. Filter:
if data.portnum != PRIVATE_APP as i32 { return; } // 256
if detect_version(&data.payload) != Some(0x03) { return; }The first-byte version check lets future protocol revisions co-exist on the same port without breaking older clients.
use voicetastic_core::voice::{AssemblerConfig, VoiceAssembler};
use std::time::Duration;
let asm = VoiceAssembler::new(AssemblerConfig {
message_timeout: Duration::from_secs(30),
partial_play_on_timeout: true,
..AssemblerConfig::default()
});| Field | Default | Meaning |
|---|---|---|
message_timeout |
30 s | Hard timeout per in-progress message. |
partial_play_on_timeout |
true |
Emit incomplete messages on timeout (vs. discard). |
match asm.accept(&from_id, to, channel, &payload) {
AssemblyEvent::Pending => {} // accumulating
AssemblyEvent::Duplicate => {} // already had this chunk
AssemblyEvent::Complete(m) => handle_voice(*m).await?, // done!
AssemblyEvent::Nack(info) => route_to_sender(info), // we're hearing the other end's NACK
AssemblyEvent::Rejected(e) => warn!(?e, "bad frame"),
}from_id MUST be the canonical lowercase !hex8 form
(voicetastic_core::ids::node_num_to_id).
You MUST drive tick() periodically to handle timeouts and emit NACKs:
let mut tick = tokio::time::interval(Duration::from_millis(250));
loop {
tokio::select! {
_ = tick.tick() => {
let out = asm.tick();
for completed in out.finalized {
handle_voice(completed).await?;
}
for nack in out.nacks {
let to_node = node_id_to_num(&nack.from)?;
svc.send_data(
PRIVATE_APP as i32,
nack.frame,
nack.channel,
Some(to_node),
/* want_ack */ false,
).await?;
}
}
// ... your inbound recv loop here
}
}Recommended cadence: 100–250 ms. Below that you spin needlessly;
above that you delay NACK emission past the spec's
NACK_WINDOW_MS = 3000.
Broadcast messages emit no NACK. The assembler silently suppresses NACK frames when the in-progress entry's destination is the broadcast address — multiple receivers would otherwise pile retransmits onto the sender with no clear target. Broadcast still gets FEC + timeout-based partial finalize. See Reliability — Broadcast suppression.
The assembler enforces all of these for you:
| Bound | Default | What happens at the limit |
|---|---|---|
MAX_IN_PROGRESS_GLOBAL |
64 | Oldest started_at entry evicted + blacklisted. |
MAX_IN_PROGRESS_PER_SENDER |
4 | New messages from that sender rejected with PerSenderCap. |
MAX_MESSAGE_BYTES |
55 845 | Structurally bounded (u8 × MAX_BODY_SIZE). |
BLACKLIST_TTL |
60 s | After a message completes/times out, late frames for it are silently dropped. |
BLACKLIST_MAX |
100 | Oldest entries evicted FIFO. |
NACK_MAX_ROUNDS |
400 | After 400 consecutive NACK rounds with no new chunks, finalize-or-discard. Resets on every accepted shard. |
NACK_WINDOW_MS |
3000 | Quiet period after the last seen chunk before emitting a NACK. |
MAX_VALIDATION_STRIKES (impl) |
3 | After 3 post-template mismatches, the entry is evicted + blacklisted. |
The assembler returns AssemblyEvent::Rejected(VoiceError::*) for:
-
version != 0x03(V2 or future protocol version) - Any reserved bit of
type_flagsset (0x2Fmask) -
packet_type == 3(reserved) total_data == 0-
chunk_index >= total_data(DATA) or>= parity_count(PARITY) parity_count > 128- Unknown
codec - Codec /
total_data/stream_seqmismatch versus the established template - DATA / PARITY body length mismatch versus the established
chunk_size - Header SHA-256 integrity tag mismatch
-
(from, message_id)is on the recently-completed blacklist - New
message_idwhile the sender is atMAX_IN_PROGRESS_PER_SENDER
See Error Catalogue for one-line descriptions of each variant.
async fn handle_voice(msg: VoiceMessage) {
if !msg.is_complete {
// Partial — playback may stutter. recovered_via_fec tells you
// how many chunks the FEC layer saved.
}
if msg.received_data == 0 {
// Pure-zero playback. Skip unless you really want a silent file.
return;
}
// The audio is raw codec frames. For AMR-NB you'd write:
// #!AMR\n ‖ msg.audio
// before handing to a player.
}The fields you'll usually inspect:
| Field | Meaning |
|---|---|
is_complete |
All total_data chunks present (after FEC). |
received_data |
How many DATA chunks landed (incl. FEC reconstructions). |
recovered_via_fec |
How many were reconstructed by Reed-Solomon. |
audio |
Raw codec bytes, no container header. |
-
Forgetting
tick(). Without it, NACKs are never emitted and partially-lost messages stall untilmessage_timeout. -
Passing the wrong
from. Usevoicetastic_core::ids::node_num_to_id(packet.from)so the assembler receives the canonical lowercase!hex8form. -
Accepting frames from non-
PRIVATE_APPports. Other apps share the same Meshtastic radio; filter byportnum == 256and the version byte. -
Wrapping the audio in a container header at the wrong layer. The
protocol carries raw codec frames. Wrap to
#!AMR\n(or whatever) only when writing to disk for an external player.
→ Continue to Constants and Limits.