22 Commits
Author SHA1 Message Date
Cort BuffingtonandClaude Opus 5.5 ec8bbfaedc HBP->IPSC: accept 53-byte DMRD, carry RSSI, set the slot bit; drop old RSSI encoding
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>
2026-10-21 07:41:11 -05:00
Cort BuffingtonandClaude Opus 5.5 3cb47c74ac Config: make the RSSI encoding a boolean, rssi_encoding_new
[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>
2026-10-14 05:58:38 -05:00
Cort BuffingtonandClaude Opus 5.5 a8503c6437 docs: describe the IPSC->HBP RF handling, the capture-based tests, and C-only features
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:23:57 -05:00
Cort BuffingtonandClaude Opus 5.5 d3d480a280 Makefile: rebuild objects when the headers they include change
Objects had no header dependencies, so changing a shared header (here
config.h gaining a field) left stale objects linked against the old
struct layout: ipsc.o read the mode and master address at the wrong
offsets and the bridge came up as an IPSC peer. Generate .d files with
-MMD -MP and include them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:20:30 -05:00
Cort BuffingtonandClaude Opus 5.5 06b02e6ea3 IPSC->HBP: forward the repeater's RSSI in DMRD byte 54
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>
2026-10-13 11:14:59 -05:00
Cort BuffingtonandClaude Opus 5.5 640a952df5 IPSC->HBP late entry: test burst E control bits, not the whole byte
Late entry required data[32] == 0x16 exactly. On a weak signal the
repeater also sets 0x01 (AMBE frame 1 bad) and 0x80 (embedded LC failed
its check). In the OTA capture every 0x80 burst E carried a corrupt LC,
while 0x17 carried a good one, so the exact match was right to reject
0x9x but also rejected 0x17 and delayed late entry a superframe.

Identify burst E by the embedded-LC-present bit (0x10) and reject it
only when the LC-error bit is set. The data[32] flags now share one set
of named constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:51:04 -05:00
Cort BuffingtonandClaude Opus 5.5 603501e55c IPSC->HBP: re-sync the superframe position on every burst A
The burst position was set only at VOICE_HEAD / late entry and then
counted. When the repeater loses a burst it sends a 0x13 placeholder and
restarts at a fresh burst A, so every later burst went to HBP one
position late for the rest of the call (sync and embedded LC on the
wrong bursts). IPSC marks burst A with the voice-sync flag (data[32]
bit 6); reset the position to 0 on it.

Test replays a weak-signal call captured OTA in which this happened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:46:56 -05:00
Cort BuffingtonandClaude Opus 5.5 86e11b43ff Log IPSC "sync not detected" bursts by name; fix format-truncation warnings
Burst type 0x13 is the repeater's placeholder for a burst it lost on RF
(34 bytes, no AMBE). It was reaching the translator and logged as
"SLOT_VOICE too short for AMBE". It is now logged as what it is and not
forwarded; HBP has no equivalent frame.

The log.c and config.c -Wformat-truncation fixes are from Marco
Brunozzi IW0RED's patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:36:50 -05:00
Cort BuffingtonandClaude Opus 5.5 f7028536d5 README: credit Marco Brunozzi IW0RED for private call support
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>
2026-10-13 10:32:51 -05:00
Cort BuffingtonandClaude Opus 5.5 7d9ce4dc8e Translate private (unit-to-unit) voice calls in both directions
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>
2026-10-13 10:24:16 -05:00
Cort BuffingtonandClaude Opus 5.5 ec83237268 IPSC->HBP: send MMDVMHost silence for AMBE frames the repeater flags bad
The repeater sets a per-AMBE-frame "bad voice" flag when it could not
correct the frame on RF receive (confirmed OTA on a weak signal). We
re-encoded those frames with fresh, valid FEC, so they reached HBP
looking good. MMDVMHost replaces uncorrectable frames with silence, so
substitute its DMR_SILENCE_DATA frame for each flagged frame.

No FEC repair is added in the HBP->IPSC direction: MMDVMHost already
corrects every RF frame or replaces it with silence before it reaches
the network, so HBP never carries damaged frames.

Tests: new bad-voice check; synthetic parity inputs had the flag bits set
at random, cleared them (the reference ignores those bits, output
unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:22:11 -05:00
Cort BuffingtonandClaude Opus 5 667c1a559b Trim the padding comments to facts
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 08:59:59 -05:00
Cort BuffingtonandClaude Opus 5 f6a850d6b5 HBP: pad the RPTC blob like DMRGateway, send RPTO unpadded
Match the three conventions in DMRGateway's config-blob sprintf: text
left-justified/space, numerics right-justified/zero, lat-long
right-justified/space. Was NUL-filling every field, which connects fine
but leaves NULs in values consumers read as text.

RPTO is now 8 + strlen(options) rather than a padded 308 bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 08:52:41 -05:00
Cort BuffingtonandClaude Opus 4.8 f5a4a12dc1 docs: note HBP→IPSC reconstruction rationale in README
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>
2026-07-26 20:10:01 -05:00
Cort BuffingtonandClaude Opus 4.8 c389e32c22 hbp: add login-handshake watchdog so a dead master triggers reconnect
The keepalive watchdog only guarded an established session; nothing
covered the connection attempt itself. If the master never answered the
RPTL (host down/unreachable, no ICMP to trigger error handling), the
client sat in ST_LOGIN forever and never retried — so it stayed dead
even after the master came back.

Arm a login timer when the handshake starts, re-arm it at each step, and
cancel it on become_connected/disconnect. On timeout it drives the
existing disconnect -> reconnect path, re-sending RPTL until the master
answers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:11:57 -05:00
Cort BuffingtonandClaude Opus 4.8 9c84d448f3 IPSC peer mode: implement full mesh + duplicate suppression
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>
2026-07-01 21:46:48 -05:00
Cort BuffingtonandClaude Opus 4.8 f0fd095ef0 translate: anchor HBP->IPSC playout clock to first voice burst, not the header
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>
2026-06-24 08:04:35 -05:00
Cort BuffingtonandClaude Opus 4.8 eb73b5b462 translate: clock entire HBP→IPSC call through the jitter buffer
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>
2026-06-22 09:51:01 -05:00
Cort BuffingtonandClaude Opus 4.8 50750fd31b translate: shorten dead-man call timeout 10s -> 2s (match Python)
Port of ipsc2hbp e4d63a2. translator_check_call_timeouts already handled both
directions and is driven by the ~5s watchdog; only the threshold changes. 2s
keeps the logged call-end close to the real call duration for the lost-VOICE_TERM
case; well above every in-call wait. Effective clearing ~2-7s (rides the 5s
watchdog), same as Python.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:41:14 -05:00
Cort BuffingtonandClaude Opus 4.8 ae6a7b2ca1 translate: coalesce Talker-Alias per-superframe IPSC streams (IPSC->HBP)
Port of the Python fix (ipsc2hbp fix/ta-ghost-streams). Current XPR8400 firmware,
when a radio transmits with Talker Alias enabled, emits a new IPSC stream every
superframe for one OTA call: the call-seq (byte 5) increments per superframe and
the TA superframes carry the alias text in the common-header src/dst (non-GVCU
FLCO at burst-E byte 56). RTP timestamp stays continuous — it is one call. Read
on the wire: 1 PTT -> 30 IPSC streams (half a phantom 'N0MJS' identity) = the
ghost traffic.

Re-anchor the IPSC->HBP path in translator_ipsc_voice_received:
  - Stop treating an IPSC call-seq (byte 5) change as a call boundary; detect a
    new call by RTP-timestamp discontinuity (out_call_continues, OUT_RTP_MAX_FWD).
  - Late entry anchors identity only to a GVCU (FLCO 0x00) burst E; a Talker
    Alias / GPS burst E is skipped (its header src/dst are alias bytes).
  - Forward every frame under the LOCKED identity (out_lc) and one stable HBP
    stream ID, ignoring per-frame header relabels.
  - Relabel the start/end pairing token ipsc_id -> int_seq_id (no longer a stable
    IPSC id; it is just a token to pair the log lines).

VOICE_HEAD stays byte-5 anchored (HEAD/TERM carry no usable RTP; a new byte 5 at
a header = new stream). New ipsc_const.h: GV_RTP_TS_OFF, GV_BE_FLAG,
GV_BE_LC_FLCO_OFF, FLCO_GROUP. Parity goldens regenerated from the fixed Python:
make test passes byte-for-byte (DSP 83/83, parity IPSC->HBP 16/16, HBP->IPSC 2/2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:07:06 -05:00
Cort BuffingtonandClaude Opus 4.8 df756cb1f9 translate: clear jitter buffer on HBP mid-stream stream-ID change
Match the upstream Python fix: when an HBP voice burst arrives with a new
stream ID and no prior VOICE_TERM, also clear the per-timeslot jitter buffer
(in addition to cancelling the delivery timer and clearing call state) so no
stale AMBE from the prior call leaks into the re-entered stream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:45:33 -05:00
Cort BuffingtonandClaude Opus 4.8 a8b4eca5c0 Initial commit: ipsc2hbpc — C reimplementation of ipsc2hbp
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>
2026-06-17 08:37:41 -05:00