Files
cortandClaude Opus 4.7 7ca1bd1ec3 feat: implement unit (private) call routing
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>
2026-04-21 12:24:08 -05:00

257 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""
User Routing Cache for HBlink4
Tracks DMR users (radio IDs) and their last heard repeater for:
1. Dashboard "Last Heard" display
2. Private call routing optimization (avoids flooding all repeaters)
The cache is time-limited to prevent unbounded memory growth.
Default timeout: 10 minutes (configurable)
Copyright (C) 2025 Cort Buffington, N0MJS
License: GNU GPLv3
"""
import logging
from time import time
from typing import Dict, Optional, List, Tuple
from dataclasses import dataclass, field
LOGGER = logging.getLogger(__name__)
@dataclass
class UserEntry:
"""Cache entry for a heard user.
Source location is either a local repeater (non-zero `repeater_id`,
`outbound_name` None) or an outbound server link (`outbound_name` set,
`repeater_id` is the remote-side repeater id that originated the stream
if known, 0 otherwise). Routing consumers should inspect `outbound_name`
first: if set, forward via that outbound; otherwise forward to the local
`repeater_id`.
"""
radio_id: int
repeater_id: int
callsign: str
slot: int
talkgroup: int
last_heard: float = field(default_factory=time)
talker_alias: Optional[str] = None
outbound_name: Optional[str] = None
def to_dict(self) -> dict:
"""Convert to dictionary for serialization"""
return {
'radio_id': self.radio_id,
'repeater_id': self.repeater_id,
'callsign': self.callsign,
'slot': self.slot,
'talkgroup': self.talkgroup,
'last_heard': self.last_heard,
'talker_alias': self.talker_alias,
'outbound_name': self.outbound_name,
}
class UserCache:
"""
Routing cache for tracking users and their last heard repeater.
This cache serves two purposes:
1. Provides data for dashboard "Last Heard" display
2. Enables efficient private call routing without flooding all repeaters
Memory management: Entries expire after configurable timeout (default 10 minutes)
to prevent unbounded growth.
"""
def __init__(self, timeout_seconds: int = 600):
"""
Initialize user cache.
Args:
timeout_seconds: How long to keep entries (default 600 = 10 minutes)
"""
self._cache: Dict[int, UserEntry] = {}
self._timeout = timeout_seconds
LOGGER.info(f'User cache initialized with {timeout_seconds}s timeout')
def update(self, radio_id: int, repeater_id: int, callsign: str,
slot: int, talkgroup: int, talker_alias: Optional[str] = None,
outbound_name: Optional[str] = None) -> None:
"""
Update cache with user activity.
Args:
radio_id: Source radio ID (user)
repeater_id: Local repeater the user was heard on (0 when heard via outbound)
callsign: User's callsign
slot: Timeslot (1 or 2)
talkgroup: Talkgroup/destination ID
talker_alias: Optional decoded talker alias
outbound_name: Outbound connection name when the user was heard
via an outbound server link rather than a local repeater.
When set, routing decisions will forward unit calls via that
outbound instead of to any local repeater.
"""
now = time()
source_desc = f'outbound "{outbound_name}"' if outbound_name else f'repeater {repeater_id}'
# Update or create entry
if radio_id in self._cache:
entry = self._cache[radio_id]
entry.repeater_id = repeater_id
entry.outbound_name = outbound_name
entry.callsign = callsign
entry.slot = slot
entry.talkgroup = talkgroup
entry.last_heard = now
if talker_alias:
entry.talker_alias = talker_alias
LOGGER.debug(f'Updated cache: user {radio_id} ({callsign}) on {source_desc} slot {slot} TG {talkgroup}')
else:
self._cache[radio_id] = UserEntry(
radio_id=radio_id,
repeater_id=repeater_id,
callsign=callsign,
slot=slot,
talkgroup=talkgroup,
last_heard=now,
talker_alias=talker_alias,
outbound_name=outbound_name,
)
LOGGER.debug(f'Added to cache: user {radio_id} ({callsign}) on {source_desc} slot {slot} TG {talkgroup}')
def lookup(self, radio_id: int) -> Optional[UserEntry]:
"""
Look up a user in the cache.
Args:
radio_id: Radio ID to look up
Returns:
UserEntry if found and not expired, None otherwise
"""
if radio_id not in self._cache:
return None
entry = self._cache[radio_id]
# Check if expired
if time() - entry.last_heard > self._timeout:
del self._cache[radio_id]
LOGGER.debug(f'Removed expired entry for user {radio_id}')
return None
return entry
def get_repeater_for_user(self, radio_id: int) -> Optional[int]:
"""
Get the local repeater ID where a user was last heard.
Returns None if the user was last heard via an outbound connection —
callers that want to handle both cases should use `get_source_for_user`
instead.
Args:
radio_id: Target radio ID for private call
Returns:
Local repeater ID if user found, not expired, and was heard on a
local repeater. None otherwise.
"""
entry = self.lookup(radio_id)
if entry is None or entry.outbound_name is not None:
return None
return entry.repeater_id
def get_source_for_user(self, radio_id: int):
"""
Get the source location where a user was last heard.
Returns one of:
- ('local', repeater_id: int) — user last heard on a local repeater
- ('outbound', name: str) — user last heard via an outbound link
- None — user not cached or cache expired
"""
entry = self.lookup(radio_id)
if entry is None:
return None
if entry.outbound_name is not None:
return ('outbound', entry.outbound_name)
return ('local', entry.repeater_id)
def cleanup(self) -> int:
"""
Remove expired entries from cache.
This should be called periodically (e.g., once per minute) to prevent
unbounded memory growth.
Returns:
Number of entries removed
"""
now = time()
expired = []
for radio_id, entry in self._cache.items():
if now - entry.last_heard > self._timeout:
expired.append(radio_id)
for radio_id in expired:
del self._cache[radio_id]
if expired:
LOGGER.info(f'Cleaned up {len(expired)} expired user cache entries')
return len(expired)
def get_last_heard(self, limit: int = 50) -> List[dict]:
"""
Get list of recently heard users, sorted by most recent first.
Args:
limit: Maximum number of entries to return
Returns:
List of user entries as dictionaries, sorted by last_heard descending
"""
# Filter out expired entries
now = time()
valid_entries = [
entry for entry in self._cache.values()
if now - entry.last_heard <= self._timeout
]
# Sort by last heard (most recent first)
sorted_entries = sorted(valid_entries, key=lambda e: e.last_heard, reverse=True)
# Limit results
return [entry.to_dict() for entry in sorted_entries[:limit]]
def get_stats(self) -> dict:
"""
Get cache statistics.
Returns:
Dictionary with cache statistics
"""
now = time()
valid = sum(1 for entry in self._cache.values()
if now - entry.last_heard <= self._timeout)
return {
'total_entries': len(self._cache),
'valid_entries': valid,
'expired_entries': len(self._cache) - valid,
'timeout_seconds': self._timeout
}
def clear(self) -> None:
"""Clear all entries from cache."""
count = len(self._cache)
self._cache.clear()
LOGGER.info(f'Cleared {count} entries from user cache')