0DMRMaster version 0.4
Этот коммит содержится в:
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}")
|
||||
Executable
+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);
|
||||
Executable
+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()
|
||||
@@ -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
|
||||
|
||||
Executable
+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)
|
||||
|
||||
|
||||
Executable
+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()
|
||||
|
||||
Executable
+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)
|
||||
Executable
+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)
|
||||
Executable
+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
|
||||
Executable
+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
|
||||
|
||||
Executable
+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)
|
||||
Executable
+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}
|
||||
Executable
+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)
|
||||
Executable
+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()
|
||||
Executable
+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
|
||||
Ссылка в новой задаче
Block a user