mirror of
https://github.com/n0mjs710/dmrlink3
synced 2026-11-08 12:26:30 +00:00
- Replace try_download calls with _stream_id_file() which uses ijson to stream-parse radioid.net JSON without ever loading the full document into memory, and filters to a configurable FILTER_COUNTRIES list on the fly - Strip unused fields (only id+callsign written to disk) regardless of filter - Atomic write via .tmp + os.replace() so an interrupted download never leaves a corrupt file that passes the staleness check - Add ijson>=3.2 to requirements.txt; drop now-unused try_download import - Add FILTER_COUNTRIES = ['United States', 'France'] to config_sample.py - Move talkgroup_ids_SAMPLE.json and local_peer_ids_SAMPLE.json into dashboard/ where PATH points Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
341 lines
11 KiB
Python
341 lines
11 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
###############################################################################
|
|
# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS <n0mjs@me.com>
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
###############################################################################
|
|
|
|
'''
|
|
DMRlink3 dashboard backend.
|
|
|
|
Connects to dmrlink3's NDJSON reporting feed (one JSON object per line), keeps
|
|
authoritative display state, and serves a single-page UI that receives live
|
|
JSON events over a WebSocket.
|
|
|
|
Run: python server.py (from the dashboard/ directory, or set PYTHONPATH)
|
|
'''
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import ssl
|
|
import sys
|
|
import time
|
|
from collections import deque
|
|
from contextlib import asynccontextmanager
|
|
from urllib.request import urlopen
|
|
|
|
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
import ijson
|
|
import uvicorn
|
|
|
|
from dmr_utils3.utils import mk_id_dict, get_alias
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
STATIC = os.path.join(HERE, 'static')
|
|
sys.path.insert(0, HERE)
|
|
|
|
try:
|
|
from config import (REPORT_NAME, DMRLINK_IP, DMRLINK_PORT,
|
|
WEB_HOST, WEB_PORT, LOG_LINES,
|
|
PATH, PEER_FILE, SUBSCRIBER_FILE, TGID_FILE,
|
|
LOCAL_SUB_FILE, LOCAL_PEER_FILE)
|
|
except ImportError:
|
|
sys.exit('No config.py found — copy config_sample.py to config.py and edit it.')
|
|
|
|
try:
|
|
from config import TRY_DOWNLOAD, PEER_URL, SUBSCRIBER_URL, STALE_DAYS
|
|
except ImportError:
|
|
TRY_DOWNLOAD = False
|
|
PEER_URL = 'https://www.radioid.net/static/rptrs.json'
|
|
SUBSCRIBER_URL = 'https://www.radioid.net/static/users.json'
|
|
STALE_DAYS = 7
|
|
|
|
try:
|
|
from config import FILTER_COUNTRIES
|
|
except ImportError:
|
|
FILTER_COUNTRIES = None
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
|
logger = logging.getLogger('dmrdash')
|
|
|
|
# Drop stale "active" calls this many seconds after START if no END arrives.
|
|
CALL_STALE = 60
|
|
|
|
|
|
# ---- alias resolution --------------------------------------------------------
|
|
|
|
def _abs(p):
|
|
return p if os.path.isabs(p) else os.path.join(HERE, p)
|
|
|
|
def _stream_id_file(url, path, json_key, countries, stale_secs):
|
|
now = time.time()
|
|
if os.path.isfile(path) and (os.path.getmtime(path) + stale_secs) >= now:
|
|
logger.info('ID ALIAS MAPPER: %s is current, not downloaded', os.path.basename(path))
|
|
return
|
|
no_verify = ssl._create_unverified_context()
|
|
tmp = path + '.tmp'
|
|
try:
|
|
with urlopen(url, context=no_verify) as response, \
|
|
open(tmp, 'w', encoding='utf-8') as out:
|
|
out.write('{"%s":[' % json_key)
|
|
first = True
|
|
for record in ijson.items(response, json_key + '.item'):
|
|
if not countries or record.get('country') in countries:
|
|
if not first:
|
|
out.write(',')
|
|
json.dump({'id': record['id'], 'callsign': record['callsign']}, out)
|
|
first = False
|
|
out.write(']}')
|
|
os.replace(tmp, path)
|
|
label = ', '.join(sorted(countries)) if countries else 'all countries'
|
|
logger.info('ID ALIAS MAPPER: %s downloaded (%s)', os.path.basename(path), label)
|
|
except IOError as e:
|
|
logger.error('ID ALIAS MAPPER: download of %s failed: %s', os.path.basename(path), e)
|
|
if os.path.exists(tmp):
|
|
os.remove(tmp)
|
|
|
|
def _download_aliases():
|
|
if not TRY_DOWNLOAD:
|
|
return
|
|
base = _abs(PATH)
|
|
stale_secs = int(STALE_DAYS) * 86400
|
|
countries = set(FILTER_COUNTRIES) if FILTER_COUNTRIES else None
|
|
_stream_id_file(PEER_URL, base + PEER_FILE, 'rptrs', countries, stale_secs)
|
|
_stream_id_file(SUBSCRIBER_URL, base + SUBSCRIBER_FILE, 'users', countries, stale_secs)
|
|
|
|
def _reload_aliases():
|
|
global PEER_IDS, SUBSCRIBER_IDS, TALKGROUP_IDS
|
|
base = _abs(PATH)
|
|
PEER_IDS = mk_id_dict(base, PEER_FILE)
|
|
SUBSCRIBER_IDS = mk_id_dict(base, SUBSCRIBER_FILE)
|
|
TALKGROUP_IDS = mk_id_dict(base, TGID_FILE)
|
|
if LOCAL_PEER_FILE:
|
|
PEER_IDS.update(mk_id_dict(base, LOCAL_PEER_FILE))
|
|
if LOCAL_SUB_FILE:
|
|
SUBSCRIBER_IDS.update(mk_id_dict(base, LOCAL_SUB_FILE))
|
|
logger.info('aliases loaded: %d peers, %d subscribers, %d talkgroups',
|
|
len(PEER_IDS), len(SUBSCRIBER_IDS), len(TALKGROUP_IDS))
|
|
|
|
PEER_IDS = {}
|
|
SUBSCRIBER_IDS = {}
|
|
TALKGROUP_IDS = {}
|
|
|
|
async def _alias_refresh_loop():
|
|
while True:
|
|
await asyncio.sleep(86400)
|
|
logger.info('aliases: starting daily refresh')
|
|
await asyncio.to_thread(_download_aliases)
|
|
_reload_aliases()
|
|
|
|
# Logo
|
|
try:
|
|
from config import LOGO_FILE
|
|
except ImportError:
|
|
LOGO_FILE = ''
|
|
_logo_path = _abs(LOGO_FILE) if LOGO_FILE else ''
|
|
_logo_exists = bool(_logo_path and os.path.isfile(_logo_path))
|
|
_logo_media_type = {
|
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
|
|
}.get(os.path.splitext(_logo_path)[1].lower(), 'image/png') if _logo_exists else 'image/png'
|
|
LOGO_HTML = '<img src="/logo" alt="" class="logo">' if _logo_exists else ''
|
|
|
|
def alias(_id, _dict):
|
|
a = get_alias(_id, _dict)
|
|
return None if a == _id else a
|
|
|
|
|
|
# ---- shared state ------------------------------------------------------------
|
|
|
|
class State:
|
|
def __init__(self):
|
|
self.systems = {} # last 'config' payload
|
|
self.bridges = {} # last 'bridge' payload (enriched)
|
|
self.active = {} # call_key -> START bridge_event
|
|
self.log = deque(maxlen=LOG_LINES)
|
|
self.dmrlink = False
|
|
self.clients = set() # connected WebSockets
|
|
|
|
STATE = State()
|
|
|
|
|
|
def call_key(evt):
|
|
return '{}|{}|{}'.format(evt.get('system', ''), evt.get('ts', 0), evt.get('call_id', 0))
|
|
|
|
def enrich_call(evt):
|
|
evt['src_alias'] = alias(evt.get('src'), SUBSCRIBER_IDS)
|
|
evt['peer_alias'] = alias(evt.get('peer'), PEER_IDS)
|
|
evt['tgid_alias'] = alias(evt.get('tgid'), TALKGROUP_IDS)
|
|
return evt
|
|
|
|
def enrich_systems(systems):
|
|
now = time.time()
|
|
for sys in systems.values():
|
|
m = sys.get('MASTER', {})
|
|
ct = m.get('CONNECT_TIME', 0)
|
|
m['connected_secs'] = int(max(0, now - ct)) if ct else None
|
|
for p in sys.get('PEERS', {}).values():
|
|
ct = p.get('CONNECT_TIME', 0)
|
|
p['connected_secs'] = int(max(0, now - ct)) if ct else None
|
|
return systems
|
|
|
|
def enrich_bridges(bridges):
|
|
now = time.time()
|
|
for members in bridges.values():
|
|
for m in members:
|
|
m['TGID_NAME'] = alias(m.get('TGID'), TALKGROUP_IDS)
|
|
if m.get('TO_TYPE') in ('ON', 'OFF'):
|
|
m['remaining'] = int(m.get('TIMER', now) - now)
|
|
else:
|
|
m['remaining'] = None
|
|
return bridges
|
|
|
|
|
|
async def broadcast(obj):
|
|
dead = set()
|
|
for ws in STATE.clients:
|
|
try:
|
|
await ws.send_json(obj)
|
|
except Exception:
|
|
dead.add(ws)
|
|
STATE.clients -= dead
|
|
|
|
|
|
async def handle_event(evt):
|
|
t = evt.get('type')
|
|
if t == 'config':
|
|
STATE.systems = enrich_systems(evt.get('systems', {}))
|
|
await broadcast({'type': 'config', 'systems': STATE.systems})
|
|
|
|
elif t == 'bridge':
|
|
STATE.bridges = enrich_bridges(evt.get('bridges', {}))
|
|
await broadcast({'type': 'bridge', 'bridges': STATE.bridges})
|
|
|
|
elif t == 'bridge_event':
|
|
data = evt.get('data', {})
|
|
enrich_call(data)
|
|
data['_arrived'] = time.time()
|
|
key = call_key(data)
|
|
event_name = data.get('event', '')
|
|
if 'START' in event_name:
|
|
STATE.active[key] = data
|
|
else:
|
|
STATE.active.pop(key, None)
|
|
STATE.log.appendleft(data)
|
|
await broadcast({'type': 'bridge_event', 'data': data})
|
|
|
|
else:
|
|
logger.debug('ignoring unknown event type: %s', t)
|
|
|
|
|
|
# ---- dmrlink3 feed client (with reconnect) -----------------------------------
|
|
|
|
async def dmrlink_feed():
|
|
while True:
|
|
try:
|
|
reader, writer = await asyncio.open_connection(DMRLINK_IP, DMRLINK_PORT)
|
|
logger.info('connected to dmrlink3 feed at %s:%s', DMRLINK_IP, DMRLINK_PORT)
|
|
STATE.dmrlink = True
|
|
await broadcast({'type': 'dmrlink', 'connected': True})
|
|
while True:
|
|
line = await reader.readline()
|
|
if not line:
|
|
break
|
|
try:
|
|
await handle_event(json.loads(line))
|
|
except json.JSONDecodeError:
|
|
logger.warning('bad JSON line from dmrlink3: %r', line[:120])
|
|
except (ConnectionRefusedError, OSError) as e:
|
|
logger.warning('dmrlink3 feed unavailable (%s); retrying in 3s', e)
|
|
finally:
|
|
if STATE.dmrlink:
|
|
STATE.dmrlink = False
|
|
STATE.systems = {}
|
|
STATE.bridges = {}
|
|
STATE.active = {}
|
|
await broadcast({'type': 'dmrlink', 'connected': False})
|
|
await asyncio.sleep(3)
|
|
|
|
|
|
async def reap_calls():
|
|
while True:
|
|
await asyncio.sleep(10)
|
|
now = time.time()
|
|
stale = [k for k, e in STATE.active.items()
|
|
if now - e.get('_arrived', now) > CALL_STALE]
|
|
for k in stale:
|
|
STATE.active.pop(k, None)
|
|
if stale:
|
|
logger.debug('reaped %d stale active call(s)', len(stale))
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app):
|
|
await asyncio.to_thread(_download_aliases)
|
|
_reload_aliases()
|
|
refresher = asyncio.create_task(_alias_refresh_loop())
|
|
feed = asyncio.create_task(dmrlink_feed())
|
|
reaper = asyncio.create_task(reap_calls())
|
|
yield
|
|
feed.cancel()
|
|
reaper.cancel()
|
|
refresher.cancel()
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
|
|
|
|
# ---- HTTP + WebSocket --------------------------------------------------------
|
|
|
|
@app.get('/', response_class=HTMLResponse)
|
|
async def index():
|
|
with open(os.path.join(STATIC, 'dashboard.html'), encoding='utf-8') as f:
|
|
html = f.read()
|
|
return html.replace('{{REPORT_NAME}}', REPORT_NAME).replace('{{LOGO_HTML}}', LOGO_HTML)
|
|
|
|
@app.get('/logo')
|
|
async def serve_logo():
|
|
if not _logo_exists:
|
|
raise HTTPException(status_code=404)
|
|
return FileResponse(_logo_path, media_type=_logo_media_type)
|
|
|
|
@app.get('/api/state')
|
|
async def api_state():
|
|
return JSONResponse(current_state())
|
|
|
|
def current_state():
|
|
return {
|
|
'report_name': REPORT_NAME,
|
|
'dmrlink': STATE.dmrlink,
|
|
'systems': STATE.systems,
|
|
'bridges': STATE.bridges,
|
|
'active': list(STATE.active.values()),
|
|
'log': list(STATE.log),
|
|
}
|
|
|
|
@app.websocket('/ws')
|
|
async def ws_endpoint(ws: WebSocket):
|
|
await ws.accept()
|
|
STATE.clients.add(ws)
|
|
try:
|
|
await ws.send_json({'type': 'initial', **current_state()})
|
|
while True:
|
|
await ws.receive_text()
|
|
except WebSocketDisconnect:
|
|
pass
|
|
finally:
|
|
STATE.clients.discard(ws)
|
|
|
|
|
|
def main():
|
|
uvicorn.run(app, host=WEB_HOST, port=WEB_PORT, log_level='warning')
|
|
|
|
if __name__ == '__main__':
|
|
main()
|