Files
HBlink4/dashboard/server.py
T
cortandClaude Opus 4.7 16db4f0421 feat: automated radioid.net user database refresh in dashboard
The dashboard now pulls user.csv from radioid.net on a daily schedule,
filters it to the operator's configured country/callsign/ID scope, and
hot-swaps the in-memory radio_id -> callsign map without a restart. No more
stale callsigns when someone new gets their DMR ID.

Pipeline (dashboard/user_db.py):
- Stage 1: conditional HTTPS GET (If-Modified-Since) against
  database.radioid.net/static/user.csv with identifying User-Agent. Uses
  stdlib urllib.request, gzip-aware, 100 MB safety cap, 120s timeout. No
  new dependencies.
- Stage 2: stream through csv.DictReader applying country / callsign_regex
  / radio_id_range filters. Default countries are ["United States",
  "Canada"], reproducing the current scripts/filter_user_csv.py behavior
  byte-for-byte.
- Stage 3: atomic write to data/user_db.json (stringified dict, ~2.4 MB vs
  the current 7.7 MB CSV), sidecar metadata, then wholesale replacement of
  the in-memory dict reference — single bytecode op under the GIL, no lock
  needed. Readers see either the old or new dict, never a partial state.

Failure handling: any stage fault leaves the previous snapshot in place
and logs a warning. A filter that produces fewer than
fallback.min_rows_required rows is treated as a malformed download and
rejected. 304 Not Modified touches the timestamp so the "too stale" check
doesn't re-fire on every restart. The guiding rule: callsign lookups must
never get worse than they were before.

Scheduling (user_db_refresh_task coroutine):
- Catch-up refresh at startup if the snapshot is missing or older than
  on_startup_if_older_than_hours (default 36).
- Then schedules the next run at time_of_day + [0, jitter_minutes)
  random offset. Defaults: 03:17 local, 15 min jitter — deliberately
  off-hour and off-minute to avoid the top-of-hour stampede hitting
  radioid.net from Pi-Star fleets every night.
- "hourly" in config is clamped to "daily" with a warning. Upstream only
  regenerates daily; sub-daily polling is pure waste.

Config: new user_database block in dashboard/config.json with source URL,
User-Agent (with explicit contact placeholder), schedule, filter rules, and
fallback thresholds. Sample and defaults in load_config() match. Startup
warns if the placeholder contact 'operator@example.org' wasn't changed so
radioid.net can reach the operator if there's ever an issue.

Dashboard UI: small "Callsign DB: N entries, updated Xh ago" indicator to
the right of the "Last Heard" section header, with a verbose hover tooltip
showing source URL, last refresh status, upstream Last-Modified, and last
error if any. Gold when the last refresh failed; re-ticks every 60s
client-side. The single call site that consumes user_database (stream_start
handler) was updated from module global to state.user_db.

scripts/filter_user_csv.py gets a deprecation notice but stays functional
for air-gapped workflows. Its filter logic now lives in
dashboard.user_db.filter_rows_from_csv_bytes() as a pure function so the
legacy script could be rewritten to import it without duplicating rules.

Design rationale and sources documented in
docs/user_csv_automation_proposal.md — notably, radioid.net's terms
explicitly flag "bulk mirroring" of the JSON API as blockable behavior, so
the daily static-CSV pull is the intended channel (Pi-Star, HBMonv2, and
others all use this same URL). The dashboard now follows the same
convention.

Locally verified: filter round-trips the existing user.csv to 136,613
rows in ~750ms, snapshot is 2.41 MB on disk, and the import path cleanly
wires into DashboardState without regressing existing stats/last-heard
persistence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:38:15 -05:00

1295 lines
54 KiB
Python

