DMRD bytes 53-54 (BER, RSSI) are optional on input: HBlink3 passes a
53-byte DMRD through unchanged. Accept anything from 53 bytes and read
byte 54 only when present; output is still 55 bytes.
DMRD byte 54 of a voice header or terminator now goes into the RSSI
field (bytes 52-53) of the VOICE_HEAD / VOICE_TERM built from it, as
8.8 fixed-point -dBm. No reading (53 bytes, 0, or a synthesized
HEAD/TERM) sends 0 as before. The terminator reading is cleared at the
end of each call so it cannot reach a later synthesized TERM.
Byte 31 bit 7 of VOICE_HEAD / VOICE_TERM is the timeslot (0x40 / 0xC0
in repeater captures); it was hardcoded 0x80 (TS2). The RSSI status
bit (bit 6) is left 0.
The old firmware RSSI encoding (-56 dB offset) is dropped: no repeater
using it is known. [ipsc] rssi_encoding_new is no longer read; configs
that still carry it load unchanged.
Tests: 53/55-byte input, RSSI round trip through the IPSC->HBP decoder,
slot bit, terminator reading not carried into the next call, legacy
key fixture, and a late-entry case where only the LC-error bit can
reject a corrupt burst E LC. parity_ref_gv.txt byte 31 corrected to
TS1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[ipsc] rssi_encoding = "new" | "old" could not be set at all: get_choice()
upper-cases the configured value before comparing, and the choices were
lowercase, so both values were rejected as invalid. Only a missing key
(the default) worked, and the tests set the config field directly.
Replace it with [ipsc] rssi_encoding_new = true | false (default true).
The RSSI tests now load both values from config files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
VOICE_HEAD and VOICE_TERM carry a 16-bit RSSI when bit 7 of byte 34 is
set; it follows the data and slot type. It is 8.8 fixed-point -dBm, and
goes out as the HBP byte-54 magnitude on the DMRD built from that packet.
Voice bursts carry no RSSI, so their DMRD send 0, HBP's "no reading";
holding a value would report a measurement that was not made.
[ipsc] rssi_encoding = "new" | "old" selects the firmware encoding (old
adds 56 dB). Only "new" is verified OTA (-60 to -115 dBm on a walk test);
the firmware version where it changed is uncertain.
Decoding and both encodings from node-dmr-lib by rick51231 (MIT). The
field was first located by Marco Brunozzi IW0RED.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The private-call translation in 7d9ce4d is based on his patch: PVT_VOICE
handling in both directions, the FLCO 3 LC, and the burst-E late-entry
check. We dropped its configuration settings and derive the outbound
opcode from the LC's FLCO.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IPSC->HBP: PVT_VOICE (0x81) was dropped. It uses the GROUP_VOICE layout,
so it now goes through the same path: DMRD gets the private-call flag
and the LC is FLCO 3. Late entry anchors on a burst E carrying FLCO 3.
HBP->IPSC: the HBP header LC was already copied verbatim, so radios saw
private calls, but the packets went out as GROUP_VOICE. The opcode now
follows the LC's FLCO, and the fallback LCs (late entry, orphan
terminator) honour the HBP private-call flag.
No on/off setting: the translator carries private calls; the network
server decides who may make them. Confirmed private calls are not
supported.
Tests replay a PVT_VOICE call captured from a MOTOTRBO repeater and an
HBP private call captured from HBlink3, full and late entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a design-goals bullet summarizing what the HBP→IPSC path rebuilds for
IPSC that HBP leaves implicit (embedded-LC late entry, AMBE silence fill,
always-a-terminator) and the invariant that the VOICE_HEAD packet is never
fabricated. Points to §7.9 of the Python theory of operation for the full
rationale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the full-mesh peer mode from ipsc2hbp (Python) to the C stack.
Peer mode previously dropped every packet not from the master's IP and
never used the peer list, so it received no audio in a real IPSC mesh
(where voice is delivered peer-to-peer, not via the master).
- Parse PEER_LIST_REPLY into ip->peers[]; peer-register and keepalive
with each peer (PEER_REG_REQ/_REPLY, PEER_ALIVE_REQ/_REPLY).
- Accept GROUP_VOICE from the master OR any known peer (peer-id
membership, not source host).
- Fan outbound voice to master + all connected peers; IPSC-received
voice still goes only to HBP.
- Per-timeslot source lock (VOICE_LOCK_TIMEOUT / VOICE_DUP_GUARD) drops
duplicate/looped copies of a call so overlapping streams cannot garble
the audio. Gated to peer mode; master path unchanged.
Builds clean (-Wall -Wextra); DSP + parity tests pass.
README/toml.sample updated for the mesh + lock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror of the Python ipsc2hbp fix. The delivery clock was anchored to the
VOICE_HEAD with headers clocked out through a pre-roll FIFO. A live --wire capture
shows the HBP side delivers a SINGLE VOICE_HEAD followed by a ~171-180 ms gap
before the first voice burst (the radio's 2-3 redundant headers are collapsed to
one by the HBP server, leaving a real gap, not a continuous 60 ms grid).
That gap is wider than the 120 ms (depth=2) jitter buffer, so anchoring the clock
to the header consumed the whole jitter lead before voice arrived: the timer fired
empty voice slots, synthesized AMBE silence, and real bursts landed just behind
the advancing delivery pointer and were never popped. With ~10 ms of inbound
jitter this caused constant silence-substitution plus a false stream-timeout that
tore the call down mid-stream -- audible as warbling, lossy-over-the-air audio.
Fix (option A): forward VOICE_HEAD(s) immediately on receive and anchor the voice
delivery clock to the FIRST voice burst (arm_clock_from_voice), giving every burst
the full jitter_buffer_depth of lead regardless of the header->voice gap. Retain
the tail-clip fix: the terminator is held pending and emitted by the clock only
after the buffered voice tail drains (or immediately if the clock is idle).
Removes the now-unused in_head_lc/in_head_n FIFO and in_voice_started state.
Builds clean; DSP self-test 83/83, parity IPSC->HBP 16/16 and HBP->IPSC 2/2 pass.
Updates README and toml-sample clocking docs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the unified delivery-clock model from ipsc2hbp. Headers (pre-roll FIFO),
voice (position-indexed buffer), and the terminator (held pending until the
voice tail drains) all emit on one per-timeslot 60 ms-cadence timer, preserving
the source's header→voice spacing so the repeater sees a continuous native grid
— no header→voice gap and no clipped tail. Headers are relayed faithfully, never
fabricated.
Add [hbp] jitter_buffer_depth config (default 2 = 120 ms, range 1-8) to tune
the delay per link. Drive the event loop in the inbound parity test and
regenerate the inbound parity fixtures for the clocked model.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Feature-for-feature, config-for-config port of the Python ipsc2hbp IPSC↔HBP
translator to dependency-free C11 (links only libc).
- src/dmr/: self-contained DMR DSP/FEC library (BPTC(196,96), embedded LC,
AMBE 49<->72, RS(12,9), Hamming, Golay), validated bit-for-bit against
dmr_utils3 via golden vectors.
- src/: config (in-tree TOML parser), logging, poll() event loop, UDP,
SHA-1/SHA-256/HMAC, IPSC master/peer protocol, HBP client, call translator.
- Single-threaded event loop mirrors the original asyncio design.
- tests/: DSP self-test + byte-for-byte translator parity vs the Python
reference (IPSC->HBP DMRD and HBP->IPSC GROUP_VOICE).
make && make test both pass; integration-tested against the fake peer/master.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>