mirror of
https://github.com/n0mjs710/HBlink4.git
synced 2026-11-08 04:19:26 +00:00
Subscriber-to-subscriber call routing with user cache + broadcast fallback, subscriber-pair hang time, cross-slot forwarding, and optional propagation over outbound server links that forms an implicit reverse-path tree between HBlink4 peers. Source-side gating: - Per-repeater unit_calls_enabled seeded from pattern default_unit_calls - Trusted repeaters can override via UNIT=true|false in RPTO Target calculation (_calculate_unit_call_targets): - Cache hit -> one-to-one to local repeater or outbound link - Cache miss -> broadcast to all unit-enabled peers on source's slot - Anti-loop: outbound-sourced calls never forward to any outbound Slot semantics are ships-in-the-night. The originating slot is always preserved on forward; the target's cached slot is informational only because DMR radios can monitor both slots simultaneously. Hang time protects the (rf_src, dst_id) subscriber pair. Either direction of the same pair passes; same source to a new target passes; anything else is a hijack. Log lines are normalized so group and unit share identical shape, differing only in TS/TGID vs TS/RID and optional annotations ([broadcast], [one-to-one via N], etc). Dashboard Last Heard "Talkgroup" column renamed to "Destination" with group/unit emoji indicators. Known limitation: no cross-outbound forwarding for either call type; TODO includes a stream-id-based loop-detection design sketch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
792 lines
31 KiB
Markdown
792 lines
31 KiB
Markdown
# HBlink4 Stream Tracking System
|
||
|
||
## 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.
|
||
|
||
```python
|
||
@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 period
|
||
- `is_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`:
|
||
|
||
```python
|
||
# 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:
|
||
|
||
```python
|
||
def _handle_stream_start(repeater, rf_src, dst_id, slot, stream_id, call_type_bit) -> bool
|
||
```
|
||
|
||
**Process:**
|
||
1. **Check for existing stream**:
|
||
- If same `stream_id`: Allow (continuation - should not happen, but handle gracefully)
|
||
- If different `stream_id` and stream active: **DENY** (contention)
|
||
- If different `stream_id` and stream in hang time: Check hang time rules
|
||
|
||
2. **Hang Time Rules** (if stream exists and ended):
|
||
- **Same user** (`rf_src` matches): Allow (can continue or switch TG)
|
||
- **Different user, same TG** (`dst_id` matches): Allow (join conversation)
|
||
- **Different user, different TG**: **DENY** (hijacking prevention)
|
||
|
||
3. **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
|
||
|
||
4. **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
|
||
|
||
5. **Create StreamState**:
|
||
- Initialize with `is_assumed=False` (real RX stream)
|
||
- Store routing targets in `target_repeaters`
|
||
- Set `routing_cached=True`
|
||
- Set `packet_count=1`
|
||
|
||
6. **Assign to slot**: `repeater.set_slot_stream(slot, new_stream)`
|
||
|
||
7. **Log stream start**: `INFO - RX stream started on repeater X slot Y: src=..., dst=..., stream_id=..., targets=N`
|
||
|
||
8. **Emit event**: `stream_start` event to dashboard
|
||
|
||
9. **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:
|
||
|
||
```python
|
||
def _handle_stream_packet(repeater, rf_src, dst_id, slot, stream_id, call_type_bit) -> bool
|
||
```
|
||
|
||
**Process:**
|
||
1. **Get current stream**: `current_stream = repeater.get_slot_stream(slot)`
|
||
|
||
2. **If no stream**: Call `_handle_stream_start()` (first packet)
|
||
|
||
3. **Stream ID validation**:
|
||
- **Same `stream_id`**: Update stream state
|
||
- **Different `stream_id`**: Check for fast terminator or contention
|
||
|
||
4. **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
|
||
|
||
5. **Update stream state** (if same stream):
|
||
- `stream.last_seen = current_time`
|
||
- `stream.packet_count += 1`
|
||
|
||
6. **Return True**: Packet is valid for forwarding
|
||
|
||
### 3. Stream Termination (Primary: Terminator Frame)
|
||
|
||
When a DMR terminator frame is detected:
|
||
|
||
```python
|
||
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**:
|
||
1. Call `_end_stream(stream, repeater_id, slot, current_time, 'terminator')`
|
||
2. **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_end` event to dashboard
|
||
- **Do NOT clear slot** - stream enters hang time
|
||
|
||
3. **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:**
|
||
1. For each connected repeater, check both slots
|
||
2. 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)
|
||
3. 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_expired` event
|
||
- Slot now available for new streams from any source
|
||
|
||
**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:**
|
||
1. Log: `INFO - Fast terminator: stream on repeater X slot Y ended via inactivity (XXXms since last packet)`
|
||
2. Call `_end_stream(stream, repeater_id, slot, current_time, 'fast_terminator')`
|
||
3. Stream enters hang time
|
||
4. 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`, same `dst_id`: Allow (different user joining same TG conversation)
|
||
- Different `rf_src`, different `dst_id`: **DENY** (hijacking attempt)
|
||
- **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
|
||
|
||
```json
|
||
{
|
||
"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`):
|
||
1. Extract talkgroup from `dst_id`
|
||
2. 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_repeaters` set
|
||
3. Store set in `StreamState.target_repeaters`
|
||
4. Set `StreamState.routing_cached = True`
|
||
|
||
**For every packet** (`_forward_stream`):
|
||
1. Check if `stream.routing_cached` is True
|
||
2. If yes: Use `stream.target_repeaters` set (O(1) lookup)
|
||
3. For each target in set: Send packet
|
||
4. **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):**
|
||
```python
|
||
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):**
|
||
```python
|
||
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**:
|
||
```python
|
||
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**:
|
||
```python
|
||
# 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)
|
||
```
|
||
|
||
**Result**:
|
||
- Immediate bandwidth savings (no more packets to this repeater)
|
||
- Real RX stream processed normally
|
||
- Automatic - no configuration needed
|
||
|
||
**Performance**: O(R×S) where R = repeaters (~10-50), S = slots (2)
|
||
- Typical: 20-100 operations when contention detected
|
||
- Frequency: Rare (only when repeater starts RX with assumed TX present)
|
||
- 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
|
||
```json
|
||
{
|
||
"repeater_id": 312100,
|
||
"slot": 1,
|
||
"src_id": 3121234,
|
||
"dst_id": 3120,
|
||
"stream_id": "a1b2c3d4",
|
||
"call_type": "group"
|
||
}
|
||
```
|
||
|
||
### stream_end
|
||
```json
|
||
{
|
||
"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
|
||
```json
|
||
{
|
||
"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.
|