"""
HBlink4 Dashboard - Separate Process
FastAPI + Uvicorn + WebSockets for real-time monitoring
Updates every 10 superframes (60 packets = 1 second) for smooth real-time feel
"""
import asyncio
import json
import csv
import socket
import os
import ipaddress
import signal
import atexit
from datetime import datetime, date, timedelta
from collections import deque
from typing import Dict, List, Set, Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from pathlib import Path
import logging
try:
from .user_db import UserDatabase, compute_next_refresh_seconds, _age_str
except ImportError:
# Running the dashboard directly without package install
import sys as _sys
_sys.path.insert(0, str(Path(__file__).parent))
from user_db import UserDatabase, compute_next_refresh_seconds, _age_str
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="HBlink4 Dashboard", version="1.1.0")
# Load dashboard configuration
def load_config() -> dict:
"""Load dashboard configuration from config.json"""
config_path = Path(__file__).parent / "config.json"
default_config = {
"server_name": "HBlink4 Server",
"server_description": "Amateur Radio DMR Network",
"dashboard_title": "HBlink4 Dashboard",
"refresh_interval": 1000,
"max_events": 50,
"event_receiver": {
"transport": "unix",
"host": "127.0.0.1",
"port": 8765,
"unix_socket": "/tmp/hblink4.sock",
"ipv6": False,
"buffer_size": 65536
},
"user_database": {
"enabled": True,
"source_url": "https://database.radioid.net/static/user.csv",
"user_agent": "HBlink4-Dashboard (contact=operator@example.org)",
"refresh": {
"schedule": "daily",
"time_of_day": "03:17",
"jitter_minutes": 15,
"on_startup_if_older_than_hours": 36
},
"filter": {
"countries": ["United States", "Canada"],
"callsign_regex": None,
"radio_id_ranges": None
},
"fallback": {
"keep_stale_on_failure": True,
"min_rows_required": 1000
}
}
}
if config_path.exists():
try:
with open(config_path) as f:
config = json.load(f)
# Merge with defaults (in case new keys are added)
return {**default_config, **config}
except Exception as e:
logger.warning(f"Failed to load config.json: {e}, using defaults")
return default_config
else:
# Create default config file
try:
with open(config_path, 'w') as f:
json.dump(default_config, f, indent=4)
logger.info(f"Created default config at {config_path}")
except Exception as e:
logger.warning(f"Failed to create config.json: {e}")
return default_config
dashboard_config = load_config()
# In-memory state (could be Redis/database for persistence)
class DashboardState:
"""Maintains state for the dashboard"""
def __init__(self):
self.repeaters: Dict[int, dict] = {}
self.repeater_details: Dict[int, dict] = {} # Detailed info (sent once per connection)
self.outbounds: Dict[str, dict] = {} # Outbound connections (key: connection_name)
self.streams: Dict[str, dict] = {} # key: f"{repeater_id}.{slot}"
self.events: deque = deque(maxlen=500) # Ring buffer of recent events
self.last_heard: List[dict] = [] # Last heard users
self.last_heard_stats: dict = {} # User cache statistics
self.websocket_clients: Set[WebSocket] = set()
self.hblink_connected: bool = False # Track HBlink4 connection status
self.stats = {
'total_calls_today': 0, # Total RX calls (streams) received today
'total_duration_today': 0.0, # Total duration of RX streams only (seconds)
'retransmitted_calls': 0, # Total calls retransmitted today (may be higher than received)
'start_time': datetime.now().isoformat(),
'last_reset_date': date.today().isoformat() # Track when stats were last reset
}
# Persistence file paths
self._data_dir = Path(__file__).parent / "data"
self._stats_file = self._data_dir / "stats.json"
self._last_heard_file = self._data_dir / "last_heard.json"
self._persistence_disabled = False # Will be set to True if write permissions fail
# User database (radio_id -> callsign) with background refresh.
# Loaded from disk snapshot on startup; populated/refreshed by
# user_db_refresh_task() registered in startup_event().
self.user_db = UserDatabase(self._data_dir)
# Load persisted data
self._load_persisted_data()
def reset_daily_stats(self):
"""Reset daily statistics at midnight"""
self.stats['total_calls_today'] = 0
self.stats['total_duration_today'] = 0.0
self.stats['retransmitted_calls'] = 0
self.stats['last_reset_date'] = date.today().isoformat()
logger.info(f"📊 Daily stats reset at midnight (server time)")
def _load_persisted_data(self):
"""Load persisted statistics and last heard data on startup, purge old data"""
try:
# Test write permissions early
self._check_write_permissions()
self._cleanup_temp_files()
self._load_stats()
self._load_last_heard()
self.user_db.load_from_disk()
self._purge_old_data()
logger.info("📁 Loaded persisted dashboard data")
except PermissionError as e:
logger.error(f"❌ No write permissions for persistence directory: {e}")
logger.error("💡 Dashboard will run without persistence - restart data will not be saved")
self._persistence_disabled = True
except Exception as e:
logger.warning(f"⚠️ Could not load persisted data: {e}")
def _check_write_permissions(self):
"""Test write permissions to data directory"""
try:
# Create data directory if needed
self._data_dir.mkdir(exist_ok=True)
# Test write permissions with a temp file
test_file = self._data_dir / ".write_test"
test_file.write_text("test")
test_file.unlink()
# Set flag to enable persistence
self._persistence_disabled = False
except (PermissionError, OSError) as e:
logger.error(f"❌ Cannot write to persistence directory {self._data_dir}: {e}")
raise PermissionError(f"No write access to {self._data_dir}")
def _cleanup_temp_files(self):
"""Remove any orphaned temp files from previous unclean shutdown"""
try:
if not self._data_dir.exists():
return
removed_count = 0
for temp_file in self._data_dir.glob("*.tmp"):
try:
temp_file.unlink()
removed_count += 1
logger.debug(f"🗑️ Removed orphaned temp file: {temp_file.name}")
except OSError as e:
logger.warning(f"⚠️ Could not remove temp file {temp_file.name}: {e}")
if removed_count > 0:
logger.info(f"🧹 Cleaned up {removed_count} orphaned temp file(s) from previous session")
except Exception as e:
logger.warning(f"⚠️ Could not cleanup temp files: {e}")
def _purge_old_data(self):
"""Remove old data files to prevent accumulation"""
if self._persistence_disabled:
return
try:
# Check if stats file is from a previous day and remove it
if self._stats_file.exists():
try:
with open(self._stats_file, 'r') as f:
saved_stats = json.load(f)
today = date.today().isoformat()
if saved_stats.get('last_reset_date') != today:
self._stats_file.unlink()
logger.info("🗑️ Removed old stats file from previous day")
except (json.JSONDecodeError, KeyError) as e:
# If we can't read it, try to remove it
try:
self._stats_file.unlink()
logger.info("🗑️ Removed corrupted stats file")
except OSError as remove_error:
logger.warning(f"⚠️ Could not remove corrupted stats file: {remove_error}")
except OSError as e:
logger.warning(f"⚠️ Could not read/remove old stats file: {e}")
except Exception as e:
logger.warning(f"⚠️ Could not purge old data: {e}")
def _load_stats(self):
"""Load statistics from file, validate date, reset if needed"""
if not self._stats_file.exists():
logger.debug("No stats file found, using fresh stats")
return
try:
with open(self._stats_file, 'r') as f:
saved_stats = json.load(f)
# Check if stats are from today
today = date.today().isoformat()
if saved_stats.get('last_reset_date') == today:
# Stats are still valid for today, load them
self.stats.update(saved_stats)
logger.info(f"📊 Loaded today's stats: {self.stats['total_calls_today']} calls, {self.stats['retransmitted_calls']} retransmitted")
else:
# Stats are from a previous day, keep fresh stats but log
logger.info(f"📊 Previous day's stats found ({saved_stats.get('last_reset_date')}), using fresh daily stats")
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"⚠️ Could not parse stats file: {e}")
def _load_last_heard(self):
"""Load last heard users, filtering out entries older than 4 hours (more aggressive)"""
if not self._last_heard_file.exists():
logger.debug("No last heard file found, starting fresh")
return
try:
with open(self._last_heard_file, 'r') as f:
saved_data = json.load(f)
# More aggressive filtering - only keep entries from last 4 hours
cutoff_time = datetime.now() - timedelta(hours=4)
valid_users = []
for user in saved_data.get('users', []):
try:
last_seen = datetime.fromisoformat(user['last_seen'])
if last_seen > cutoff_time:
valid_users.append(user)
except (ValueError, KeyError):
continue # Skip malformed entries
# Only keep data if we have recent users, otherwise start fresh
if valid_users:
self.last_heard = valid_users
self.last_heard_stats = saved_data.get('stats', {})
logger.info(f"📻 Loaded {len(valid_users)} recent users from last heard")
else:
logger.info("📻 No recent users found, starting fresh last heard list")
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"⚠️ Could not parse last heard file: {e}")
def save_stats(self):
"""Save current statistics to file with atomic write (called only on shutdown)"""
if self._persistence_disabled:
logger.debug("📁 Persistence disabled - skipping stats save")
return
try:
self._data_dir.mkdir(exist_ok=True)
temp_file = self._stats_file.with_suffix('.tmp')
with open(temp_file, 'w') as f:
json.dump(self.stats, f, indent=2)
# Atomic operation - replace file only if write succeeded
temp_file.rename(self._stats_file)
logger.debug(f"📁 Saved stats to {self._stats_file}")
except PermissionError as e:
logger.error(f"❌ Permission denied saving stats: {e}")
logger.error("💡 Dashboard will exit gracefully without saving persistence data")
except Exception as e:
logger.error(f"❌ Failed to save stats: {e}")
def save_last_heard(self):
"""Save last heard users and stats to file with atomic write (called only on shutdown)"""
if self._persistence_disabled:
logger.debug("📁 Persistence disabled - skipping last heard save")
return
try:
self._data_dir.mkdir(exist_ok=True)
temp_file = self._last_heard_file.with_suffix('.tmp')
data = {
'timestamp': datetime.now().isoformat(),
'users': self.last_heard,
'stats': self.last_heard_stats
}
with open(temp_file, 'w') as f:
json.dump(data, f, indent=2)
# Atomic operation - replace file only if write succeeded
temp_file.rename(self._last_heard_file)
logger.debug(f"📁 Saved last heard to {self._last_heard_file}")
except PermissionError as e:
logger.error(f"❌ Permission denied saving last heard: {e}")
logger.error("💡 Dashboard will exit gracefully without saving persistence data")
except Exception as e:
logger.error(f"❌ Failed to save last heard: {e}")
def save_all_data(self):
"""Save all persistent data"""
self.save_stats()
self.save_last_heard()
state = DashboardState()
async def broadcast_hblink_status(connected: bool):
"""Broadcast HBlink4 connection status to all WebSocket clients"""
state.hblink_connected = connected
message = {
'type': 'hblink_status',
'data': {
'connected': connected,
'timestamp': datetime.now().isoformat()
}
}
# Broadcast to all connected clients
disconnected_clients = set()
for client in state.websocket_clients:
try:
await client.send_json(message)
# If HBlink just connected, also send full current state to ensure browser has everything
if connected:
await client.send_json({
'type': 'initial_state',
'data': {
'repeaters': list(state.repeaters.values()),
'repeater_details': state.repeater_details,
'outbounds': list(state.outbounds.values()),
'streams': list(state.streams.values()),
'events': list(state.events)[-50:],
'stats': state.stats,
'last_heard': state.last_heard,
'hblink_connected': state.hblink_connected
}
})
except Exception as e:
logger.debug(f"Failed to send status to client: {e}")
disconnected_clients.add(client)
# Clean up disconnected clients
state.websocket_clients -= disconnected_clients
class TCPProtocol(asyncio.Protocol):
"""TCP protocol handler for receiving events from hblink4"""
def __init__(self, callback):
self.callback = callback
self.buffer = b''
self.transport = None
def connection_made(self, transport):
"""Called when TCP connection established"""
peername = transport.get_extra_info('peername')
logger.info(f"✅ HBlink4 connected via TCP from {peername}")
self.transport = transport
# Clear dashboard state on reconnect
# HBlink4 will re-send all current repeaters via repeater_connected events
logger.info("🔄 Clearing dashboard state - requesting HBlink4 state sync")
state.repeaters.clear()
state.outbounds.clear()
state.streams.clear()
# Set connection status synchronously (async broadcast may be delayed)
state.hblink_connected = True
# Send sync request to HBlink4 to trigger initial state send
try:
sync_request = json.dumps({'type': 'sync_request'}).encode('utf-8')
length = len(sync_request)
frame = length.to_bytes(4, byteorder='big') + sync_request
transport.write(frame)
logger.info("📤 Sent sync_request to HBlink4")
except Exception as e:
logger.error(f"Failed to send sync_request: {e}")
# Notify all browser clients that HBlink4 is connected
asyncio.create_task(broadcast_hblink_status(True))
def data_received(self, data):
"""Called when TCP data received (handles framing)"""
self.buffer += data
# Process all complete frames in buffer
while len(self.buffer) >= 4:
# Read length prefix (4 bytes, big-endian)
length = int.from_bytes(self.buffer[:4], byteorder='big')
# Check if we have complete frame
if len(self.buffer) < 4 + length:
break # Wait for more data
# Extract frame
frame = self.buffer[4:4+length]
self.buffer = self.buffer[4+length:]
# Process event
asyncio.create_task(self.callback(frame))
def connection_lost(self, exc):
"""Called when TCP connection lost"""
if exc:
logger.warning(f"⚠️ HBlink4 TCP connection lost: {exc}")
else:
logger.info("HBlink4 TCP connection closed")
# Set connection status synchronously (async broadcast may be delayed)
state.hblink_connected = False
# Notify all browser clients that HBlink4 is disconnected
asyncio.create_task(broadcast_hblink_status(False))
class UnixProtocol(asyncio.Protocol):
"""Unix socket protocol handler for receiving events from hblink4"""
def __init__(self, callback):
self.callback = callback
self.buffer = b''
self.transport = None
def connection_made(self, transport):
"""Called when Unix socket connection established"""
logger.info(f"✅ HBlink4 connected via Unix socket")
self.transport = transport
# Clear dashboard state on reconnect
# HBlink4 will re-send all current repeaters via repeater_connected events
logger.info("🔄 Clearing dashboard state - requesting HBlink4 state sync")
state.repeaters.clear()
state.outbounds.clear()
state.streams.clear()
# Set connection status synchronously (async broadcast may be delayed)
state.hblink_connected = True
# Send sync request to HBlink4 to trigger initial state send
try:
sync_request = json.dumps({'type': 'sync_request'}).encode('utf-8')
length = len(sync_request)
frame = length.to_bytes(4, byteorder='big') + sync_request
transport.write(frame)
logger.info("📤 Sent sync_request to HBlink4")
except Exception as e:
logger.error(f"Failed to send sync_request: {e}")
# Notify all browser clients that HBlink4 is connected
asyncio.create_task(broadcast_hblink_status(True))
def data_received(self, data):
"""Called when data received (handles framing)"""
self.buffer += data
# Process all complete frames in buffer
while len(self.buffer) >= 4:
# Read length prefix (4 bytes, big-endian)
length = int.from_bytes(self.buffer[:4], byteorder='big')
# Check if we have complete frame
if len(self.buffer) < 4 + length:
break # Wait for more data
# Extract frame
frame = self.buffer[4:4+length]
self.buffer = self.buffer[4+length:]
# Process event
asyncio.create_task(self.callback(frame))
def connection_lost(self, exc):
"""Called when Unix socket connection lost"""
if exc:
logger.warning(f"⚠️ HBlink4 Unix socket connection lost: {exc}")
else:
logger.info("HBlink4 Unix socket connection closed")
# Set connection status synchronously (async broadcast may be delayed)
state.hblink_connected = False
# Notify all browser clients that HBlink4 is disconnected
asyncio.create_task(broadcast_hblink_status(False))
class EventReceiver:
"""Receives events from hblink4 via TCP or Unix socket"""
def __init__(self, transport='unix', host_ipv4='127.0.0.1', host_ipv6='::1',
port=8765, unix_socket='/tmp/hblink4.sock', disable_ipv6=False):
"""
Initialize event receiver with transport abstraction
Args:
transport: 'tcp' or 'unix'
host_ipv4: Listen address for IPv4 (for TCP)
host_ipv6: Listen address for IPv6 (for TCP)
port: Listen port (for TCP)
unix_socket: Unix socket path (for Unix transport)
disable_ipv6: Disable IPv6 (for networks with broken IPv6)
"""
self.transport = transport.lower()
self.host_ipv4 = host_ipv4
self.host_ipv6 = host_ipv6 if not disable_ipv6 else None
self.disable_ipv6 = disable_ipv6
if disable_ipv6 and transport == 'tcp':
logger.warning('⚠️ IPv6 disabled for event receiver - using IPv4 only')
self.port = port
self.unix_socket = unix_socket
self.server = None
self.server_v6 = None
async def start(self):
"""Start receiving events from hblink4"""
loop = asyncio.get_event_loop()
if self.transport == 'tcp':
await self._start_tcp(loop)
elif self.transport == 'unix':
await self._start_unix(loop)
else:
logger.error(f"Unknown transport: {self.transport} (valid options: 'tcp', 'unix')")
raise ValueError(f"Unknown transport: {self.transport}")
async def _start_tcp(self, loop):
"""Start TCP server on both IPv4 and IPv6"""
# Start IPv4 listener
if self.host_ipv4:
try:
self.server = await loop.create_server(
lambda: TCPProtocol(self.process_event),
self.host_ipv4, self.port,
family=socket.AF_INET
)
logger.info(f"✓ Listening for HBlink4 events via TCP on {self.host_ipv4}:{self.port} (IPv4)")
except Exception as e:
logger.error(f"✗ Failed to start IPv4 TCP listener: {e}")
# Start IPv6 listener
if self.host_ipv6:
try:
self.server_v6 = await loop.create_server(
lambda: TCPProtocol(self.process_event),
self.host_ipv6, self.port,
family=socket.AF_INET6
)
logger.info(f"✓ Listening for HBlink4 events via TCP on [{self.host_ipv6}]:{self.port} (IPv6)")
except Exception as e:
logger.error(f"✗ Failed to start IPv6 TCP listener: {e}")
async def _start_unix(self, loop):
"""Start Unix socket server"""
# Remove existing socket file if it exists
if os.path.exists(self.unix_socket):
try:
os.unlink(self.unix_socket)
logger.info(f"Removed existing socket file: {self.unix_socket}")
except Exception as e:
logger.warning(f"Failed to remove existing socket: {e}")
self.server = await loop.create_unix_server(
lambda: UnixProtocol(self.process_event),
self.unix_socket
)
# Set socket permissions (readable/writable by owner and group)
try:
os.chmod(self.unix_socket, 0o660)
except Exception as e:
logger.warning(f"Failed to set socket permissions: {e}")
logger.info(f"📡 Listening for HBlink4 events via Unix socket at {self.unix_socket}")
async def process_event(self, data: bytes):
"""Process incoming event from hblink4"""
try:
event = json.loads(data.decode('utf-8'))
await self.handle_event(event)
except Exception as e:
logger.error(f"Error processing event: {e}")
async def handle_event(self, event: dict):
"""Update state and broadcast to WebSocket clients"""
event_type = event['type']
data = event['data']
# Update internal state based on event type
if event_type == 'repeater_connected':
state.repeaters[data['repeater_id']] = {
**data,
'connected_at': event['timestamp'],
'last_activity': event['timestamp'],
'last_ping': data.get('last_ping', event['timestamp']),
'missed_pings': data.get('missed_pings', 0),
'status': 'connected'
}
logger.info(f"Repeater connected: {data['repeater_id']} ({data.get('callsign', 'UNKNOWN')})")
elif event_type == 'repeater_keepalive':
if data['repeater_id'] in state.repeaters:
state.repeaters[data['repeater_id']]['last_ping'] = data.get('last_ping', event['timestamp'])
state.repeaters[data['repeater_id']]['missed_pings'] = data.get('missed_pings', 0)
state.repeaters[data['repeater_id']]['last_activity'] = event['timestamp']
elif event_type == 'repeater_disconnected':
if data['repeater_id'] in state.repeaters:
# Remove repeater from state immediately
del state.repeaters[data['repeater_id']]
logger.info(f"Repeater disconnected: {data['repeater_id']} ({data.get('callsign', 'UNKNOWN')}) - reason: {data.get('reason', 'unknown')}")
elif event_type == 'repeater_details':
# Store detailed repeater information (sent once on connection)
state.repeater_details[data['repeater_id']] = {
**data,
'received_at': event['timestamp']
}
logger.debug(f"Repeater details received: {data['repeater_id']} - Pattern: {data.get('matched_pattern', 'Unknown')}")
elif event_type == 'repeater_options_updated':
# RPTO received - update TG lists in real-time
if data['repeater_id'] in state.repeaters:
state.repeaters[data['repeater_id']]['slot1_talkgroups'] = data.get('slot1_talkgroups', [])
state.repeaters[data['repeater_id']]['slot2_talkgroups'] = data.get('slot2_talkgroups', [])
state.repeaters[data['repeater_id']]['rpto_received'] = data.get('rpto_received', False)
state.repeaters[data['repeater_id']]['translations'] = data.get('translations', [])
logger.info(f"Repeater options updated via RPTO: {data['repeater_id']}")
elif event_type == 'stream_start':
# Handle both repeater streams and outbound connection streams
connection_type = data.get('connection_type', 'repeater')
if connection_type == 'outbound':
# Outbound stream - key by connection_name.slot
key = f"{data['connection_name']}.{data['slot']}"
else:
repeater_id = data.get('repeater_id')
if repeater_id is None:
logger.error("Stream start missing repeater_id: %s", event)
return
# Repeater stream - key by repeater_id.slot (legacy behavior)
key = f"{repeater_id}.{data['slot']}"
# Look up callsign from user database
src_id = data.get('rf_src') or data.get('src_id') # Handle both field names
callsign = state.user_db.get(src_id, '') if src_id else ''
state.streams[key] = {
**data,
'callsign': callsign, # Add callsign to stream data
'start_time': event['timestamp'],
'packets': 0,
'duration': 0,
'status': 'active'
}
# Count different stream types
if not data.get('is_assumed', False):
# RX streams: actual traffic being received (from repeaters OR outbound connections)
# Only exclude TX streams (is_assumed=True)
state.stats['total_calls_today'] += 1
# Add/update user in last_heard immediately with "active" status
# Only for actual received calls, not retransmitted calls
if src_id:
# Build source display: "Name (originating_repeater_id)"
# For repeaters: callsign (repeater_id)
# For outbound: connection_name (remote_repeater_id)
if connection_type == 'outbound':
conn_name = data.get('connection_name', 'Unknown')
remote_rid = data.get('remote_repeater_id', 0)
source_name = f"{conn_name} ({remote_rid})"
else:
repeater_id = data.get('repeater_id', 0)
# Look up repeater callsign from state
repeater_info = state.repeaters.get(repeater_id, {})
repeater_callsign = repeater_info.get('callsign', '')
if repeater_callsign:
source_name = f"{repeater_callsign} ({repeater_id})"
else:
source_name = str(repeater_id)
# Find existing entry or create new one
existing_idx = next((i for i, u in enumerate(state.last_heard) if u['radio_id'] == src_id), None)
user_entry = {
'radio_id': src_id,
'callsign': callsign,
'source_name': source_name,
'slot': data['slot'],
'talkgroup': data.get('dst_id', 0),
'last_heard': event['timestamp'],
'active': True # Mark as currently active
}
# Only local repeater ingresses have translation state we can reference;
# outbound-connection ingresses are remote and carry no local map.
if connection_type != 'outbound':
user_entry['source_repeater_id'] = data.get('repeater_id')
if existing_idx is not None:
state.last_heard[existing_idx] = user_entry
else:
state.last_heard.insert(0, user_entry) # Add to front
# Keep only most recent 10 entries
state.last_heard = state.last_heard[:10]
else:
# TX streams: traffic being retransmitted to repeaters or outbound connections
# Don't add to last_heard - these represent the same call being forwarded
state.stats['retransmitted_calls'] += 1
# Update repeater/outbound last activity
if connection_type == 'outbound' and data['connection_name'] in state.outbounds:
state.outbounds[data['connection_name']]['last_activity'] = event['timestamp']
elif data.get('repeater_id') in state.repeaters:
state.repeaters[data['repeater_id']]['last_activity'] = event['timestamp']
elif event_type == 'stream_update':
# Handle both repeater and outbound streams
connection_type = data.get('connection_type', 'repeater')
if connection_type == 'outbound':
key = f"{data['connection_name']}.{data['slot']}"
else:
key = f"{data['repeater_id']}.{data['slot']}"
if key in state.streams:
state.streams[key]['packets'] = data['packets']
state.streams[key]['duration'] = data['duration']
elif event_type == 'stream_end':
# Handle both repeater and outbound streams
connection_type = data.get('connection_type', 'repeater')
if connection_type == 'outbound':
key = f"{data['connection_name']}.{data['slot']}"
else:
key = f"{data['repeater_id']}.{data['slot']}"
if key in state.streams:
# Stream ended and entering hang time (combined event)
stream = state.streams[key]
stream['status'] = 'hang_time'
stream['packets'] = data.get('packet_count', data.get('packets', 0))
stream['duration'] = data['duration']
stream['end_reason'] = data.get('end_reason', 'unknown')
stream['hang_time'] = data.get('hang_time', 0)
# Only accumulate duration for RX streams (not assumed/TX streams or outbound)
if not data.get('is_assumed', False) and connection_type != 'outbound':
state.stats['total_duration_today'] += data['duration']
# Update last_heard entry to mark as no longer active (only for repeater RX streams)
if connection_type != 'outbound':
src_id = stream.get('rf_src') or stream.get('src_id')
if src_id:
user_entry = next((u for u in state.last_heard if u['radio_id'] == src_id), None)
if user_entry:
user_entry['active'] = False
user_entry['last_heard'] = event['timestamp']
elif event_type == 'hang_time_expired':
# Hang time has expired, clear the slot (handle both repeater and outbound)
connection_type = data.get('connection_type', 'repeater')
if connection_type == 'outbound':
key = f"{data['connection_name']}.{data['slot']}"
else:
key = f"{data['repeater_id']}.{data['slot']}"
if key in state.streams:
del state.streams[key]
logger.debug(f"Hang time expired for {key}")
elif event_type == 'outbound_connecting':
# Outbound connection is attempting to connect
conn_name = data['connection_name']
state.outbounds[conn_name] = {
**data,
'connecting_at': event['timestamp'],
'last_activity': event['timestamp'],
'status': 'connecting'
}
logger.info(f"Outbound connection attempting: {conn_name} (radio_id={data.get('radio_id')})")
elif event_type == 'outbound_connected':
# Outbound connection established
conn_name = data['connection_name']
state.outbounds[conn_name] = {
**data,
'connected_at': event['timestamp'],
'last_activity': event['timestamp'],
'status': 'connected'
}
logger.info(f"Outbound connection established: {conn_name} (radio_id={data.get('radio_id')}) at {data.get('remote_address')}:{data.get('remote_port')}")
elif event_type == 'outbound_disconnected':
# Outbound connection lost - change to disconnected status but don't remove
conn_name = data['connection_name']
if conn_name in state.outbounds:
state.outbounds[conn_name]['status'] = 'disconnected'
state.outbounds[conn_name]['disconnected_at'] = event['timestamp']
state.outbounds[conn_name]['disconnect_reason'] = data.get('reason', 'unknown')
logger.info(f"Outbound connection disconnected: {conn_name} - reason: {data.get('reason', 'unknown')}")
elif event_type == 'outbound_error':
# Outbound connection error
conn_name = data['connection_name']
if conn_name in state.outbounds:
state.outbounds[conn_name]['status'] = 'error'
state.outbounds[conn_name]['error_message'] = data.get('error_message', 'Unknown error')
state.outbounds[conn_name]['last_error'] = event['timestamp']
else:
# Connection failed before it was established
state.outbounds[conn_name] = {
**data,
'status': 'error',
'error_message': data.get('error_message', 'Unknown error'),
'last_error': event['timestamp']
}
logger.warning(f"Outbound connection error: {conn_name} - {data.get('error_message', 'Unknown error')}")
# Add to event log (only for user-facing on-air activity events)
# Skip system events like repeater_connected, repeater_disconnected, hang_time_expired, repeater_keepalive
# Skip TX/assumed streams from the events log (but they're still tracked in state.streams for repeater cards)
if event_type in ['stream_start', 'stream_end']:
# Only add RX streams to the events log (TX streams have is_assumed=True)
if not data.get('is_assumed', False):
state.events.append(event)
# For stream events, include updated last_heard list in the event
if event_type in ['stream_start', 'stream_end', 'hang_time_expired']:
event['last_heard'] = state.last_heard
# Send to all WebSocket clients
await self.send_to_clients(event)
async def send_to_clients(self, event: dict):
"""Send event to all connected WebSocket clients"""
if not state.websocket_clients:
return
message = json.dumps(event)
disconnected = set()
for client in state.websocket_clients:
try:
await client.send_text(message)
except:
disconnected.add(client)
# Remove disconnected clients
state.websocket_clients -= disconnected
# REST API endpoints
@app.get("/api/config")
async def get_config():
"""Get dashboard configuration"""
return dashboard_config
@app.get("/api/repeaters")
async def get_repeaters():
"""Get all connected repeaters"""
return {"repeaters": list(state.repeaters.values())}
@app.get("/api/outbounds")
async def get_outbounds():
"""Get all outbound connections"""
return {"outbounds": list(state.outbounds.values())}
@app.get("/api/streams")
async def get_streams():
"""Get all active streams"""
return {"streams": list(state.streams.values())}
@app.get("/api/events")
async def get_events(limit: int = 100):
"""Get recent events"""
return {"events": list(state.events)[-limit:]}
@app.get("/api/stats")
async def get_stats():
"""Get system statistics"""
return {
"stats": state.stats,
"repeaters_connected": len([r for r in state.repeaters.values() if r.get('status') == 'connected']),
"active_streams": len([s for s in state.streams.values() if s.get('status') == 'active'])
}
@app.get("/api/repeater/{repeater_id}")
async def get_repeater_details(repeater_id: int):
"""Get detailed information about a specific repeater"""
if repeater_id not in state.repeaters:
return {"error": "Repeater not found"}, 404
repeater = state.repeaters[repeater_id]
details = state.repeater_details.get(repeater_id, {})
# If no details event received yet, try to determine from config
if not details or not details.get('matched_pattern'):
# Try to load pattern from config
config_path = Path(__file__).parent.parent / 'config' / 'config.json'
try:
with open(config_path) as f:
config = json.load(f)
# Import here to avoid circular dependencies
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from hblink4.access_control import RepeaterMatcher
matcher = RepeaterMatcher(config)
pattern = matcher.get_pattern_for_repeater(repeater_id, repeater.get('callsign'))
if pattern:
details['matched_pattern'] = pattern.name
details['pattern_description'] = pattern.description
else:
details['matched_pattern'] = 'Default'
details['pattern_description'] = 'Using default configuration'
except Exception as e:
logger.warning(f'Could not determine pattern for repeater {repeater_id}: {e}')
details['matched_pattern'] = details.get('matched_pattern', 'Default')
details['pattern_description'] = details.get('pattern_description', 'Using default configuration')
# Calculate runtime statistics
current_time = datetime.now().timestamp()
uptime_seconds = int(current_time - repeater.get('connected_at', current_time))
# Count streams for this repeater
total_streams = len([e for e in state.events
if e.get('type') == 'stream_start'
and e.get('data', {}).get('repeater_id') == repeater_id])
# Find active streams
slot1_active = any(s.get('repeater_id') == repeater_id and s.get('slot') == 1
and s.get('status') == 'active'
for s in state.streams.values())
slot2_active = any(s.get('repeater_id') == repeater_id and s.get('slot') == 2
and s.get('status') == 'active'
for s in state.streams.values())
# Clean up IPv4-mapped IPv6 addresses
address = repeater.get('address', '')
if address.startswith('::ffff:'):
address = address[7:] # Remove '::ffff:' prefix
# Build comprehensive response
return {
"repeater_id": repeater_id,
"callsign": repeater.get('callsign', 'UNKNOWN'),
"connection_type": repeater.get('connection_type', details.get('connection_type', 'unknown')),
"connection": {
"address": address,
"connected_at": repeater.get('connected_at', 0),
"uptime_seconds": uptime_seconds,
"last_ping": repeater.get('last_ping', 0),
"missed_pings": repeater.get('missed_pings', 0),
"status": repeater.get('status', 'unknown')
},
"location": {
"location": repeater.get('location', ''),
"latitude": details.get('latitude', ''),
"longitude": details.get('longitude', ''),
"height": details.get('height', '')
},
"frequencies": {
"rx_freq": repeater.get('rx_freq', ''),
"tx_freq": repeater.get('tx_freq', ''),
"tx_power": details.get('tx_power', ''),
"colorcode": repeater.get('colorcode', ''),
"slots": details.get('slots', '')
},
"access_control": {
"connection_category": details.get('matched_pattern', 'Default'),
"category_description": details.get('pattern_description', 'Using default configuration'),
"rpto_received": repeater.get('rpto_received', False),
"slot1_talkgroups": repeater.get('slot1_talkgroups') if 'slot1_talkgroups' in repeater else None,
"slot2_talkgroups": repeater.get('slot2_talkgroups') if 'slot2_talkgroups' in repeater else None,
"talkgroups_source": "RPTO" if repeater.get('rpto_received') else "Pattern/Config"
},
"metadata": {
"description": details.get('description', ''),
"url": details.get('url', ''),
"software_id": repeater.get('software_id') or details.get('software_id', ''),
"package_id": repeater.get('package_id') or details.get('package_id', '')
},
"statistics": {
"total_streams_today": total_streams,
"slot1_active": slot1_active,
"slot2_active": slot2_active
}
}
# WebSocket endpoint
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket connection for real-time updates"""
await websocket.accept()
state.websocket_clients.add(websocket)
logger.info(f"🌐 WebSocket client connected (total: {len(state.websocket_clients)})")
# Log current state for debugging
logger.info(f"📊 Sending initial_state: hblink_connected={state.hblink_connected}, repeaters={len(state.repeaters)}, streams={len(state.streams)}")
# Send initial state
await websocket.send_json({
'type': 'initial_state',
'data': {
'repeaters': list(state.repeaters.values()),
'repeater_details': state.repeater_details,
'outbounds': list(state.outbounds.values()),
'streams': list(state.streams.values()),
'events': list(state.events)[-50:],
'stats': state.stats,
'last_heard': state.last_heard,
'hblink_connected': state.hblink_connected,
'user_db_status': state.user_db.status_dict()
}
})
try:
while True:
# Keep connection alive (client can send ping)
data = await websocket.receive_text()
if data == 'ping':
await websocket.send_text('pong')
except WebSocketDisconnect:
logger.debug(f"WebSocket client disconnected (remaining: {len(state.websocket_clients) - 1})")
except asyncio.CancelledError:
# Expected during shutdown - don't log as error
logger.debug(f"WebSocket task cancelled during shutdown")
except Exception as e:
logger.error(f"WebSocket error: {e}")
finally:
# Always clean up, regardless of how we exited
state.websocket_clients.discard(websocket)
# Serve frontend
@app.get("/", response_class=HTMLResponse)
async def dashboard():
"""Serve dashboard HTML"""
html_path = Path(__file__).parent / 'static' / 'dashboard.html'
if not html_path.exists():
return HTMLResponse("<h1>Dashboard HTML not found</h1><p>Please create dashboard/static/dashboard.html</p>", status_code=404)
with open(html_path) as f:
return HTMLResponse(f.read())
# Mount static files
static_path = Path(__file__).parent / "static"
if static_path.exists():
app.mount("/static", StaticFiles(directory=static_path), name="static")
@app.on_event("startup")
async def startup_event():
"""Start event receiver on startup"""
receiver_config = dashboard_config.get('event_receiver', {})
receiver = EventReceiver(
transport=receiver_config.get('transport', 'unix'),
host_ipv4=receiver_config.get('host_ipv4', '127.0.0.1'),
host_ipv6=receiver_config.get('host_ipv6', '::1'),
port=receiver_config.get('port', 8765),
unix_socket=receiver_config.get('unix_socket', '/tmp/hblink4.sock'),
disable_ipv6=receiver_config.get('disable_ipv6', False)
)
asyncio.create_task(receiver.start())
asyncio.create_task(midnight_reset_task())
asyncio.create_task(user_db_refresh_task())
logger.info("🚀 HBlink4 Dashboard started!")
logger.info(f"📡 Event transport: {receiver_config.get('transport', 'unix').upper()}")
logger.info("📊 Access dashboard at http://localhost:8080")
# Warn operator if they left the placeholder contact in the User-Agent.
udb_cfg = dashboard_config.get("user_database", {}) or {}
ua = udb_cfg.get("user_agent", "")
if "operator@example.org" in ua:
logger.warning(
"⚠️ user_database.user_agent still contains the placeholder contact "
"'operator@example.org' — update it in config.json so radioid.net can "
"reach you if there's an issue with your traffic."
)
@app.on_event("shutdown")
async def shutdown_event():
"""Clean shutdown - save data"""
logger.info("💾 Dashboard shutting down, saving data...")
save_persistent_data()
logger.info("✅ Dashboard shutdown complete")
async def midnight_reset_task():
"""Background task to reset daily stats at midnight"""
while True:
# Check if date has changed
current_date = date.today().isoformat()
if current_date != state.stats.get('last_reset_date'):
state.reset_daily_stats()
# Send stats update to all WebSocket clients
await send_stats_update()
# Check every 60 seconds
await asyncio.sleep(60)
async def user_db_refresh_task():
"""
Background task that refreshes the user database from radioid.net.
- On startup: if the on-disk snapshot is missing or older than
`on_startup_if_older_than_hours`, run one catch-up refresh immediately.
- Then loop: sleep until the next scheduled time_of_day + jitter, refresh,
repeat.
- Respects `user_database.enabled=false` as a hard disable.
- Broadcasts a user_db_status event after each refresh attempt so the UI
can show "DB updated Nh ago" without a full page reload.
"""
# Wait a moment for the startup log to settle, then evaluate state.
await asyncio.sleep(2)
udb_cfg = dashboard_config.get("user_database", {}) or {}
if not udb_cfg.get("enabled", True):
logger.info("📘 user_database.enabled=false — background refresh disabled")
return
refresh_cfg = udb_cfg.get("refresh", {}) or {}
startup_threshold_h = float(refresh_cfg.get("on_startup_if_older_than_hours", 36))
# Startup catch-up: run now if the snapshot is missing or stale.
age_h = state.user_db.snapshot_age_hours()
if age_h is None or age_h >= startup_threshold_h:
logger.info(
f"📘 user_db snapshot age={age_h}h >= threshold {startup_threshold_h}h — "
"running catch-up refresh"
)
status = await state.user_db.refresh_from_upstream(udb_cfg)
await broadcast_user_db_status(trigger="startup", status=status)
while True:
# Compute next scheduled refresh.
delay = compute_next_refresh_seconds(
schedule=refresh_cfg.get("schedule", "daily"),
time_of_day=refresh_cfg.get("time_of_day", "03:17"),
jitter_minutes=int(refresh_cfg.get("jitter_minutes", 15)),
)
logger.info(
f"📘 Next user_db refresh in {delay / 3600:.1f}h "
f"(schedule={refresh_cfg.get('schedule', 'daily')}, "
f"time_of_day={refresh_cfg.get('time_of_day', '03:17')})"
)
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
logger.debug("user_db_refresh_task cancelled during sleep")
return
status = await state.user_db.refresh_from_upstream(udb_cfg)
await broadcast_user_db_status(trigger="scheduled", status=status)
async def broadcast_user_db_status(trigger: str, status: str):
"""Send a user_db_status event to all connected clients."""
if not state.websocket_clients:
return
payload = state.user_db.status_dict()
payload["trigger"] = trigger
payload["status"] = status
message = json.dumps({
"type": "user_db_status",
"timestamp": datetime.now().timestamp(),
"data": payload,
})
disconnected = set()
for client in state.websocket_clients:
try:
await client.send_text(message)
except Exception:
disconnected.add(client)
state.websocket_clients -= disconnected
async def send_stats_update():
"""Send stats update to all WebSocket clients"""
if not state.websocket_clients:
return
message = json.dumps({
'type': 'stats_reset',
'timestamp': datetime.now().timestamp(),
'data': state.stats
})
disconnected = set()
for client in state.websocket_clients:
try:
await client.send_text(message)
except:
disconnected.add(client)
# Remove disconnected clients
state.websocket_clients -= disconnected
# ========== GRACEFUL SHUTDOWN HANDLING ==========
def save_persistent_data():
"""Save all persistent data on shutdown - called by signal handlers and atexit"""
try:
if state._persistence_disabled:
logger.info("💾 Dashboard shutdown complete (persistence was disabled)")
return
state.save_all_data()
logger.info("💾 Dashboard data saved on shutdown")
except Exception as e:
logger.error(f"❌ Failed to save data on shutdown: {e}")
logger.info("💾 Dashboard shutdown complete (with save errors)")
def signal_handler(signum, frame):
"""Handle shutdown signals gracefully"""
logger.info(f"📡 Received signal {signum}, shutting down...")
# Just save and exit - let systemd/uvicorn handle the rest
save_persistent_data()
import sys
sys.exit(0)
# Register shutdown handler
# Note: When running under systemd, don't override signal handlers
# Let uvicorn handle SIGTERM gracefully, which will trigger shutdown_event
if __name__ == "__main__":
# Only register signal handlers when running directly (not via systemd/uvicorn service)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
atexit.register(save_persistent_data) # Fallback for emergency exit
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"dashboard.server:app",
host="0.0.0.0",
port=8080,
log_level="info",
access_log=False # Disable access logging (reduces log clutter)
)