Этот коммит содержится в:
Alexander UR6LKW
2025-05-19 16:51:57 +03:00
родитель 058607fe5b
Коммит 65914bc653
16 изменённых файлов: 1430 добавлений и 0 удалений
Executable
+100
Просмотреть файл
@@ -0,0 +1,100 @@
import uvicorn
from datetime import datetime
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from dmrtools.dispatcher import Dispatcher
class DMRApiHelper:
CALLS_MAX = 50
dispatcher: Dispatcher|None = None
@classmethod
def get_peers(cls) -> list:
if cls.dispatcher is None:
return list()
peers = sorted(cls.dispatcher.peer_keeper.peers,
key=lambda p: p.connect_time)
peers_info = [{
"name": p.name,
"peer_id": p.peer_id,
"addr": p.addr_str,
"status": p.status.name,
"connect_time": p.connect_time,
"active_time": p.active_time,
"units": list(p.units.keys()),
"config": p.config} for p in peers]
return peers_info
@classmethod
def get_calls(cls) -> list:
if cls.dispatcher is None:
return list()
calls = sorted(cls.dispatcher.call_keeper.calls_log,
key=lambda c: c.start_time, reverse=True)
# get first CALLS_MAX if more than
if len(calls) > cls.CALLS_MAX:
calls = calls[:cls.CALLS_MAX]
calls_info = [{
"call_id": c.call_id,
"dir": f"{c.src_id}->{c.dst_hr}",
"src_id": c.src_id,
"dst_id": c.dst_id,
"peer_id": c.peer_id,
"call_type": c.call_type.name,
"start_time": c.start_time,
"last_packet_time": c.last_packet_time,
"is_ended": c.is_ended,
"end_time": c.end_time,
"broadcast": c.route_to is None,
"route_to": (list(peer.name for peer in c.route_to)
if c.route_to is not None else []),
"time": f"{c.time:.1f}s"} for c in calls]
return calls_info
# Define FastAPI app
app = FastAPI()
# Mount the "dashboard" directory at /dashboard
app.mount("/dashboard", StaticFiles(directory="dashboard"), name="dashboard")
@app.get("/api/dashboard")
async def get_dashboard():
return {"success": True,
"peers": DMRApiHelper.get_peers(),
"calls": DMRApiHelper.get_calls()}
@app.get("/api/peers")
async def get_peers():
return {"success": True, "peers": DMRApiHelper.get_peers()}
@app.get("/api/calls")
async def get_calls():
return {"success": True, "calls": DMRApiHelper.get_calls()}
async def start_api(host: str, port: int, dispatcher: Dispatcher) -> None:
DMRApiHelper.dispatcher = dispatcher
config = uvicorn.Config(app, host=host, port=port, log_level="error",
loop="asyncio")
server = uvicorn.Server(config)
try:
await server.serve()
except Exception as e:
print(f"start_api: Exception {e}")
+175
Просмотреть файл
@@ -0,0 +1,175 @@
const peersContainer = document.getElementById('peers');
const callsContainer = document.getElementById('calls');
const baseURL = `${window.location.protocol}//${window.location.host}`;
function formatTimestamp(ts) {
if (!ts) return "--"
const date = new Date(ts * 1000);
const now = new Date();
const isToday =
date.getDate() === now.getDate() &&
date.getMonth() === now.getMonth() &&
date.getFullYear() === now.getFullYear();
const timeStr = date.toLocaleTimeString('en-US', { hour12: false });
const dateStr = date.toLocaleDateString();
const secondsAgo = Math.floor((now - date) / 1000);
const rel = secondsAgo < 60
? `${secondsAgo}s ago`
: secondsAgo < 3600
? `${Math.floor(secondsAgo / 60)}m ago`
: secondsAgo < 86400
? `${Math.floor(secondsAgo / 3600)}h ago`
: `${Math.floor(secondsAgo / 86400)}d ago`;
return `${isToday ? timeStr : `${dateStr} ${timeStr}`} <span class="text-xs">(${rel})</span>`;
}
async function fetchData() {
try {
const dashboardRes = await fetch(`${baseURL}/api/dashboard`);
const dashboardData = await dashboardRes.json();
renderPeers(dashboardData.peers || []);
renderCallsTable(dashboardData.calls || []);
} catch (err) {
console.error('Error fetching data:', err);
}
}
function getStatusColor(status) {
switch (status) {
case 'ACTIVE': return 'bg-green-900 text-green-200';
case 'TIMEOUT': return 'bg-yellow-900 text-yellow-200';
case 'DEAD': return 'bg-red-950 text-red-200';
default: return 'bg-cyan-900 text-cyan-200';
}
}
function toggleVisibility(id) {
const el = document.getElementById(id);
if (el) {
el.classList.toggle('hidden');
}
}
let nextExpandedSectionId = 0;
function renderPeers(peers) {
peersContainer.innerHTML = '';
if (peers.length === 0) {
peersContainer.innerHTML = '<p class="text-gray-500">No peers connected.</p>';
return;
}
peers.forEach(peer => {
const div = document.createElement('div');
div.className = 'bg-gray-800 rounded-md shadow overflow-hidden';
const cardTitleColor = getStatusColor(peer.status);
const unitsHTML = (peer.units || [])
.map(unit => `<span class="text-xs bg-cyan-800 text-cyan-200 px-2 py-1 rounded mr-1">${unit}</span>`)
.join('');
const callsign = peer.config.callsign || "--"
const expandedSectionId = `expandable-${nextExpandedSectionId++}`;
div.innerHTML = `
<h3 class="text-lg font-bold p-3 py-1 ${cardTitleColor}">${peer.name}</h3>
<div class="p-3 pt-2 pb-4">
<div><strong>Callsign:</strong> ${callsign}</div>
<div><strong>Address:</strong> ${peer.addr}</div>
<div><strong>Status:</strong> ${peer.status}</div>
<div><strong>Connected:</strong> ${formatTimestamp(peer.connect_time)}</div>
<div><strong>Last Active:</strong> ${formatTimestamp(peer.active_time)}</div>
${unitsHTML? `<div class="mt-2">${unitsHTML}</div>` : ''}
</div>
`;
peersContainer.appendChild(div);
});
}
let tableUpdateIntervals = [];
function renderCallsTable(calls) {
tableUpdateIntervals.forEach(tui => {
clearInterval(tui)
});
tableUpdateIntervals = []
const tableBody = document.getElementById('calls-table-body');
tableBody.innerHTML = '';
if (!calls.length) {
tableBody.innerHTML = `
<tr>
<td colspan="5" class="text-center py-4 text-gray-500">No calls at the moment.</td>
</tr>
`;
return;
}
calls.forEach(call => {
const row = document.createElement('tr');
row.className = `border-b border-gray-700 ${
call.is_ended ? '' : 'bg-yellow-900 animate-pulse'
}`;
const durationCell = document.createElement('td');
durationCell.className = 'px-4 py-2 text-center';
if (call.is_ended) {
durationCell.textContent = call.time;
} else {
// Dynamic live updating
const startMs = call.start_time * 1000;
function updateDuration() {
const now = Date.now();
const delta = ((now - startMs) / 1000).toFixed(1);
durationCell.textContent = `${delta}s`;
// console.log(`Upd dur ${durationCell}`)
}
updateDuration();
tableUpdateIntervals.push(setInterval(updateDuration, 330));
}
let dstBadgeStyle = call.call_type === 'GROUP'
? 'bg-blue-900 text-blue-200'
: 'bg-cyan-900 text-cyan-200';
let dstBadgeText = call.call_type === 'GROUP'
? "TG-" + call.dst_id
: call.dst_id;
let routingHTML = '';
if (call.broadcast) {
routingHTML = `<span class="text-xs bg-gray-700 text-white px-2 py-1 rounded mr-1">Broadcast</span>`;
} else if (call.route_to && call.route_to.length > 0) {
routingHTML = call.route_to
.map(r => `<span class="text-xs bg-green-900 text-green-200 px-2 py-1 rounded mr-1">${r}</span>`)
.join('');
}
row.innerHTML = `
<td class="px-4 py-2 text-right">${call.call_id}</td>
<td class="px-4 py-2 text-left whitespace-nowrap">${formatTimestamp(call.start_time)}</td>
<td class="px-4 py-2 text-left whitespace-nowrap">
<span class="p-0.5 px-2 rounded bg-cyan-800 text-cyan-200">${call.src_id}</span>
<span class="text-gray-400">⇉</span>
<span class="p-0.5 px-2 rounded ${dstBadgeStyle}">${dstBadgeText}</span>
</td>
<td class="px-4 py-2 text-left whitespace-nowrap">${routingHTML}</td>
`;
row.appendChild(durationCell);
tableBody.appendChild(row);
});
}
fetchData();
setInterval(fetchData, 3000);
+39
Просмотреть файл
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<title>DMR Network Dashboard</title>
<script defer src="app.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-gray-200 p-0">
<h1 class="bg-gray-800 text-gray-500 text-2xl font-bold mb-6 p-4 py-3 text-center">DMR Network Dashboard</h1>
<div class="m-4">
<!-- Peers Section -->
<div class="my-6">
<h2 class="text-xl font-semibold mb-2">Connected Peers</h2>
<div id="peers" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"></div>
</div>
<!-- Calls Section -->
<h2 class="text-xl font-semibold mb-2">Active & Recent Calls</h2>
<div id="calls" class="bg-gray-900 rounded-lg shadow overflow-auto">
<table class="table-auto min-w-full text-md text-gray-200">
<thead class="bg-gray-800 text-xs uppercase text-gray-400">
<tr>
<th class="px-4 py-2 w-1/20">ID</th>
<th class="px-4 py-2">Time</th>
<th class="px-4 py-2 w-full">Direction</th>
<th class="px-4 py-2">Route</th>
<th class="px-4 py-2 w-1/20">Dur.</th>
</tr>
</thead>
<tbody id="calls-table-body">
<tr><td colspan="5" class="text-center py-4 text-gray-500">Loading calls...</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>
Executable
+140
Просмотреть файл
@@ -0,0 +1,140 @@
import asyncio
import logging
from argparse import ArgumentParser
from api.dmrapi import start_api
from dmrtools import Dispatcher
from dmrtools.app import App
from dmrtools.asyncnetwork import AsyncDatagramServer
from dmrtools.auth import IPeerAuth
class DMRMaster:
def __init__(self) -> None:
self.interface: str = '0.0.0.0'
self.port: int = 62031
self.web_interface: str = '0.0.0.0'
self.web_port: int = 8000
self.dispatcher: Dispatcher|None = None
self.dg_server: AsyncDatagramServer|None = None
def setup(self) -> None:
ap = ArgumentParser(
prog="dmrmaster.py",
description='0DMRMaster Server by Alexander Mokrov (UR6LKW)')
ap.add_argument('-i', '--interface', type=str,
help='Interface to listen on. Defaults to 0.0.0.0.')
ap.add_argument('-p', '--port', type=int,
help='UDP port to listen on. Defaults to 62031.')
ap.add_argument('--web-interface', type=str,
help='Interface to run API on. Defaults to 0.0.0.0.')
ap.add_argument('--web-port', type=int,
help='TCP port to run API on. Defaults to 8000.')
ap.add_argument('-l', '--log-file', type=str, help='Log filename')
ap.add_argument('-d', '--ll-debug', action='store_true',
help='Log level (INFO/DEBUG)')
args = ap.parse_args()
# print(args)
if args.interface is not None:
self.interface = args.interface
if args.port is not None:
self.port = args.port
if args.web_interface is not None:
self.web_interface = args.web_interface
if args.web_port is not None:
self.web_port = args.web_port
log_level = logging.DEBUG if args.ll_debug else logging.INFO
self.setup_log(log_level, args.log_file)
def setup_log(self, log_level = logging.DEBUG, log_file = None) -> None:
handlers = [logging.StreamHandler()]
if log_file:
file_handler = logging.FileHandler(log_file)
handlers.append(file_handler) # type: ignore[arg-type]
logging.basicConfig(
level=log_level,
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
handlers=handlers
)
def set_peer_auth(self, peer_auth: IPeerAuth) -> None:
if self.dispatcher is None:
logging.error("Can't set auth: no dispatcher")
return
logging.debug(f"Peer auth set to {peer_auth}")
self.dispatcher.peer_auth = peer_auth
def register_app(self, app: App) -> None:
if self.dispatcher is None:
logging.error("Can't register app: no dispatcher")
return
self.dispatcher.app_keeper.register(app)
def config(self) -> None:
"""
Local config
"""
pass
def run(self) -> None:
"""
Run, go async and handle KeyboardInterupt
"""
try:
asyncio.run(self.__async_run())
except KeyboardInterrupt:
logging.info("Interrupted by user (Ctrl+C). Exiting gracefully.")
async def __async_run(self) -> None:
await self.__start_udp()
if not isinstance(self.dg_server, AsyncDatagramServer):
logging.critical(f"Can't listen on {self.interface}:{self.port}")
return
self.dispatcher = Dispatcher(self.dg_server)
self.config() # local config for apps
logging.info(f"Starting API on {self.web_interface}:{self.web_port}")
try:
await start_api(self.web_interface, self.web_port, self.dispatcher)
# await asyncio.Future() # Run forever
finally:
self.stop()
async def __start_udp(self) -> None:
logging.info(
f"Starting server listening on {self.interface}:{self.port}")
loop = asyncio.get_running_loop()
transport, self.dg_server = await loop.create_datagram_endpoint(
lambda: AsyncDatagramServer(),
local_addr=(self.interface, self.port))
def stop(self) -> None:
if self.dispatcher is not None:
self.dispatcher.shutdown()
self.dispatcher = None
if self.dg_server is not None:
self.dg_server.close()
self.dg_server = None
def main():
master = DMRMaster();
master.setup()
master.run()
if __name__ == '__main__':
main()
+3
Просмотреть файл
@@ -1,2 +1,5 @@
from .dispatcher import Dispatcher
from .dmrproto import DMRPPacketFactory
from .network import IDatagramSender, IDatagramReceiver
from .pphex import hexdump
from .auth import AllowAllPeerAuth, DenyAllPeerAuth, ListPeerAuth
+54
Просмотреть файл
@@ -0,0 +1,54 @@
import logging
import time
from abc import ABC, abstractmethod
from .call import Call
from .dmrproto import DMRPPacketData
class AppException(Exception):
pass
class IAppDispatcher(ABC):
"""
Dispatcher interface for app
"""
@abstractmethod
def inject_packet(self, p: DMRPPacketData) -> None:
pass
class IAppCallInterceptor(ABC):
@abstractmethod
def process_call_packet(self, call: Call, p: DMRPPacketData) -> None:
pass
class App(ABC):
def __init__(self) -> None:
self.dispatcher: IAppDispatcher|None = None
@abstractmethod
def get_name(self) -> str:
pass
class AppKeeper:
def __init__(self, dispatcher: IAppDispatcher):
self.dispatcher: IAppDispatcher = dispatcher
self.apps: list[App] = []
def register(self, app: App) -> bool:
logging.info(f"Registered app '{app.get_name()}' ")
app.dispatcher = self.dispatcher
self.apps.append(app)
return True
def process_call_packet(self, call: Call, p: DMRPPacketData) -> None:
for app in self.apps:
if isinstance(app, IAppCallInterceptor):
app.process_call_packet(call, p)
+45
Просмотреть файл
@@ -0,0 +1,45 @@
import asyncio
import logging
from typing import Any
from .network import IDatagramReceiver, IDatagramSender
class AsyncDatagramServer(asyncio.DatagramProtocol, IDatagramSender):
def __init__(self) -> None:
self.receiver: IDatagramReceiver|None = None
self.transport: Any|None = None
def connection_made(self, transport: Any) -> None:
# bug in linux implementation - _SelectorDatagramTransport is
# not inherited from DatagramTransport. That's why hasattr is used
if (hasattr(transport, 'sendto') or
isinstance(transport, asyncio.DatagramTransport)):
self.transport = transport
else:
logging.error("AsyncDatagramServer.connection_made(): "
"Unexpected transport received")
def set_receiver(self, receiver: IDatagramReceiver):
self.receiver = receiver
def datagram_received(self, data: bytes, addr: tuple) -> None:
if self.receiver is not None:
self.receiver.recv_dg(data, addr)
def send_dg(self, data: bytes, addr: tuple) -> None:
if self.transport is not None:
self.transport.sendto(data, addr)
# def error_received(self, exc: Exception|None) -> None:
# logging.error(f"Client protocol error: {exc}")
def connection_lost(self, exc: Exception|None) -> None:
logging.info(f"Client connection closed: {exc}")
self.transport = None
def close(self) -> None:
if self.transport:
self.transport.close()
+69
Просмотреть файл
@@ -0,0 +1,69 @@
import logging
from abc import ABC, abstractmethod
from .dmrproto import calc_password_hash
class IPeerAuth(ABC):
@abstractmethod
def allow_peer_id(self, peer_id: int) -> bool:
pass
@abstractmethod
def check_password(self, peer_id: int, salt: bytes,
pass_hash: bytes) -> bool:
pass
class AllowAllPeerAuth(IPeerAuth):
"""
Auth agent to allow any peer id and any password
"""
def allow_peer_id(self, peer_id: int) -> bool:
return True
def check_password(self, peer_id: int, salt: bytes,
pass_hash: bytes) -> bool:
return True
class DenyAllPeerAuth(IPeerAuth):
"""
Auth agent to deny any peer id and any password
"""
def allow_peer_id(self, peer_id: int) -> bool:
logging.warning("Deny all policy active")
return False
def check_password(self, peer_id: int, salt: bytes,
pass_hash: bytes) -> bool:
return False
class ListPeerAuth(IPeerAuth):
"""
Auth agent with list of allowed peers and their passwords
use empty password to accept any password
"""
def __init__(self, allowed_peers: dict[int, str]|None = None) -> None:
"""
allowed_peers in format peer_id -> password
"""
self.allowed_peers: dict[int, str] = (
dict() if allowed_peers is None else allowed_peers)
def allow_peer_id(self, peer_id: int) -> bool:
return peer_id in self.allowed_peers
def check_password(self, peer_id: int, salt: bytes,
pass_hash: bytes) -> bool:
if peer_id not in self.allowed_peers:
return False
valid_password = self.allowed_peers[peer_id]
if valid_password == '':
logging.debug(f"Any password accepted for {peer_id}")
return True # Accept any password if empty in config
return pass_hash == calc_password_hash(salt, valid_password)
+118
Просмотреть файл
@@ -0,0 +1,118 @@
import logging
from time import time
from .dmrproto import DMRPPacketData
from .peer import Peer
class Call:
DEAD_TIMEOUT = 5
CLEAN_TIMEOUT = 60
CLEAN_LOG_TIMEOUT = 3600 * 6 # 6h
def __init__(self, call_id: int,
src_id: int, dst_id: int, peer_id: int,
call_type: DMRPPacketData.CallType) -> None:
self.call_id: int = call_id
self.src_id: int = src_id
self.dst_id: int = dst_id
self.peer_id: int = peer_id
self.call_type: DMRPPacketData.CallType = call_type
self.start_time: float = time()
self.last_packet_time: float = time()
self.end_time: float|None = None
self.packets: int = 0
self.route_to: set[Peer]|None = None
logging.info(f"Voice call beg {str(self)}")
@property
def dst_hr(self) -> str:
dst: str = f"{self.dst_id}"
if self.call_type == DMRPPacketData.CallType.GROUP:
dst = f"TG-{self.dst_id}"
return dst
@property
def is_ended(self) -> bool:
return self.end_time is not None
@property
def is_dead(self) -> bool:
return (not self.is_ended and
not self.check_timeout(self.DEAD_TIMEOUT))
@property
def time(self) -> float:
if self.end_time is None:
return self.last_packet_time - self.start_time
return self.end_time - self.start_time
@property
def to_be_cleaned(self) -> bool:
return self.is_ended and not self.check_timeout(self.CLEAN_TIMEOUT)
@property
def to_be_cleaned_log(self) -> bool:
return self.is_ended and not self.check_timeout(self.CLEAN_LOG_TIMEOUT)
def packet_received(self) -> None:
self.last_packet_time = time()
self.packets += 1
def check_timeout(self, timeout: float) -> bool:
return time() - self.last_packet_time < timeout
def end(self, by_timeout: bool = False) -> None:
self.end_time = self.last_packet_time if by_timeout else time()
action = "t/o" if by_timeout else "end"
logging.info(f"Voice call {action} {str(self)}")
def __str__(self) -> str:
duration = f"dur:{self.time:.1f}s" if self.is_ended else "running"
res = (f"id:{self.call_id} {self.src_id}->{self.dst_hr} "
f"peer:{self.peer_id} {duration}")
return res
def __repr__(self) -> str:
return f"<Call '{self.call_id}'>"
class CallKeeper:
def __init__(self) -> None:
self.calls: set[Call] = set()
self.calls_log: set[Call] = set()
def maintain(self) -> None:
# end dead calls
dead_calls: set[Call] = set(call for call in self.calls
if call.is_dead)
if len(dead_calls) > 0:
logging.debug(f"Ending dead calls {dead_calls}")
for call in dead_calls:
call.end(by_timeout=True)
logging.debug(
"Upkeep calls" +
("".join(["\n - " + str(call) for call in self.calls])))
clean_calls: set[Call] = set(call for call in self.calls
if call.to_be_cleaned)
if len(clean_calls) > 0:
logging.debug(f"Removing ended calls {clean_calls}")
self.calls -= clean_calls
clean_log: set[Call] = set(call for call in self.calls_log
if call.to_be_cleaned_log)
self.calls_log -= clean_log
def by_call_id(self, call_id: int) -> Call|None:
call_id_map: dict[int, Call] = {call.call_id: call
for call in self.calls}
return call_id_map[call_id] if call_id in call_id_map else None
def add(self, call: Call) -> None:
self.calls.add(call)
self.calls_log.add(call)
+138
Просмотреть файл
@@ -0,0 +1,138 @@
import asyncio
import logging
import traceback
from time import time
from .app import AppKeeper, IAppDispatcher
from .call import Call, CallKeeper
from .dmrproto import DMRPPacketData, DMRPPacketTalkerAlias
from .dmrproto import DMRPPacketFactory, DMRPBasePacket, DMRPBasePeerPacket
from .network import IDatagramReceiver, IDatagramSender
from .peer import Peer, PeerKeeper
from .peer_controller import IPCDispatcher, PeerController
from .auth import IPeerAuth, DenyAllPeerAuth
from .pphex import hexdump
class Dispatcher(IDatagramReceiver, IAppDispatcher, IPCDispatcher):
MAINTENANCE_PERIOD = 10
def __init__(self, sender: IDatagramSender) -> None:
self.sender: IDatagramSender = sender
self.peer_auth: IPeerAuth = DenyAllPeerAuth() # Default policy to deny
self.peer_keeper: PeerKeeper = PeerKeeper()
self.call_keeper: CallKeeper = CallKeeper()
self.app_keeper: AppKeeper = AppKeeper(self)
sender.set_receiver(self)
asyncio.create_task(self.maintain_task())
def maintain(self) -> None:
self.peer_keeper.maintain()
self.call_keeper.maintain()
async def maintain_task(self) -> None:
logging.debug("Dispatcher: periodic maintenance task scheduled")
while True:
await asyncio.sleep(self.MAINTENANCE_PERIOD)
self.maintain()
def shutdown(self) -> None:
logging.info("Dispatcher: shutting down")
for peer in self.peer_keeper.get_all():
controller: PeerController = PeerController(peer, self)
controller.send_close()
def dispatch_data_packet(self, p: DMRPPacketData, orig_addr: tuple) -> None:
logging.debug(f"Dispatching packet from {orig_addr}:\n{p}\n")
call_id = p.stream_id
if (call := self.call_keeper.by_call_id(call_id)) is None:
call = Call(p.stream_id, p.src_id, p.dst_id,
p.peer_id, p.call_type)
self.call_keeper.add(call)
if call.call_type == DMRPPacketData.CallType.UNIT:
# get peers location and rout unit call
peers = self.peer_keeper.get_by_unit(p.dst_id)
if len(peers) > 0:
call.route_to = peers
call.packet_received()
if p.is_voice_term:
call.end()
self.app_keeper.process_call_packet(call, p)
if isinstance(p, DMRPBasePeerPacket):
self.distribute_by_peers(p, orig_addr, call.route_to)
def dispatch_ta_packet(self, p: DMRPPacketTalkerAlias,
orig_addr: tuple) -> None:
self.distribute_by_peers(p, orig_addr)
def distribute_by_peers(self, p: DMRPBasePeerPacket,
orig_addr: tuple,
peers: set[Peer]|None = None) -> None:
sendp = p.copy()
if peers is None:
peers = self.peer_keeper.get_active()
for peer in peers:
if orig_addr == peer.addr: # skip myself
continue
sendp.peer_id = peer.peer_id
# logging.debug(f" - sending to {peer.logname}"
# f"\n{hexdump(sendp.get_data())}\n{sendp}\n")
self.sender.send_dg(sendp.get_data(), peer.addr)
#-------------------------------
# IDatagramReceiver implementation
def recv_dg(self, data: bytes, addr: tuple) -> None:
peer: Peer = self.peer_keeper.get_by_addr(addr)
p: DMRPBasePacket|None = None
try:
p = DMRPPacketFactory.fd(data)
logging.debug(f"Got packet "
f"from {peer.logname} | {len(data)} bytes:\n"
f"{hexdump(data)}\n"
f"{str(p)}\n")
except Exception as e:
logging.error(f"Exception with packet "
f"from {peer.logname} | {len(data)} bytes:\n"
f"{hexdump(data)}\n"
f"{traceback.format_exc()}")
return
# process in context of the peer by controller
controller: PeerController = PeerController(peer, self)
if not controller.process_packet(p):
return
if type(p) is DMRPPacketData:
self.dispatch_data_packet(p, addr)
if type(p) is DMRPPacketTalkerAlias:
self.dispatch_ta_packet(p, addr)
#-------------------------------
# IAppDispatcher implementation
def inject_packet(self, p: DMRPPacketData) -> None:
logging.debug(f"Injecting packet from app")
self.dispatch_data_packet(p, (None, None))
#-------------------------------
# IPCDispatcher implementation
def send_dg(self, data: bytes, addr: tuple) -> None:
self.sender.send_dg(data, addr)
def get_peer_keeper(self) -> PeerKeeper:
return self.peer_keeper
def get_peer_auth(self) -> IPeerAuth:
return self.peer_auth
+18
Просмотреть файл
@@ -0,0 +1,18 @@
from abc import ABC, abstractmethod
class IDatagramReceiver(ABC):
@abstractmethod
def recv_dg(self, data: bytes, addr: tuple) -> None:
pass
class IDatagramSender(ABC):
@abstractmethod
def send_dg(self, data: bytes, addr: tuple) -> None:
pass
@abstractmethod
def set_receiver(self, receiver: IDatagramReceiver) -> None:
pass
+102
Просмотреть файл
@@ -0,0 +1,102 @@
import asyncio
import logging
import time
from .app import App, IAppCallInterceptor, AppException
from .call import Call
from .dmrproto import DMRPPacketData
class ParrotApp(App, IAppCallInterceptor):
"""
Simple parrot application
"""
def __init__(self, parrot_id: int = 9990, repeat_delay: int = 5,
enable_unit: bool = True, enable_group: bool = True) -> None:
# settings
self.parrot_id: int = parrot_id
self.repeat_delay: int = repeat_delay
self.enable_unit: bool = enable_unit
self.enable_group: bool = enable_group
super().__init__()
# internal state
self._mycalls: set[int] = set()
self._records: dict[int, list[DMRPPacketData]] = dict()
@property
def name(self) -> str:
return self.get_name()
async def repeat(self, packets: list[DMRPPacketData]) -> None:
if self.dispatcher is None:
raise AppException(
"send_packets called before dispatcher had been set")
if len(packets) == 0:
logging.debug(f"{self.name}: nothing to repeat, 0 packets in call")
return
await asyncio.sleep(self.repeat_delay)
logging.info(f"{self.name}: repeating call")
stream_id = packets[0].stream_id
self._mycalls.add(stream_id)
start_time = time.monotonic()
for i, p in enumerate(packets):
self.dispatcher.inject_packet(p)
if (delay := start_time + (i + 1) * 0.06 - time.monotonic()) > 0:
await asyncio.sleep(delay)
self._mycalls.discard(stream_id)
def record(self, call: Call, p: DMRPPacketData) -> None:
if p.src_id == self.parrot_id or p.dst_id != self.parrot_id:
return
if (p.call_type == DMRPPacketData.CallType.UNIT
and not self.enable_unit):
logging.debug(f"{self.name}: unit calls disabled")
return
if (p.call_type == DMRPPacketData.CallType.GROUP
and not self.enable_group):
logging.debug(f"{self.name}: group calls disabled")
return
if p.stream_id in self._mycalls:
logging.debug(f"{self.name}: skipping my own call")
return
if p.stream_id not in self._records:
logging.info(f"{self.name}: recording {call.call_id}")
self._records[p.stream_id] = []
ans_p = p.copy()
# invert source and dest for unit call
if p.call_type == DMRPPacketData.CallType.UNIT:
ans_p.src_id = self.parrot_id
ans_p.dst_id = p.src_id
ans_p.stream_id = p.stream_id + 1
self._records[p.stream_id].append(ans_p)
if call.is_ended:
packets: list[DMRPPacketData] = self._records[p.stream_id]
asyncio.create_task(self.repeat(packets))
del self._records[p.stream_id]
#------------------------------------
# App implementation
def get_name(self) -> str:
return f"Parrot {self.parrot_id}"
#------------------------------------
# IAppCallInterceptor implementation
def process_call_packet(self, call: Call, p: DMRPPacketData) -> None:
self.record(call, p)
+158
Просмотреть файл
@@ -0,0 +1,158 @@
from __future__ import annotations
import enum
import logging
from time import time
class Unit:
TIMEOUT = 3600
def __init__(self, unit_id: int) -> None:
self.unit_id: int = unit_id
self.active_time: float = time()
def update_active(self) -> None:
self.active_time = time()
def check_timeout(self) -> bool:
return time() - self.active_time < self.TIMEOUT
class Peer:
class Status(enum.IntEnum):
LOGIN = enum.auto()
AUTH = enum.auto()
CONFIG = enum.auto()
ACTIVE = enum.auto()
DEAD = enum.auto()
def is_applicable(self, test_status: Peer.Status) -> bool:
"""
Loose check, if test_status is applicable for the current one,
like we can treat any status except DEAD as LOGIN and accept login
packet in it
"""
applicable_map: dict[Peer.Status, set[Peer.Status]] = {
# current status --> Applicable test statuses
Peer.Status.AUTH: {Peer.Status.LOGIN,
Peer.Status.AUTH},
Peer.Status.CONFIG: {Peer.Status.LOGIN,
Peer.Status.AUTH,
Peer.Status.CONFIG},
Peer.Status.ACTIVE: {Peer.Status.LOGIN,
Peer.Status.AUTH,
Peer.Status.CONFIG,
Peer.Status.ACTIVE},
}
# if not in map, return exact match
if self not in applicable_map:
return self == test_status
# return if applicable
return test_status in applicable_map[self]
PING_TIMEOUT = 130
def __init__(self, addr: tuple) -> None:
self.addr: tuple = addr
self.auth_salt: bytes|None = None
self.status: Peer.Status = Peer.Status.LOGIN
self.peer_id: int = 0
self.connect_time: float = time()
self.active_time: float = time()
self.config: dict[str, str] = dict()
self.units: dict[int, Unit] = dict()
@property
def logname(self) -> str:
return (self.addr_str if self.peer_id == 0
else f"{self.peer_id}/{self.addr_str}")
@property
def name(self) -> str:
return str(self.peer_id) if self.peer_id != 0 else self.addr_str
@property
def addr_str(self) -> str:
return f"{self.addr[0]}:{self.addr[1]}"
def check_timeout(self) -> bool:
return time() - self.active_time < self.PING_TIMEOUT
def update_active(self) -> None:
self.active_time = time()
def update_unit(self, unit_id: int) -> None:
if unit_id not in self.units:
logging.info(f"Unit {unit_id} added to {self.logname}")
self.units[unit_id] = Unit(unit_id)
self.units[unit_id].update_active()
def die(self) -> None:
self.status = Peer.Status.DEAD
self.peer_id = 0
def __str__(self) -> str:
return f"{self.logname} status:{self.status.name}"
def __repr__(self) -> str:
return f"<Peer '{self.logname}'>"
class PeerKeeper:
def __init__(self) -> None:
self.peers: set[Peer] = set()
def maintain(self) -> None:
# Check timeouts
for peer in self.peers:
# check unit timeouts:
tout_unit_ids = [unit_id for unit_id, unit in peer.units.items()
if not unit.check_timeout()]
for unit_id in tout_unit_ids:
logging.info(f"Unit {unit_id} removed from {peer.logname}")
del peer.units[unit_id]
if not peer.check_timeout():
logging.info(f"Peer {peer.logname} timed out")
peer.die()
# remove dead peers
dead_peers = set(peer for peer in self.peers
if peer.status == Peer.Status.DEAD)
if len(dead_peers) > 0:
logging.debug(f"Removing dead peers {dead_peers}")
self.peers -= dead_peers
logging.debug(
"Upkeep peers" +
("".join(["\n - " + str(peer) for peer in self.peers])))
def get_by_addr(self, addr: tuple) -> Peer:
addr_map: dict[tuple, Peer] = {peer.addr: peer for peer in self.peers}
if addr in addr_map:
return addr_map[addr]
peer = Peer(addr)
logging.info(f"Peer {peer.logname} connected")
self.peers.add(peer)
return peer
def get_by_id(self, peer_id: int) -> set[Peer]:
return {peer for peer in self.peers if peer.peer_id == peer_id}
def get_by_unit(self, unit_id: int) -> set[Peer]:
return {peer for peer in self.peers if unit_id in peer.units}
def get_all(self) -> set[Peer]:
return self.peers
def get_active(self) -> set[Peer]:
return {peer for peer in self.peers
if peer.status == Peer.Status.ACTIVE}
+210
Просмотреть файл
@@ -0,0 +1,210 @@
import logging
from abc import ABC, abstractmethod
from .auth import IPeerAuth
from .dmrproto import DMRPBasePacket
from .dmrproto import DMRPPacketConfig, DMRPPacketPing, DMRPPacketPong
from .dmrproto import DMRPPacketData, DMRPPacketTalkerAlias
from .dmrproto import DMRPPacketLogin, DMRPPacketSalt, DMRPPacketAuth
from .dmrproto import DMRPPacketMasterClose, DMRPPacketRepeaterClose
from .dmrproto import DMRPPacketMasterNoAck, DMRPPacketAck
from .peer import Peer, PeerKeeper
from .pphex import hexdump
class IPCDispatcher(ABC):
"""
Dispatcher interface to pass into controller to call back
"""
@abstractmethod
def send_dg(self, data: bytes, addr: tuple) -> None:
pass
@abstractmethod
def get_peer_keeper(self) -> PeerKeeper:
pass
@abstractmethod
def get_peer_auth(self) -> IPeerAuth:
pass
class PeerController:
def __init__(self, peer: Peer, dispatcher: IPCDispatcher) -> None:
self.peer: Peer = peer
self.dispatcher: IPCDispatcher = dispatcher
self.peer_auth: IPeerAuth = dispatcher.get_peer_auth()
def process_packet(self, p: DMRPBasePacket) -> bool:
"""
Process packet in context of the peer
Returns:
True - continue processing with dispatcher (for data)
False - stop processing (peer cmds)
"""
# CLOSE packet
if type(p) is DMRPPacketRepeaterClose:
logging.info(f"Peer {self.peer.logname} disconnected")
self.peer.die()
return False
# DATA packet
if type(p) is DMRPPacketData:
if not self.peer.status.is_applicable(Peer.Status.ACTIVE):
logging.debug(
f"Got DMRPPacketData "
f"while status is {self.peer.status.name}")
logging.error(f"Data from inactive peer {self.peer.logname}. "
f"Closing connection.")
self.peer.die()
self.send_close(p.peer_id)
return False
self.peer.update_active()
self.peer.update_unit(p.src_id)
return True
# PING packet
if type(p) is DMRPPacketPing:
if not self.peer.status.is_applicable(Peer.Status.ACTIVE):
logging.debug(
f"Got DMRPPacketPing "
f"while status is {self.peer.status.name}")
logging.error(f"Ping from inactive peer {self.peer.logname}. "
f"Closing connection.")
self.peer.die()
self.send_close(p.peer_id)
return False
logging.debug(f"Ping-pong {self.peer.logname}.")
self.peer.update_active()
self.send_pong()
return False
# LOGIN sequence
if type(p) is DMRPPacketLogin:
if not self.peer.status.is_applicable(Peer.Status.LOGIN):
logging.debug(
f"Got DMRPPacketLogin "
f"while status is {self.peer.status.name}")
logging.error(f"Bad login sequence from {self.peer.logname}. "
f"Closing connection.")
self.peer.die()
self.send_close(p.peer_id)
return False
peer_keeper: PeerKeeper = self.dispatcher.get_peer_keeper()
if len(peer_keeper.get_by_id(p.peer_id)) > 0:
logging.error(f"Auth {self.peer.logname}: "
f"peer id {p.peer_id} is already connected")
self.peer.die()
self.send_close(p.peer_id)
return False
if not self.peer_auth.allow_peer_id(p.peer_id):
logging.error(f"Auth {self.peer.logname}: "
f"peer id {p.peer_id} disabled")
self.peer.die()
self.send_close(p.peer_id)
return False
self.peer.status = Peer.Status.AUTH
logging.info(f"Login request from {self.peer.logname}"
f" ({p.peer_id})")
self.send_salt()
return False
if type(p) is DMRPPacketAuth:
if not self.peer.status.is_applicable(Peer.Status.AUTH):
logging.debug(
f"Got DMRPPacketAuth "
f"while status is {self.peer.status.name}")
logging.error(f"Bad login sequence from {self.peer.logname}. "
f"Closing connection.")
self.peer.die()
self.send_close(p.peer_id)
return False
if (self.peer.auth_salt is None or
not self.peer_auth.check_password(p.peer_id,
self.peer.auth_salt,
p.pass_hash)):
logging.error(
f"Auth {self.peer.logname}: password incorrect")
self.peer.die()
self.send_close(p.peer_id)
return False
self.peer.peer_id = p.peer_id
self.peer.status = Peer.Status.CONFIG
logging.info(f"Auth success {self.peer.logname}")
self.send_ack_ok()
return False
if type(p) is DMRPPacketConfig:
if not self.peer.status.is_applicable(Peer.Status.CONFIG):
logging.debug(
f"Got DMRPPacketConfig "
f"while status is {self.peer.status.name}")
logging.error(f"Bad login sequence from {self.peer.logname}. "
f"Closing connection.")
self.peer.die()
self.send_close(p.peer_id)
return False
config = {
'callsign': p.callsign,
'rx_freq': p.rx_freq,
'tx_freq': p.tx_freq,
'power': p.power,
'color_code': p.color_code,
'lat': p.lat,
'lon': p.lon,
'height': p.height,
'location': p.location,
'description': p.description,
'slots': p.slots,
'url': p.url,
'software_id': p.software_id,
'package_id': p.package_id,
}
logging.info(f"Config from {self.peer.logname}: " +
", ".join(f"{k}={v}" for k, v in config.items()));
self.peer.status = Peer.Status.ACTIVE
self.peer.config = config
self.send_ack_ok()
return False
return True
def send_data(self, p: DMRPBasePacket) -> None:
data: bytes = p.get_data()
logging.debug(
f"Sending packet to {self.peer.logname} | {len(data)} bytes:\n"
f"{hexdump(data)}\n"
f"{str(p)}\n")
self.dispatcher.send_dg(p.get_data(), self.peer.addr)
def send_close(self, peer_id: int = 0) -> None:
p = DMRPPacketMasterClose()
p.peer_id = peer_id if peer_id != 0 else self.peer.peer_id
self.send_data(p)
def send_salt(self) -> None:
p = DMRPPacketSalt()
p.set_random_salt()
self.peer.auth_salt = p.salt
self.send_data(p)
def send_ack_ok(self) -> None:
p = DMRPPacketAck()
p.peer_id = self.peer.peer_id
self.send_data(p)
def send_pong(self) -> None:
p = DMRPPacketPong()
p.peer_id = self.peer.peer_id
self.send_data(p)
+24
Просмотреть файл
@@ -0,0 +1,24 @@
import logging
import time
from dmrmaster import DMRMaster
from dmrtools import AllowAllPeerAuth, ListPeerAuth
from dmrtools.parrot_app import ParrotApp
class DMRMasterLocal(DMRMaster):
def config(self):
# Local config
self.set_peer_auth(AllowAllPeerAuth())
# self.set_peer_auth(ListPeerAuth({1: 'pass1', 2: ''}))
self.register_app(ParrotApp(9990))
def main():
master = DMRMasterLocal();
master.setup()
master.run()
if __name__ == '__main__':
main()
+37
Просмотреть файл
@@ -0,0 +1,37 @@
annotated-types==0.7.0
anyio==4.9.0
asyncio==3.4.3
certifi==2025.4.26
click==8.1.8
colorama==0.4.6
dnspython==2.7.0
email_validator==2.2.0
fastapi==0.115.12
fastapi-cli==0.0.7
h11==0.16.0
httpcore==1.0.9
httptools==0.6.4
httpx==0.28.1
idna==3.10
Jinja2==3.1.6
markdown-it-py==3.0.0
MarkupSafe==3.0.2
mdurl==0.1.2
pydantic==2.11.4
pydantic_core==2.33.2
Pygments==2.19.1
python-dotenv==1.1.0
python-multipart==0.0.20
PyYAML==6.0.2
rich==14.0.0
rich-toolkit==0.14.4
shellingham==1.5.4
sniffio==1.3.1
starlette==0.46.2
tomli==2.2.1
typer==0.15.3
typing-inspection==0.4.0
typing_extensions==4.13.2
uvicorn==0.34.2
watchfiles==1.0.5
websockets==15.0.1