Files
Cort BuffingtonandClaude Opus 5 046c47b7d0 Group calls only, per-slot lanes, and N instances per process
Three changes, all driven by the same design conversation.

GROUP CALLS ONLY. Private calls to the talkback's radio ID are now
ignored. Unit routing draws on a globally administered namespace and is
unbounded -- a private call to an unknown ID floods until located -- so
reachability-by-ID would require a unique registered radio ID for every
instance anyone ever deploys, for something that is neither a radio nor
a repeater. BrandMeister can do it because it is the sole authority over
its own namespace; these tools assume an internet of independent
routers. Talkgroups are operator-scoped and bounded by the operator's
own rules, so they cost nobody else anything.

Consequence: the radio ID never needs to be reachable, so one ID an
operator already owns serves every instance they run.

PER-SLOT LANES. The previous version had a single global capture buffer,
so two simultaneous callers -- one per timeslot, which the wire delivers
routinely -- reset it on every interleaved packet and captured nothing.
That is the same defect the 2019 Python playback.py had. Capture and
replay are now per timeslot and fully independent. Within a lane, a
second stream no longer supersedes unconditionally: first-come-wins
unless the current capture has gone quiet, so interleaving can never
thrash the buffer.

One talkgroup per slot, deliberately: a slot carries one call at a time,
so a list would advertise capacity that does not exist.

N INSTANCES. One process now runs several independent connections, each
with its own radio ID, server, and talkgroups. The purpose is one
instance per HBlink3 *system*, so each system gets a private talkback
without building a bridge to multiplex everyone into one engine.
Bridging into a single instance remains supported. Two instances sharing
a radio ID on the same server is rejected at startup.

Adds toml_sections() to the vendored reader to discover [instance.<name>]
sections, since it has no array-of-tables.

Tests: rewrite conformance 566 checks; new test_lanes covers lane
isolation, same-lane non-thrash, the ingress gate, and the capture
ceiling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:55:00 -05:00

250 lines
11 KiB
C

