diff --git a/api/dmrapi.py b/api/dmrapi.py
new file mode 100755
index 0000000..35af974
--- /dev/null
+++ b/api/dmrapi.py
@@ -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}")
diff --git a/dashboard/app.js b/dashboard/app.js
new file mode 100755
index 0000000..d7c07df
--- /dev/null
+++ b/dashboard/app.js
@@ -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}`} (${rel})`;
+}
+
+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 = '
No peers connected.
';
+ 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 => `${unit}`)
+ .join('');
+
+ const callsign = peer.config.callsign || "--"
+
+ const expandedSectionId = `expandable-${nextExpandedSectionId++}`;
+
+ div.innerHTML = `
+ ${peer.name}
+
+
Callsign: ${callsign}
+
Address: ${peer.addr}
+
Status: ${peer.status}
+
Connected: ${formatTimestamp(peer.connect_time)}
+
Last Active: ${formatTimestamp(peer.active_time)}
+ ${unitsHTML? `
${unitsHTML}
` : ''}
+
+ `;
+
+ 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 = `
+
+ | No calls at the moment. |
+
+ `;
+ 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 = `Broadcast`;
+ } else if (call.route_to && call.route_to.length > 0) {
+ routingHTML = call.route_to
+ .map(r => `${r}`)
+ .join('');
+ }
+
+ row.innerHTML = `
+ ${call.call_id} |
+ ${formatTimestamp(call.start_time)} |
+
+ ${call.src_id}
+ ⇉
+ ${dstBadgeText}
+ |
+ ${routingHTML} |
+ `;
+
+ row.appendChild(durationCell);
+ tableBody.appendChild(row);
+ });
+}
+
+fetchData();
+setInterval(fetchData, 3000);
diff --git a/dashboard/index.html b/dashboard/index.html
new file mode 100755
index 0000000..220035f
--- /dev/null
+++ b/dashboard/index.html
@@ -0,0 +1,39 @@
+
+
+
+
+ DMR Network Dashboard
+
+
+
+
+ DMR Network Dashboard
+
+
+
+
+
+
+
Active & Recent Calls
+
+
+
+
+ | ID |
+ Time |
+ Direction |
+ Route |
+ Dur. |
+
+
+
+ | Loading calls... |
+
+
+
+
+
+
diff --git a/dmrmaster.py b/dmrmaster.py
new file mode 100755
index 0000000..f085621
--- /dev/null
+++ b/dmrmaster.py
@@ -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()
diff --git a/dmrtools/__init__.py b/dmrtools/__init__.py
index eb4d863..75a6ab8 100755
--- a/dmrtools/__init__.py
+++ b/dmrtools/__init__.py
@@ -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
diff --git a/dmrtools/app.py b/dmrtools/app.py
new file mode 100755
index 0000000..d3c16f4
--- /dev/null
+++ b/dmrtools/app.py
@@ -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)
+
+
diff --git a/dmrtools/asyncnetwork.py b/dmrtools/asyncnetwork.py
new file mode 100755
index 0000000..3538e06
--- /dev/null
+++ b/dmrtools/asyncnetwork.py
@@ -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()
+
diff --git a/dmrtools/auth.py b/dmrtools/auth.py
new file mode 100755
index 0000000..6b77ff5
--- /dev/null
+++ b/dmrtools/auth.py
@@ -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)
diff --git a/dmrtools/call.py b/dmrtools/call.py
new file mode 100755
index 0000000..190936a
--- /dev/null
+++ b/dmrtools/call.py
@@ -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""
+
+
+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)
diff --git a/dmrtools/dispatcher.py b/dmrtools/dispatcher.py
new file mode 100755
index 0000000..7cf7b44
--- /dev/null
+++ b/dmrtools/dispatcher.py
@@ -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
diff --git a/dmrtools/network.py b/dmrtools/network.py
new file mode 100755
index 0000000..c997e29
--- /dev/null
+++ b/dmrtools/network.py
@@ -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
+
diff --git a/dmrtools/parrot_app.py b/dmrtools/parrot_app.py
new file mode 100755
index 0000000..88a48a6
--- /dev/null
+++ b/dmrtools/parrot_app.py
@@ -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)
diff --git a/dmrtools/peer.py b/dmrtools/peer.py
new file mode 100755
index 0000000..ae2ecf7
--- /dev/null
+++ b/dmrtools/peer.py
@@ -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""
+
+
+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}
diff --git a/dmrtools/peer_controller.py b/dmrtools/peer_controller.py
new file mode 100755
index 0000000..30425a2
--- /dev/null
+++ b/dmrtools/peer_controller.py
@@ -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)
diff --git a/localmaster_EXAMPLE.py b/localmaster_EXAMPLE.py
new file mode 100755
index 0000000..f938006
--- /dev/null
+++ b/localmaster_EXAMPLE.py
@@ -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()
diff --git a/requirements.txt b/requirements.txt
new file mode 100755
index 0000000..c855156
--- /dev/null
+++ b/requirements.txt
@@ -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