HBlink4 Stream Tracking System
April 21, 2026 · View on GitHub
Overview
The stream tracking system is the core of HBlink4's DMR traffic management, providing per-slot per-repeater transmission state tracking with sophisticated contention handling and intelligent routing. This system enables HBlink4 to correctly manage simultaneous transmissions, prevent slot hijacking, and efficiently forward traffic between repeaters.
Key Capabilities:
- Fast Terminator Detection: Streams end in ~60ms (primary method) vs 2000ms timeout (fallback)
- Hang Time Protection: Prevents slot hijacking between transmissions in a conversation
- Per-Stream Routing Cache: Calculate-once, forward-many (O(1) routing decisions)
- RX/TX Contention Handling: Automatic bandwidth optimization when repeaters start receiving
- Bidirectional Routing: Symmetric inbound/outbound talkgroup filtering
Design Principles
1. Slot Independence
Each repeater has two independent timeslots (1 and 2) that can carry different streams simultaneously. A repeater can be receiving on slot 1 while transmitting on slot 2, or handling different talkgroups on each slot.
2. First-Come-First-Served with Hang Time Protection
When multiple sources attempt to use the same slot:
- Active streams: First stream wins, others are denied (contention)
- Hang time period: After stream ends, slot is reserved for the original source only
- Hang time rules:
- Same user: Can continue on same TG or switch to different TG (fast TG switching)
- Different user, same TG: Can join conversation (multi-user conversation)
- Different user, different TG: DENIED (hijacking prevention)
3. Fast Terminator Detection
Primary stream ending method using DMR frame analysis:
- Terminator frame detected: Stream ends immediately (~60ms after PTT release)
- Timeout fallback: Used only when terminator packet is lost (~2000ms delay)
- Result: Near-instant slot availability for legitimate users
4. Intelligent Routing with Per-Stream Caches
Traffic forwarding uses per-stream routing caches:
- Calculate once: Routing decisions made at stream start
- Forward many: All packets use cached targets (O(1) forwarding)
- Inbound/outbound filtering: Symmetric talkgroup access control
- Automatic optimization: Repeaters removed from cache when they start receiving
5. Real vs Assumed Streams
- Real streams (RX): Traffic received FROM a repeater (actual RF activity)
- Assumed streams (TX): Traffic sent TO a repeater (what we're forwarding)
- Priority: Real streams always win over assumed streams (repeater RX > server TX)
Core Data Structures
StreamState
Tracks an active DMR transmission stream on a specific repeater slot.
@dataclass
class StreamState:
repeater_id: bytes # Repeater this stream is on (4 bytes)
rf_src: bytes # RF source DMR ID (3 bytes)
dst_id: bytes # Destination talkgroup/ID (3 bytes)
slot: int # Timeslot (1 or 2)
start_time: float # When transmission started (Unix timestamp)
last_seen: float # Last packet received (Unix timestamp)
stream_id: bytes # Unique stream identifier (4 bytes)
packet_count: int = 0 # Number of packets in this stream
ended: bool = False # True when stream ended (in hang time)
end_time: Optional[float] = None # When stream ended (for hang time calculation)
call_type: str = "unknown" # "group", "private", "data", or "unknown"
is_assumed: bool = False # True if TX stream (forwarded TO repeater)
target_repeaters: Optional[set] = None # Cached set of repeater_ids for forwarding
routing_cached: bool = False # True once routing calculated
lc_base: Optional[bytes] = None # 9-byte Link Control captured at stream start
# (decoded from VHEAD if present, else synthesized)
lc_cache: Dict[Tuple[bytes, bytes], Any] = field(default_factory=dict)
# Per-target (out_dst, out_src) → encoded
# (h_lc, t_lc, emb_lc) for payload rewrite under
# translation. See docs/dmrd_translation.md.
Key Methods:
is_active(timeout: float): Returns True if stream has received a packet within timeout periodis_in_hang_time(timeout: float, hang_time: float): Returns True if stream is in hang time period
Stream Types:
- RX Stream (
is_assumed=False): Traffic received from a repeater's local users - TX Stream (
is_assumed=True): Traffic we're forwarding to a repeater (tracking what we send)
RepeaterState Extensions
Stream tracking fields added to RepeaterState:
# Talkgroup access control (stored as sets for O(1) lookup)
# None = allow all, empty set = deny all, non-empty set = allow only those TGs
slot1_talkgroups: Optional[set] = None
slot2_talkgroups: Optional[set] = None
rpto_received: bool = False # True if repeater sent RPTO
# Active stream tracking per slot
slot1_stream: Optional[StreamState] = None
slot2_stream: Optional[StreamState] = None
Key Methods:
get_slot_stream(slot: int): Get the active stream for a slot (1 or 2)set_slot_stream(slot: int, stream: Optional[StreamState]): Set or clear a slot's stream
Stream Lifecycle
1. Stream Start (RX from Repeater)
When the first DMRD packet arrives on an idle slot:
def _handle_stream_start(repeater, rf_src, dst_id, slot, stream_id, call_type_bit) -> bool
Process:
-
Check for existing stream:
- If same
stream_id: Allow (continuation - should not happen, but handle gracefully) - If different
stream_idand stream active: DENY (contention) - If different
stream_idand stream in hang time: Check hang time rules
- If same
-
Hang Time Rules (if stream exists and ended):
- Same user (
rf_srcmatches): Allow (can continue or switch TG) - Different user, same TG (
dst_idmatches): Allow (join conversation) - Different user, different TG: DENY (hijacking prevention)
- Same user (
-
Inbound Routing Check (if slot available):
- Call
_check_inbound_routing(repeater_id, slot, tgid) - Verify TG is in repeater's allowed list for this slot
- If denied: Log warning and return False
- Call
-
Calculate Routing Targets (once per stream):
- Call
_calculate_stream_targets(...)to find which repeaters should receive this traffic - Checks outbound routing rules for each potential target
- Checks slot availability (no contention on target)
- Returns set of repeater_ids that will receive ALL packets in this stream
- Call
-
Create StreamState:
- Initialize with
is_assumed=False(real RX stream) - Store routing targets in
target_repeaters - Set
routing_cached=True - Set
packet_count=1
- Initialize with
-
Assign to slot:
repeater.set_slot_stream(slot, new_stream) -
Log stream start:
INFO - RX stream started on repeater X slot Y: src=..., dst=..., stream_id=..., targets=N -
Emit event:
stream_startevent to dashboard -
Update user cache: Record last known repeater for this DMR ID (for private call routing)
2. Stream Continuation
When a DMRD packet arrives on a slot with an active stream:
def _handle_stream_packet(repeater, rf_src, dst_id, slot, stream_id, call_type_bit) -> bool
Process:
-
Get current stream:
current_stream = repeater.get_slot_stream(slot) -
If no stream: Call
_handle_stream_start()(first packet) -
Stream ID validation:
- Same
stream_id: Update stream state - Different
stream_id: Check for fast terminator or contention
- Same
-
Fast Terminator Detection (if different
stream_id):- Check if old stream is still active (< 200ms since last packet)
- If > 200ms: Old stream terminated without terminator packet
- Log fast terminator, call
_end_stream(), allow new stream via_handle_stream_start() - If < 200ms: Real contention, deny new stream
-
Update stream state (if same stream):
stream.last_seen = current_timestream.packet_count += 1
-
Return True: Packet is valid for forwarding
3. Stream Termination (Primary: Terminator Frame)
When a DMR terminator frame is detected:
def _is_dmr_terminator(data: bytes, frame_type: int) -> bool
Terminator Detection:
- Byte 15 (_bits) bits 4-5: frame_type must be 0x2 (HBPF_DATA_SYNC)
- Byte 15 (_bits) bits 0-3: dtype_vseq must be 0x2 (HBPF_SLT_VTERM)
- Result: Frame type 0x2 AND dtype 0x2 = terminator frame
When terminator detected:
-
Call
_end_stream(stream, repeater_id, slot, current_time, 'terminator') -
Unified ending logic:
- Set
stream.ended = True - Set
stream.end_time = current_time - Calculate duration:
duration = end_time - start_time - Log stream end:
INFO - RX stream ended: ... duration=X.XXs, packets=N, entering hang time (10.0s) - Emit
stream_endevent to dashboard - Do NOT clear slot - stream enters hang time
- Set
-
Hang time begins: Slot reserved for original
rf_src
Timing: ~60ms after PTT release (near-instant slot availability)
4. Stream Termination (Fallback: Timeout)
Checked every 1 second by _check_stream_timeouts():
Process:
- For each connected repeater, check both slots
- If stream exists and not active (> 2.0 seconds since last packet):
- If not ended: Call
_end_stream(stream, repeater_id, slot, current_time, 'timeout') - Enters hang time (same as terminator method)
- If not ended: Call
- If stream ended and hang time expired:
- Clear slot:
repeater.set_slot_stream(slot, None) - Log:
DEBUG - RX hang time completed on repeater X slot Y - Emit
hang_time_expiredevent - Slot now available for new streams from any source
- Clear slot:
Timing: ~2000ms after last packet (fallback for lost terminators only)
5. Fast Terminator Detection (200ms Rule)
When a new stream arrives with different stream_id while a stream is active:
Check for stale stream:
time_since_last_packet = current_time - current_stream.last_seen- If > 200ms: Old stream effectively terminated (no terminator packet received)
- If < 200ms: Real contention (deny new stream)
If fast terminator detected:
- Log:
INFO - Fast terminator: stream on repeater X slot Y ended via inactivity (XXXms since last packet) - Call
_end_stream(stream, repeater_id, slot, current_time, 'fast_terminator') - Stream enters hang time
- Call
_handle_stream_start()to check if new stream allowed (hang time rules apply)
Benefit: Detects operator releasing PTT without transmitting terminator frame (quick key-ups)
6. Stream Contention
When a DMRD packet arrives with a different stream_id than the active stream:
Scenarios:
A. Active stream (< 200ms since last packet):
- Result: DENY new stream
- Log:
WARNING - Stream contention on repeater X slot Y: existing stream (...) vs new stream (...) - Reason: First-come-first-served - active transmission has priority
B. Stale stream (> 200ms since last packet):
- Result: Fast terminator detection
- Action: End old stream, evaluate new stream against hang time rules
- Log: Fast terminator log + hang time check
C. Stream in hang time:
- Check hang time rules:
- Same
rf_src: Allow (same user continuing or switching TG) - Different
rf_src, samedst_id: Allow (different user joining same TG conversation) - Different
rf_src, differentdst_id: DENY (hijacking attempt)
- Same
- Log: Appropriate message based on rule matched
Hang Time Protection
Hang time provides sophisticated protection against slot hijacking while allowing legitimate multi-user conversations and fast TG switching.
Configuration
{
"global": {
"stream_hang_time": 10.0 // Seconds (10-20 recommended)
}
}
Recommended values:
- 10 seconds: Fast-paced networks, experienced operators
- 15 seconds: Balanced (default)
- 20 seconds: Slower operators, roundtable conversations
Hang Time Rules
| Scenario | rf_src Match | dst_id Match | Result | Use Case |
|---|---|---|---|---|
| Same user, same TG | ✓ Yes | ✓ Yes | ✅ ALLOW | User continuing conversation |
| Same user, different TG | ✓ Yes | ✗ No | ✅ ALLOW | Fast TG switching |
| Different user, same TG | ✗ No | ✓ Yes | ✅ ALLOW | Multi-user conversation |
| Different user, different TG | ✗ No | ✗ No | ❌ DENY | Hijacking prevention |
Hang Time Protection Scenarios
Scenario 1: Same User Continuing Conversation
Timeline:
t=0s : User 312123 transmits on TG 3120
t=2.5s : User 312123 releases PTT (terminator detected)
t=2.5s : Hang time begins (10.0s), slot reserved for 312123
t=4.0s : User 312123 keys up again on TG 3120
Result : ✅ ALLOWED - Same user, same TG
Log: "Same user continuing conversation during hang time: src=312123, dst=3120"
Scenario 2: Fast Talkgroup Switching
Timeline:
t=0s : User 312123 transmits on TG 3120
t=2.5s : User 312123 releases PTT (terminator detected)
t=2.5s : Hang time begins (10.0s), slot reserved for 312123
t=3.0s : User 312123 keys up on TG 9 (different talkgroup!)
Result : ✅ ALLOWED - Same user can switch TGs
Log: "Same user switching talkgroup during hang time: src=312123, old_dst=3120, new_dst=9"
Note: This enables operators to quickly switch between talkgroups without waiting for hang time to expire.
Scenario 3: Multi-User Conversation (Roundtable)
Timeline:
t=0s : User 312123 transmits on TG 3120
t=2.5s : User 312123 releases PTT (terminator detected)
t=2.5s : Hang time begins (10.0s), slot reserved for 312123
t=4.0s : User 312456 keys up on TG 3120 (different user, SAME TG!)
Result : ✅ ALLOWED - Different user joining conversation
Log: "Different user joining conversation during hang time: old_src=312123, new_src=312456, dst=3120"
Benefit: Allows natural roundtable conversations without hang time interference.
Scenario 4: Hijacking Prevention
Timeline:
t=0s : User 312123 transmits on TG 3120
t=2.5s : User 312123 releases PTT (terminator detected)
t=2.5s : Hang time begins (10.0s), slot reserved for 312123
t=4.0s : User 312456 tries to transmit on TG 9 (different user, different TG!)
Result : ❌ DENIED - Hijacking attempt blocked
Log: "Hang time hijacking blocked: slot reserved for TG 3120, denied src=312456 attempting TG 9"
This is the key protection: Prevents users from stealing slots mid-conversation.
Complex Scenario: Partial Talkgroup Overlap (A-B-C Network)
Network topology:
- Repeater A: TGs [3120, 9]
- Repeater B: TGs [3120, 3121] (shares 3120 with A, shares 3121 with C)
- Repeater C: TGs [3121, 8]
Note: Repeaters A and C do NOT share any talkgroups (isolated by B).
Scenario: A transmits TG 3120, B forwards to A, then C tries TG 3121
Timeline:
---------
t=0s: User on Repeater A transmits TG 3120
→ Server receives from A (RX stream on A slot 1)
→ Server calculates targets: B has TG 3120
→ Server forwards to B (assumed TX stream on B slot 1)
Repeater A Slot 1: RX stream (src=312123, dst=3120)
Repeater B Slot 1: TX assumed stream (forwarded from A)
Repeater C Slot 1: IDLE (doesn't have TG 3120)
t=2.5s: User on A releases PTT (terminator detected)
→ A enters hang time (reserved for user 312123, TG 3120)
→ B enters hang time (assumed stream ended)
Repeater A Slot 1: HANG TIME (reserved for 312123 on TG 3120)
Repeater B Slot 1: HANG TIME (assumed, lower priority)
Repeater C Slot 1: IDLE
t=3.0s: User on Repeater C tries to transmit TG 3121
→ Server receives from C (RX stream attempt)
→ Server calculates targets: B has TG 3121 ✓
→ Server checks B slot 1 availability
→ B slot 1 is in hang time (assumed stream, TG 3120)
→ Assumed streams have LOW priority (real RX > assumed TX)
→ C's real RX wins over B's assumed TX
→ Server clears B's assumed stream
→ Server forwards C's TG 3121 to B
Repeater A Slot 1: HANG TIME (still reserved for 312123)
Repeater B Slot 1: RX from C (TG 3121) ✅ ALLOWED
Repeater C Slot 1: RX stream (src=312789, dst=3121)
Result: ✅ C successfully uses B slot 1 because:
1. A and C don't share TGs (isolated)
2. B's slot 1 only had assumed stream (low priority)
3. Real RX always wins over assumed TX
Key Points:
- Hang time on A protects A's slot from hijacking
- Hang time on B (assumed) doesn't block C (real RX wins)
- No TG overlap between A and C prevents interference
- B acts as isolated bridge between A and C networks
Routing and Traffic Forwarding
Per-Stream Routing Cache
HBlink4 uses a "calculate-once, forward-many" approach:
At stream start (_calculate_stream_targets):
- Extract talkgroup from
dst_id - For each connected repeater (except source):
- Check outbound routing: Does target have this TG in allowed list?
- Check slot availability: Is target slot idle or only in assumed hang time?
- If both checks pass: Add to
target_repeatersset
- Store set in
StreamState.target_repeaters - Set
StreamState.routing_cached = True
For every packet (_forward_stream):
- Check if
stream.routing_cachedis True - If yes: Use
stream.target_repeatersset (O(1) lookup) - For each target in set: Send packet
- No recalculation needed - routing decisions persist for entire stream
Benefits:
- Performance: O(1) forwarding per packet (set iteration)
- Consistency: All packets in stream follow same route
- Scalability: Efficient with 100+ repeaters
- Simplicity: No per-packet routing logic
Inbound vs Outbound Routing
Symmetric routing: Same TG lists control both directions.
Inbound (FROM repeater):
def _check_inbound_routing(repeater_id: bytes, slot: int, tgid: int) -> bool:
allowed_tgids = repeater.slot1_talkgroups (or slot2_talkgroups)
return tgid in allowed_tgids # O(1) set membership
- Called when packet ARRIVES from repeater
- Checks if repeater is ALLOWED TO SEND this TG
- If denied: Drop packet, log warning
Outbound (TO repeater):
def _check_outbound_routing(repeater_id: bytes, slot: int, tgid: int) -> bool:
allowed_tgids = repeater.slot1_talkgroups (or slot2_talkgroups)
return tgid in allowed_tgids # O(1) set membership
- Called during
_calculate_stream_targets - Checks if repeater is ALLOWED TO RECEIVE this TG
- If denied: Exclude from target set
Talkgroup Filtering Modes:
| Config | Value | Behavior |
|---|---|---|
| Not configured | N/A | Allow ALL talkgroups (legacy mode) |
slot1_talkgroups: null | None | Allow ALL talkgroups |
slot1_talkgroups: [] | [] (empty) | DENY ALL (slot disabled) |
slot1_talkgroups: [1,2,3] | Set {1,2,3} | Allow ONLY listed TGs |
RX/TX Contention: Assumed Stream Handling
The Problem
When server forwards traffic TO a repeater (TX), it creates an "assumed stream" to track what it's sending. However, repeaters have local users who can key up at any time, creating RX traffic. This creates a contention scenario.
Without proper handling:
- Server continues sending packets to busy repeater
- Repeater hardware ignores packets (can't TX and RX simultaneously)
- Wasted bandwidth (potentially significant on multi-repeater networks)
The Solution
When a repeater starts receiving (RX) while we have an assumed stream (TX) to it:
Detection:
if current_stream and current_stream.is_assumed and not current_stream.ended:
# Repeater starting RX while we have active assumed TX
Route-Cache Removal:
# Remove this repeater from ALL active streams' target_repeaters
for other_repeater in self._repeaters.values():
for other_slot in [1, 2]:
other_stream = other_repeater.get_slot_stream(other_slot)
if other_stream and other_stream.routing_cached:
if repeater.repeater_id in other_stream.target_repeaters:
other_stream.target_repeaters.discard(repeater.repeater_id)
``$
**\text{Result}**:
- \text{Immediate} \text{bandwidth} \text{savings} (\text{no} \text{more} \text{packets} \text{to} \text{this} \text{repeater})
- \text{Real} \text{RX} \text{stream} \text{processed} \text{normally}
- \text{Automatic} - \text{no} \text{configuration} \text{needed}
**\text{Performance}**: \text{O}(\text{R} \times \text{S}) \text{where} \text{R} = \text{repeaters} (~10-50), \text{S} = \text{slots} (2)
- \text{Typical}: 20-100 \text{operations} \text{when} \text{contention} \text{detected}
- \text{Frequency}: \text{Rare} (\text{only} \text{when} \text{repeater} \text{starts} \text{RX} \text{with} \text{assumed} \text{TX} \text{present})
- \text{Method}: $set.discard()` is O(1) per removal
### Assumed Stream Priority Rules
| Current Stream | New Stream | Result | Reason |
|----------------|------------|--------|---------|
| Real RX | Real RX | Contention check | Normal rules apply |
| Real RX | Assumed TX | Don't create | Slot busy with real traffic |
| Assumed TX | Real RX | **Real wins** | Remove from route-cache |
| Assumed TX | Assumed TX | Update | Track multiple forwarding targets |
**Key principle**: Real (RX) always wins over Assumed (TX)
### Logging
INFO - Repeater 312100 slot 1 starting RX while we have active assumed TX stream - repeater wins, removing from active route-caches DEBUG - Removed repeater 312100 from route-cache of stream on repeater 312101 slot 1 INFO - RX stream started on repeater 312100 slot 1: src=312567, dst=3121, stream_id=abc123, targets=3
## Unit (Private) Call Routing
HBlink4 routes unit (subscriber-to-subscriber) calls using a user cache with broadcast fallback. See [configuration.md § Unit Call Forwarding](configuration.md#unit-private-call-forwarding) for the config surface; this section describes runtime behavior.
**User Cache:**
- Tracks last known source (local repeater *or* outbound connection) for each DMR ID
- Updated on every stream start — group and unit calls both populate it
- Timeout: 600 seconds default, minimum 60 (see `user_cache.timeout`)
- Cleanup: every 60 seconds, plus lazy expiration on lookup
**Eligibility gate (source side):**
- Local repeater must have `unit_calls_enabled` (pattern default `default_unit_calls` + optional `UNIT=true|false` RPTO override for trusted repeaters)
- Outbound-sourced unit calls require `unit_calls_enabled: true` on the outbound config
**Routing (`_handle_unit_stream_start` / `_handle_outbound_unit_call`):**
1. Extract `rf_src` (source radio) and `dst_id` (target radio — *not* a TGID) from the packet; `call_type` bit is 1 for unit calls (0 is group).
2. Look up `dst_id` via `UserCache.get_source_for_user(dst_id)`:
- **Cache hit, local target** → route to that single repeater (on the source's originating slot)
- **Cache hit, outbound target** → route to that single outbound (only for locally-sourced calls — outbound-to-outbound is blocked to prevent loops)
- **Cache miss or ineligible target** → broadcast to every unit-enabled local repeater plus every unit-enabled outbound (again, locally-sourced calls only — outbound-sourced broadcasts stay local)
3. Update the user cache with the source radio's location (local `repeater_id` or `outbound_name`).
4. Forward. Subsequent calls in either direction route one-to-one once both ends have populated the cache, so only the very first unit call between two users whose cache entries have both lapsed pays the broadcast cost.
**Slot handling — ships in the night:** unit calls always forward on the *source's* originating slot. The target's last-heard slot (tracked in the cache for informational display) is not a routing constraint, because DMR radios can monitor both TS1 and TS2 simultaneously.
**Hang time:** keyed on the `(rf_src, dst_id)` subscriber pair rather than a talkgroup. Either direction of the same conversation (A→B or B→A) passes through during hang time; the same source calling a new target also passes through. Anything else is a hijack and is denied.
**Anti-loop for outbound links:** unit calls arriving on an outbound connection are never re-forwarded to any outbound. Group calls have the same restriction. For star/tree topologies this is correct; multi-hop chain/mesh topologies would require stream-id-based loop detection (tracked in [TODO.md](TODO.md)).
**Log lines are normalized with group calls:**
Unit RX stream started on repeater 312017 TS/RID: 1/3120102 src=3120101 targets=15 stream_id=a5e9442f [broadcast] Unit RX stream ended on repeater 312017 TS/RID: 1/3120102 src=3120101 duration=3.06s packets=50 reason=terminator - entering hang time (5.0s) Group RX stream started on repeater 312017 TS/TGID: 1/3120 src=3120101 targets=5 stream_id=... [FAST TG SWITCH]
## Performance Characteristics
### Memory Usage
**Per Repeater:**
- RepeaterState: ~2KB
- StreamState (max 2): ~400 bytes each
- Total: ~2.8KB per repeater
**100 Repeaters**: ~280KB (negligible)
### CPU Usage
**Per-Packet Operations:**
- Repeater lookup: O(1) dict lookup
- Stream validation: O(1) stream_id comparison
- Routing decision: O(1) set membership (cached targets)
- Forwarding: O(T) where T = targets in cache (typically 3-10)
**Periodic Tasks:**
- Stream timeout check: Every 1 second, O(R×2) where R = repeaters
- User cache cleanup: Every 60 seconds, O(U) where U = cached users
- Forwarding stats: Every 5 seconds, O(1)
**Scalability**: Linear O(n) with repeater count, sub-millisecond per-packet processing
### Network Bandwidth
**Without Route-Cache Optimization:**
- Worst case: Forwarding to 50 repeaters with 10 active streams
- Wasted bandwidth: ~100KB/s per busy repeater
**With Route-Cache Optimization:**
- Assumed streams removed immediately when repeater starts RX
- Bandwidth saved: Up to 100KB/s per optimized repeater
- Detection latency: < 60ms (first RX packet)
## Configuration Impact
Stream tracking respects repeater configuration patterns and talkgroup access control.
**Example Configuration:**
```json
{
"repeater_configurations": {
"patterns": [
{
"name": "KS-DMR Network",
"match": {"id_ranges": [[312000, 312099]]},
"config": {
"passphrase": "secret",
"slot1_talkgroups": [8, 9],
"slot2_talkgroups": [3120, 3121, 3122]
}
}
],
"default": {
"passphrase": "default-pass",
"slot1_talkgroups": [8],
"slot2_talkgroups": [8]
}
}
}
Effect on Stream Tracking:
Repeater 312050 (matches KS-DMR pattern):
- Slot 1: Can RX and TX TGs 8, 9 only
- Slot 2: Can RX and TX TGs 3120, 3121, 3122 only
- Packet for TG 1 on slot 1: DENIED (inbound routing check fails)
- Forward TG 3120 to this repeater slot 2: ALLOWED (outbound routing check passes)
Repeater 999999 (matches default):
- Slot 1: Can RX and TX TG 8 only
- Slot 2: Can RX and TX TG 8 only
- Very restricted access (guest/untrusted repeater pattern)
Logging
HBlink4 provides comprehensive logging of stream activity:
Stream Start (RX)
INFO - RX stream started on repeater 312100 slot 1: src=3121234, dst=3120, stream_id=a1b2c3d4, targets=3
Stream Start (TX Assumed)
DEBUG - TX stream created on repeater 312100 slot 1: dst=3120 (assumed, forwarding)
Stream Termination (Terminator Detected)
INFO - RX stream ended on repeater 312100 slot 1: src=3121234, dst=3120, duration=2.46s, packets=41, entering hang time (10.0s)
Stream Termination (Timeout Fallback)
INFO - RX stream ended on repeater 312100 slot 1: src=3121234, dst=3120, duration=4.52s, packets=226, entering hang time (10.0s)
Fast Terminator Detection
INFO - Fast terminator: stream on repeater 312100 slot 1 ended via inactivity (215ms since last packet): src=3121234, dst=3120, duration=1.85s, packets=31
Hang Time Expiry
DEBUG - RX hang time completed on repeater 312100 slot 1: src=3121234, dst=3120, hang_duration=10.02s
Contention (Active Stream)
WARNING - Stream contention on repeater 312100 slot 1: existing stream (src=3121234, dst=3120, active 150ms ago) vs new stream (src=3125678, dst=3121)
Hang Time Protection (Same User Continuing)
INFO - Same user continuing conversation on repeater 312100 slot 1 during hang time: src=3121234, dst=3120
Hang Time Protection (Fast TG Switch)
INFO - Same user switching talkgroup on repeater 312100 slot 1 during hang time: src=3121234, old_dst=3120, new_dst=9
Hang Time Protection (Different User, Same TG)
INFO - Different user joining conversation on repeater 312100 slot 1 during hang time: old_src=3121234, new_src=3125678, dst=3120
Hang Time Protection (Hijacking Blocked)
WARNING - Hang time hijacking blocked on repeater 312100 slot 1: slot reserved for TG 3120, denied src=3125678 attempting TG 9
Inbound Routing Denied
WARNING - Inbound routing denied: repeater=312100 TS1/TG9 not in allowed list {8, 3120}
RX/TX Contention (Assumed Stream Override)
INFO - Repeater 312100 slot 1 starting RX while we have active assumed TX stream - repeater wins, removing from active route-caches
DEBUG - Removed repeater 312100 from route-cache of stream on repeater 312101 slot 1
Event Emission (Dashboard Integration)
Stream tracking emits real-time events to the dashboard:
stream_start
{
"repeater_id": 312100,
"slot": 1,
"src_id": 3121234,
"dst_id": 3120,
"stream_id": "a1b2c3d4",
"call_type": "group"
}
stream_end
{
"repeater_id": 312100,
"slot": 1,
"src_id": 3121234,
"dst_id": 3120,
"duration": 2.46,
"packets": 41,
"end_reason": "terminator",
"hang_time": 10.0,
"call_type": "group",
"is_assumed": false
}
hang_time_expired
{
"repeater_id": 312100,
"slot": 1
}
Dashboard Usage:
- Real-time repeater slot status display
- Recent events log (filters out TX assumed streams)
- Traffic statistics and analytics
- Network health monitoring
What Sets HBlink4 Apart
HBlink4's stream tracking system provides capabilities that surpass other DMR server implementations:
1. Fast Terminator Detection
- HBlink4: ~60ms stream end detection (primary method)
- Others: ~2000ms timeout-only detection
- Benefit: Near-instant slot availability, better user experience
2. Sophisticated Hang Time Protection
- HBlink4: 4 distinct rules (same user, user switch TG, join conversation, hijacking prevention)
- Others: Simple timeout or no protection
- Benefit: Prevents hijacking while allowing natural conversations and fast TG switching
3. Per-Stream Routing Cache
- HBlink4: Calculate-once, forward-many with O(1) decisions
- Others: Per-packet routing calculations
- Benefit: Massively improved performance on large networks
4. RX/TX Contention Handling
- HBlink4: Automatic route-cache removal, bandwidth optimization
- Others: Continue sending to busy repeaters
- Benefit: Significant bandwidth savings on multi-repeater networks
5. Real vs Assumed Stream Priority
- HBlink4: Real RX always wins over assumed TX
- Others: May not distinguish or handle poorly
- Benefit: Correct behavior when repeaters start receiving
These capabilities enable HBlink4 to scale to large networks (100+ repeaters) while maintaining excellent performance and correct DMR behavior in complex contention scenarios.