/* test_rewrite.c — the loopback-identity conformance vector.
*
* The whole correctness claim of this program is:
*
* the audio that comes back is bit-identical to the audio that went in,
* and everything identifying the call is rewritten to us — in the DMRD
* header AND in both LC carriers inside the payload.
*
* These tests assert exactly that over a synthetic capture covering every
* frame kind a real stream contains.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include "talkback.h"
#include "hbp_const.h"
#include "dmr/dmr.h"
/* replay.c transmits through the HBP client and reads back through the
* instance accessors; the rewrite path under test does neither, so stubs are
* enough to link. */
void hbp_send_dmrd(struct hbp *hb, const uint8_t *data, int len) { (void)hb; (void)data; (void)len; }
int hbp_is_connected(struct hbp *hb) { (void)hb; return 0; }
const char *instance_name(const tb_instance *in) { (void)in; return "test"; }
struct hbp *instance_hbp(const tb_instance *in) { (void)in; return NULL; }
const InstanceCfg *instance_cfg(const tb_instance *in) { (void)in; return NULL; }
ev_loop *instance_loop(const tb_instance *in) { (void)in; return NULL; }
static int failures = 0;
static int checks = 0;
#define CHECK(cond, ...) do { \
checks++; \
if (!(cond)) { failures++; printf(" FAIL: "); printf(__VA_ARGS__); printf("\n"); } \
} while (0)
/* ------------------------------------------------------------------ */
/* Synthetic capture */
/* ------------------------------------------------------------------ */
#define ORIG_SRC 3121234u
#define ORIG_TGID 9990u
#define TB_RADIO 3120099u
#define ORIG_SID 0xAABBCCDDu
#define NEW_SID 0x11223344u
static uint32_t prng_state = 0x12345678u;
static uint8_t prng_byte(void) {
prng_state = prng_state * 1664525u + 1013904223u;
return (uint8_t)(prng_state >> 24);
}
static void wr24(uint8_t *p, uint32_t v) { p[0]=(uint8_t)(v>>16); p[1]=(uint8_t)(v>>8); p[2]=(uint8_t)v; }
static void wr32(uint8_t *p, uint32_t v) { p[0]=(uint8_t)(v>>24); p[1]=(uint8_t)(v>>16); p[2]=(uint8_t)(v>>8); p[3]=(uint8_t)v; }
static uint32_t rd24(const uint8_t *p) { return (uint32_t)p[0]<<16 | (uint32_t)p[1]<<8 | p[2]; }
static uint32_t rd32(const uint8_t *p) { return (uint32_t)p[0]<<24 | (uint32_t)p[1]<<16 | (uint32_t)p[2]<<8 | p[3]; }
/* One DMRD packet. flags carries slot / call type / frame type / dtype. */
static void mk_pkt(uint8_t *out, uint8_t seq, uint32_t src, uint32_t dst,
uint8_t flags, uint32_t sid)
{
memset(out, 0, DMRD_LEN);
memcpy(out, "DMRD", 4);
out[DMRD_SEQ_OFF] = seq;
wr24(out + DMRD_SRC_OFF, src);
wr24(out + DMRD_DST_OFF, dst);
wr32(out + DMRD_RPTR_OFF, 312999); /* the caller's repeater */
out[DMRD_FLAGS_OFF] = flags;
wr32(out + DMRD_STREAM_OFF, sid);
for (int i = 0; i < 33; i++) out[DMRD_PAYLOAD_OFF + i] = prng_byte();
out[DMRD_BER_OFF] = 0x07; /* non-zero, must be cleared */
out[DMRD_RSSI_OFF] = 0x5A;
}
/* VHEAD + 2 superframes (A..F) + VTERM = 14 packets. */
#define N_PKTS 14
static int build_capture(uint8_t *buf, int slot, int call_p, uint32_t dst)
{
uint8_t base = (uint8_t)((slot == 2 ? HBPF_TGID_TS2 : 0) |
(call_p ? HBPF_TGID_CALL_P : 0));
int n = 0;
uint8_t seq = 0;
mk_pkt(buf + n*DMRD_LEN, seq++, ORIG_SRC, dst,
base | HBPF_FRAMETYPE_DATASYNC | HBPF_SLT_VHEAD, ORIG_SID); n++;
for (int sf = 0; sf < 2; sf++) {
/* Burst A is a VOICESYNC frame, vseq 0; B..F are VOICE, vseq 1..5. */
mk_pkt(buf + n*DMRD_LEN, seq++, ORIG_SRC, dst,
base | HBPF_FRAMETYPE_VOICESYNC | 0, ORIG_SID); n++;
for (int v = 1; v <= 5; v++) {
mk_pkt(buf + n*DMRD_LEN, seq++, ORIG_SRC, dst,
base | HBPF_FRAMETYPE_VOICE | (uint8_t)v, ORIG_SID); n++;
}
}
mk_pkt(buf + n*DMRD_LEN, seq++, ORIG_SRC, dst,
base | HBPF_FRAMETYPE_DATASYNC | HBPF_SLT_VTERM, ORIG_SID); n++;
return n;
}
/* ------------------------------------------------------------------ */
/* Assertions */
/* ------------------------------------------------------------------ */
static void expected_lc(uint8_t lc[9], uint32_t dst, uint32_t src)
{
lc[0] = 0x00; /* FLCO: group voice — always */
lc[1] = 0x00; /* FID */
lc[2] = 0x00; /* service options — normalized */
wr24(lc + 3, dst);
wr24(lc + 6, src);
}
/* Pull the 9-byte LC back out of a VHEAD/VTERM payload. */
static void extract_full_lc(const uint8_t *pkt, uint8_t lc_out[9])
{
dmr_bit fb[264];
dmr_bytes_to_bits(pkt + DMRD_PAYLOAD_OFF, 33, fb);
dmr_bit bptc_bits[196];
memcpy(bptc_bits, fb, 98);
memcpy(bptc_bits + 98, fb + 166, 98);
dmr_bptc_decode_full_lc(bptc_bits, lc_out);
}
/* `call_p` seeds the captured packets with the private-call bit set, to prove
* the rewrite forces it clear. Real captures never carry it — the ingress
* gate rejects private calls — but the header must never be able to disagree
* with the group FLCO we build. */
static void run_case(const char *label, int slot, int call_p, uint32_t tgid)
{
printf("%s\n", label);
uint8_t in[N_PKTS * DMRD_LEN];
int n = build_capture(in, slot, call_p, tgid);
uint8_t dst3[3]; wr24(dst3, tgid);
tb_rewrite rw;
tb_rewrite_init(&rw, TB_RADIO, dst3, NEW_SID);
uint8_t exp_lc[9];
expected_lc(exp_lc, tgid, TB_RADIO);
CHECK(memcmp(rw.lc, exp_lc, 9) == 0,
"reply LC mismatch: got %02x%02x%02x %06x %06x",
rw.lc[0], rw.lc[1], rw.lc[2], rd24(rw.lc+3), rd24(rw.lc+6));
for (int i = 0; i < n; i++) {
const uint8_t *ip = in + i * DMRD_LEN;
uint8_t op[DMRD_LEN];
tb_rewrite_packet(&rw, ip, op, (uint8_t)i);
/* ---- header ---- */
CHECK(op[DMRD_SEQ_OFF] == (uint8_t)i, "pkt %d: seq %u != %d", i, op[DMRD_SEQ_OFF], i);
CHECK(rd24(op + DMRD_SRC_OFF) == TB_RADIO,
"pkt %d: src %u != %u", i, rd24(op + DMRD_SRC_OFF), TB_RADIO);
CHECK(rd24(op + DMRD_DST_OFF) == tgid,
"pkt %d: dst %u != %u", i, rd24(op + DMRD_DST_OFF), tgid);
CHECK(rd32(op + DMRD_RPTR_OFF) == TB_RADIO,
"pkt %d: repeater %u != %u", i, rd32(op + DMRD_RPTR_OFF), TB_RADIO);
CHECK(rd32(op + DMRD_STREAM_OFF) == NEW_SID, "pkt %d: stream ID not rewritten", i);
CHECK(op[DMRD_BER_OFF] == 0 && op[DMRD_RSSI_OFF] == 0, "pkt %d: BER/RSSI not zeroed", i);
uint8_t ifl = ip[DMRD_FLAGS_OFF], ofl = op[DMRD_FLAGS_OFF];
CHECK((ofl & HBPF_TGID_TS2) == (ifl & HBPF_TGID_TS2), "pkt %d: slot bit changed", i);
CHECK((ofl & HBPF_FRAMETYPE_MASK) == (ifl & HBPF_FRAMETYPE_MASK), "pkt %d: frame type changed", i);
CHECK((ofl & HBPF_DTYPE_MASK) == (ifl & HBPF_DTYPE_MASK), "pkt %d: dtype/vseq changed", i);
CHECK((ofl & HBPF_TGID_CALL_P) == 0, "pkt %d: private-call bit not forced clear", i);
/* ---- payload ---- */
int ftyp = ifl & HBPF_FRAMETYPE_MASK;
int dtyp = ifl & HBPF_DTYPE_MASK;
dmr_bit ib[264], ob[264];
dmr_bytes_to_bits(ip + DMRD_PAYLOAD_OFF, 33, ib);
dmr_bytes_to_bits(op + DMRD_PAYLOAD_OFF, 33, ob);
if (ftyp == HBPF_FRAMETYPE_DATASYNC && (dtyp == HBPF_SLT_VHEAD || dtyp == HBPF_SLT_VTERM)) {
uint8_t got[9];
extract_full_lc(op, got);
CHECK(memcmp(got, exp_lc, 9) == 0,
"pkt %d (%s): decoded LC %02x%02x%02x %06x %06x != expected",
i, dtyp == HBPF_SLT_VHEAD ? "VHEAD" : "VTERM",
got[0], got[1], got[2], rd24(got+3), rd24(got+6));
/* Slot type + sync window carries the colour code — preserved. */
CHECK(memcmp(ib + 98, ob + 98, 68) == 0,
"pkt %d: slot-type/sync window [98:166] was modified", i);
} else if (ftyp == HBPF_FRAMETYPE_VOICE && dtyp >= 1 && dtyp <= 4) {
uint8_t emb[4][4];
dmr_encode_emblc(exp_lc, emb);
dmr_bit want[32];
dmr_bytes_to_bits(emb[dtyp - 1], 4, want);
CHECK(memcmp(ob + 116, want, 32) == 0,
"pkt %d: EMB LC fragment for burst %c wrong", i, 'A' + dtyp);
/* Everything outside [116:148] is AMBE and must be untouched. */
CHECK(memcmp(ib, ob, 116) == 0 && memcmp(ib + 148, ob + 148, 264 - 148) == 0,
"pkt %d: AMBE outside the EMB window was modified", i);
} else {
/* Burst A, burst F, and anything else: payload untouched entirely. */
CHECK(memcmp(ip + DMRD_PAYLOAD_OFF, op + DMRD_PAYLOAD_OFF, 33) == 0,
"pkt %d: payload modified on a frame that carries no LC", i);
}
}
}
/* Two lanes rewritten independently must not bleed into each other: each keeps
* its own slot bit and its own talkgroup. */
static void run_lane_independence(void)
{
printf("TS1 and TS2 rewrites stay on their own slot and talkgroup\n");
uint8_t in1[N_PKTS * DMRD_LEN], in2[N_PKTS * DMRD_LEN];
int n1 = build_capture(in1, 1, 0, 9u);
int n2 = build_capture(in2, 2, 0, ORIG_TGID);
CHECK(n1 == n2, "capture lengths differ");
uint8_t d1[3], d2[3];
wr24(d1, 9u); wr24(d2, ORIG_TGID);
tb_rewrite rw1, rw2;
tb_rewrite_init(&rw1, TB_RADIO, d1, 0xAAAA0001u);
tb_rewrite_init(&rw2, TB_RADIO, d2, 0xBBBB0002u);
for (int i = 0; i < n1; i++) {
uint8_t o1[DMRD_LEN], o2[DMRD_LEN];
tb_rewrite_packet(&rw1, in1 + i*DMRD_LEN, o1, (uint8_t)i);
tb_rewrite_packet(&rw2, in2 + i*DMRD_LEN, o2, (uint8_t)i);
CHECK((o1[DMRD_FLAGS_OFF] & HBPF_TGID_TS2) == 0, "pkt %d: TS1 reply left TS1", i);
CHECK((o2[DMRD_FLAGS_OFF] & HBPF_TGID_TS2) != 0, "pkt %d: TS2 reply left TS2", i);
CHECK(rd24(o1 + DMRD_DST_OFF) == 9u, "pkt %d: TS1 reply wrong TG", i);
CHECK(rd24(o2 + DMRD_DST_OFF) == ORIG_TGID, "pkt %d: TS2 reply wrong TG", i);
CHECK(rd32(o1 + DMRD_STREAM_OFF) != rd32(o2 + DMRD_STREAM_OFF),
"pkt %d: lanes share a stream ID", i);
}
}
int main(void)
{
printf("dmr-talkback rewrite conformance\n\n");
run_case("group call on TS2 -> replayed onto the same talkgroup", 2, 0, ORIG_TGID);
run_case("group call on TS1 -> replayed onto the same talkgroup", 1, 0, 9u);
run_case("stray private-call bit is forced clear on the reply", 2, 1, ORIG_TGID);
run_lane_independence();
printf("\n%d checks, %d failures\n", checks, failures);
return failures ? 1 : 0;
}