diff --git a/README.md b/README.md index 4e2c802..c3463c5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ # 0DMRMaster -Private DMR master server. Version 0.4. - +Private DMR master server. Version 0.5. Copyright ©2025 Alexander Mokrov, UR6LKW. +This project is currently in public alpha and is under active development. +Don't use it in production environment, interfaces may change. + + +## License This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or @@ -18,7 +22,7 @@ along with this program. If not, see . ## Overview -There is a few parts: +This project contains: 1. An implementation of homebrew dmr protocol (which is used by brandmeister and hblink). Not yet complete, but sufficient for #2 and #3. 1. Decoding UDP proxy 1. Basic private dmr master server @@ -26,9 +30,9 @@ There is a few parts: ### Installation 1. Check basic requirements: - Linux or Windows - - Python 3.13+ + - Python 3.11+ 1. Clone/download the repo. -1. Execute (linux example): +1. Create environment and install requirements (linux example): ``` $ python -m venv venv $ . venv/bin/activate @@ -50,6 +54,7 @@ The running server listens for `62031/udp` as DMR service (may be changed with ` and exposes http API/dashboard on `8000/tcp` (may be changed with `--web-port` command line argument): - API: http://YOUR-SERVER-IP:8000/api/dashboard - Web dashboard: http://YOUR-SERVER-IP:8000/dashboard/index.html +![web dashboard](https://github.com/olympy/0DMRMaster/blob/master/doc/dashboard-screenshot.png?raw=true) @@ -72,9 +77,10 @@ and exposes http API/dashboard on `8000/tcp` (may be changed with `--web-port` c - ✔️ unit call routing - ✔️ allow single peer id check - ✔️ TA support (DMRA packet) -- 🥕 dmr internal burst structure decoding (to fix rf fields and get ambe) +- ✔️ dmr internal structure decoding (get LC, voice data) - 🥕 routing (1 timeslot == 1 routing entity) - 🥕 only one call per ts for peer (per routing entity) +- 🥕 dmr internal structure encoding (set LC, generate voice data) - 🥕 apps unit call routing (routing entity for app) - 🥕 configuration - 🥕 users configuration (allowed id and passes per id) diff --git a/dmrtools/app.py b/dmrtools/app.py index d3c16f4..509bc05 100755 --- a/dmrtools/app.py +++ b/dmrtools/app.py @@ -16,14 +16,12 @@ class IAppDispatcher(ABC): Dispatcher interface for app """ @abstractmethod - def inject_packet(self, p: DMRPPacketData) -> None: - pass + def inject_packet(self, p: DMRPPacketData) -> None: ... class IAppCallInterceptor(ABC): @abstractmethod - def process_call_packet(self, call: Call, p: DMRPPacketData) -> None: - pass + def process_call_packet(self, call: Call, p: DMRPPacketData) -> None: ... class App(ABC): @@ -31,8 +29,7 @@ class App(ABC): self.dispatcher: IAppDispatcher|None = None @abstractmethod - def get_name(self) -> str: - pass + def get_name(self) -> str: ... class AppKeeper: diff --git a/dmrtools/auth.py b/dmrtools/auth.py index 6b77ff5..8337777 100755 --- a/dmrtools/auth.py +++ b/dmrtools/auth.py @@ -7,13 +7,11 @@ from .dmrproto import calc_password_hash class IPeerAuth(ABC): @abstractmethod - def allow_peer_id(self, peer_id: int) -> bool: - pass + def allow_peer_id(self, peer_id: int) -> bool: ... @abstractmethod def check_password(self, peer_id: int, salt: bytes, - pass_hash: bytes) -> bool: - pass + pass_hash: bytes) -> bool: ... class AllowAllPeerAuth(IPeerAuth): diff --git a/dmrtools/call.py b/dmrtools/call.py index 190936a..3fcc4ea 100755 --- a/dmrtools/call.py +++ b/dmrtools/call.py @@ -2,7 +2,10 @@ import logging from time import time +from .dmrproto import CallLCDecoder from .dmrproto import DMRPPacketData +from .dmrproto import LCBase, LCCall, LCLocation +from .dmrproto import CallType from .peer import Peer @@ -24,6 +27,10 @@ class Call: self.end_time: float|None = None self.packets: int = 0 self.route_to: set[Peer]|None = None + self.ta: str|None = None + self.loc: LCLocation|None = None + self.rfcall: LCCall|None = None + self._lc_decoder: CallLCDecoder = CallLCDecoder(call_id) logging.info(f"Voice call beg {str(self)}") @property @@ -56,9 +63,30 @@ class Call: 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: + def packet_received(self, p: DMRPPacketData|None = None) -> None: self.last_packet_time = time() self.packets += 1 + if p is not None: + lc: LCBase|None = self._lc_decoder.process_voicedata(p) + if lc is not None: + logging.debug(f"New lc: {lc}") + self._update_lc_data() + + def _update_lc_data(self) -> None: + if (self.ta is None + and (ta := self._lc_decoder.ta) is not None): + self.ta = ta + logging.info(f"Talker alias: '{ta}'") + if (self.loc is None + and (loc := self._lc_decoder.location) is not None): + self.loc = loc + logging.info(f"Location: {loc.lat} {loc.lon}") + if (self.rfcall is None + and (rfcall := self._lc_decoder.call) is not None): + self.rfcall = rfcall + dst: str = (('TG' if rfcall.call_type == CallType.GROUP else '') + + str(rfcall.dst_id)) + logging.info(f"RF call data: {rfcall.src_id}->{dst}") def check_timeout(self, timeout: float) -> bool: return time() - self.last_packet_time < timeout diff --git a/dmrtools/dispatcher.py b/dmrtools/dispatcher.py index 7cf7b44..ecb4947 100755 --- a/dmrtools/dispatcher.py +++ b/dmrtools/dispatcher.py @@ -46,7 +46,7 @@ class Dispatcher(IDatagramReceiver, IAppDispatcher, IPCDispatcher): 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") + # logging.debug(f"Dispatching packet from {orig_addr}:\n{p}\n") call_id = p.stream_id @@ -61,7 +61,7 @@ class Dispatcher(IDatagramReceiver, IAppDispatcher, IPCDispatcher): if len(peers) > 0: call.route_to = peers - call.packet_received() + call.packet_received(p) if p.is_voice_term: call.end() diff --git a/dmrtools/dmrproto/__init__.py b/dmrtools/dmrproto/__init__.py index b21bd9e..58480cc 100755 --- a/dmrtools/dmrproto/__init__.py +++ b/dmrtools/dmrproto/__init__.py @@ -1,4 +1,5 @@ -from .mmdvm_l1 import * +from .enums import * from .etsi_l2 import * -from .util import * from .exceptions import * +from .lc_util import * +from .mmdvm_l1 import * diff --git a/dmrtools/dmrproto/base_fields.py b/dmrtools/dmrproto/base_fields.py index ab2afdd..74ed551 100755 --- a/dmrtools/dmrproto/base_fields.py +++ b/dmrtools/dmrproto/base_fields.py @@ -19,12 +19,10 @@ class DMRPFieldBase(ABC): obj._data[self.offset:self.eoffset] = value @abstractmethod - def __get__(self, obj, cls = None) -> Any: - pass + def __get__(self, obj, cls = None) -> Any: ... @abstractmethod - def __set__(self, obj, value: Any) -> None: - pass + def __set__(self, obj, value: Any) -> None: ... class DMRPFieldBytes(DMRPFieldBase): diff --git a/dmrtools/dmrproto/etsi_l2.py b/dmrtools/dmrproto/etsi_l2.py index d5c67d6..4344f54 100755 --- a/dmrtools/dmrproto/etsi_l2.py +++ b/dmrtools/dmrproto/etsi_l2.py @@ -12,20 +12,21 @@ from .enums import CallType from .exceptions import DMRPFieldOutOfRangeException from .exceptions import DMRPL2BadDataException from .exceptions import DMRPUnknownLCTypeException -from .factory import BaseFactory, IFactoryProduced +from .factory import AbstractFactory, IFactoryProduced class DMRPL2Base(ABC): - DataTypes = enum.StrEnum('DataTypes', [ - 'UNKNOWN', 'FULL_LC', 'VOICE_BURST' - ]) + """ + Base decoder of DMRD packet payload (layer2, 33 bytes) + """ + DataTypes = enum.StrEnum('DataTypes', ['FULL_LC', 'VOICE_BURST']) def __init__(self, data: bytes) -> None: self.set_data(data) def set_data(self, data: bytes) -> None: if len(data) != 33: - DMRPL2BadDataException("Data must be 33 bytes long") + DMRPL2BadDataException("L2 data must be 33 bytes long") self.bitdata: bitarray = bitarray(data, endian='big') @@ -33,8 +34,7 @@ class DMRPL2Base(ABC): return self.bitdata.tobytes() @abstractmethod - def get_data_type(self) -> DMRPL2Base.DataTypes: - pass + def get_data_type(self) -> DMRPL2Base.DataTypes: ... class DMRPL2FullLC(DMRPL2Base): @@ -136,18 +136,12 @@ class LCTalkerAlias(LCBase): _7BIT = 0b00 ISO8 = 0b01 UTF8 = 0b10 - UTF16BF = 0b11 - - def __init__( - self, data: bytes|None, - format: Format = Format.ISO8) -> None: - super().__init__(data) - self.__format: LCTalkerAlias.Format = format + UTF16BE = 0b11 # format - def get_format(self) -> Format: + def get_format(self) -> Format|None: if self.flco != 0x04: - return self.__format + return None return LCTalkerAlias.Format((self._data[2] & 0xC0) >> 6) def set_format(self, format: Format) -> None: @@ -158,10 +152,10 @@ class LCTalkerAlias(LCBase): format = property(get_format, set_format) # len - def get_len(self) -> int: + def get_len(self) -> int|None: if self.flco != 0x04: - return 0 - return (self._data[2] & 0x3E) >> 1 + return None + return int((self._data[2] & 0x3E) >> 1) def set_len(self, len: int) -> None: if self.flco != 0x04: @@ -174,18 +168,21 @@ class LCTalkerAlias(LCBase): len = property(get_len, set_len) @property - def ta_str(self) -> str: - # TODO: decode different fmts + def ta_data(self) -> bytes: if self.flco == 0x04: - return self._data[3:9].decode(encoding='ascii') - return self._data[2:9].decode(encoding='ascii') + if self.format == LCTalkerAlias.Format._7BIT: + data = self._data[2:9] + data[0] &= 1 + return bytes(data) + return bytes(self._data[3:9]) + return bytes(self._data[2:9]) def __str__(self) -> str: return (f"LC TA {self.flco} fmt:{self.format.name} " - f"len:{self.len} str:{self.ta_str}") + f"len:{self.len} data:{self.ta_data.hex()}") -class LCFactory(BaseFactory): +class LCFactory(AbstractFactory[LCBase]): def __init__(self) -> None: """ Initializes the lc analyzer factory with a list of all lc diff --git a/dmrtools/dmrproto/exceptions.py b/dmrtools/dmrproto/exceptions.py index 3215567..a53441c 100755 --- a/dmrtools/dmrproto/exceptions.py +++ b/dmrtools/dmrproto/exceptions.py @@ -1,3 +1,6 @@ +from abc import ABC, abstractmethod + + class DMRPFieldOutOfRangeException(Exception): """ Exception raised when a packet field value is out of the allowed or expected range. @@ -14,18 +17,17 @@ class DMRPBadPacketException(Exception): """ Exception raised when a packet is structurally invalid or corrupted """ - pass - + ... class DMRPL2BadDataException(Exception): """ Exception raised when L2 data is invalid or corrupted. """ - pass + ... class FactoryException(Exception): - pass + ... class DMRPUnknownPacketTypeException(FactoryException): @@ -33,7 +35,7 @@ class DMRPUnknownPacketTypeException(FactoryException): Exception raised when the packet factory cannot recognize the packet type from the given input data. """ - pass + ... class DMRPUnknownLCTypeException(FactoryException): @@ -41,6 +43,31 @@ class DMRPUnknownLCTypeException(FactoryException): Exception raised when the lc analyzer factory cannot recognize the lc type from the given input data. """ - pass + ... +class CustomMessageException(Exception, ABC): + @classmethod + @abstractmethod + def create_message(cls, msg: str) -> str: ... + + def __init__(self, msg: str): + super().__init__(self.__class__.create_message(msg)) + + +class EmbLCAssemblerException(CustomMessageException): + """ + Exception raised on EmbLCAssembler error + """ + @classmethod + def create_message(cls, msg: str) -> str: + return f"Embedded LC failed: {msg}" + + +class CallLCDecoderException(CustomMessageException): + """ + Exception raised on CallLCDecoder error + """ + @classmethod + def create_message(cls, msg: str) -> str: + return f"In-call LC decoder failed: {msg}" diff --git a/dmrtools/dmrproto/factory.py b/dmrtools/dmrproto/factory.py index 462016e..4c304a3 100755 --- a/dmrtools/dmrproto/factory.py +++ b/dmrtools/dmrproto/factory.py @@ -1,44 +1,48 @@ +from __future__ import annotations + from abc import ABC, abstractmethod -from typing import Self, Type, NoReturn +from typing import TypeVar, Generic, Type, ClassVar, NoReturn, Any from .exceptions import FactoryException class IFactoryProduced(ABC): @abstractmethod - def __init__(self, data: bytes) -> None: - pass + def __init__(self, data: bytes) -> None: ... @classmethod @abstractmethod - def detect_by_data(cls, data: bytes) -> bool: - pass + def detect_by_data(cls, data: bytes) -> bool: ... -class BaseFactory(ABC): - __instance: Self|None = None +TProduced = TypeVar("TProduced", bound=IFactoryProduced, covariant=True) + + +class AbstractFactory(Generic[TProduced], ABC): + _instance: ClassVar[AbstractFactory[Any]|None] = None @classmethod - def fd(cls, data: bytes) -> IFactoryProduced: + def fd(cls: Type[AbstractFactory[TProduced]], + data: bytes) -> TProduced: """ Short singleton version of from_data method """ - if cls.__instance is None: - cls.__instance = cls() - return cls.__instance.from_data(data) + if cls._instance is None: + cls._instance = cls() + return cls._instance.from_data(data) def __init__(self, - classes: list[Type[IFactoryProduced]]|None = None) -> None: - self._classes: list[Type[IFactoryProduced]] = ( + classes: list[Type[TProduced]]|None = None) -> None: + self._classes: list[Type[TProduced]] = ( classes if classes is not None else []) - def register(self, cls: Type[IFactoryProduced]) -> None: + def register(self, cls: Type[TProduced]) -> None: self._classes.append(cls) - def from_data(self, data: bytes) -> IFactoryProduced: + def from_data(self, data: bytes) -> TProduced: """ Attempts to create an instance of corresponding - IFactoryProduced-implementing class based on data + TProduced-implementing class based on data """ for cls in self._classes: if cls.detect_by_data(data): diff --git a/dmrtools/dmrproto/lc_util.py b/dmrtools/dmrproto/lc_util.py new file mode 100755 index 0000000..5db2582 --- /dev/null +++ b/dmrtools/dmrproto/lc_util.py @@ -0,0 +1,144 @@ +import logging + +from bitarray import bitarray +from dmr_utils3.bptc import decode_emblc + +from .enums import CallType, VoiceType +from .etsi_l2 import DMRPL2FullLC, DMRPL2VoiceBurst +from .etsi_l2 import LCFactory, LCBase +from .etsi_l2 import LCLocation, LCCall, LCTalkerAlias +from .exceptions import CallLCDecoderException, EmbLCAssemblerException +from .mmdvm_l1 import DMRPPacketData + + +class EmbLCAssembler: + """ + This is auxiliary class to assemble a few voice packets and + assemble embedded LC from them. + + How to use: pass DMRD packets from the same stream id to process_voicedata, if it returns true, then embedded LC can be decoded with decode() + """ + VTYPE_N_MAP: dict[VoiceType, int] = { + VoiceType.BURST_B: 0, + VoiceType.BURST_C: 1, + VoiceType.BURST_D: 2, + VoiceType.BURST_E: 3 + } + + def __init__(self) -> None: + self.reset() + + def reset(self) -> None: + self.lcs: list[bytes] = [] + self.vseq: int = 0 + + def process_voicedata(self, p: DMRPPacketData) -> bool: + if p.voice_type not in EmbLCAssembler.VTYPE_N_MAP: + return False + + burst_n = EmbLCAssembler.VTYPE_N_MAP[p.voice_type] + + # check voice sequence for next packets + if burst_n > 0 and p.vseq != (self.vseq + 1) & 0xFF: + err_msg = f"Wrong vseq ({p.vseq}, expected {self.vseq + 1})" + self.reset() + raise EmbLCAssemblerException(err_msg) + + # check, if burst sequence matches collected + if len(self.lcs) != burst_n: + err_msg = f"Wrong burst N ({burst_n}, expected {len(self.lcs)})" + self.reset() + raise EmbLCAssemblerException(err_msg) + + # try getting LC and collect + emblc = p.get_emb_lc() + if emblc is None: + self.reset() + raise EmbLCAssemblerException("Can't get data") + + self.lcs.append(emblc) + self.vseq = p.vseq + + return burst_n == 3 + + def decode(self) -> bytes|None: + if len(self.lcs) != 4: + return None + + emblc_data: bitarray = bitarray(b"".join(self.lcs), endian='big') + return decode_emblc(emblc_data) + + +class CallLCDecoder: + """ + Decode and collect both full lc and embedded lc in the same call + """ + def __init__(self, stream_id: int) -> None: + self.stream_id: int = stream_id + self.lcs: dict[int, LCBase] = {} # flco -> child(LCBase) + self._assembler: EmbLCAssembler = EmbLCAssembler() + + def process_voicedata(self, p: DMRPPacketData) -> LCBase|None: + if p.stream_id != self.stream_id: + raise CallLCDecoderException("Wrong stream_id") + + if (full_lc := p.get_full_lc()) is not None: + return self._add_lc(full_lc) + else: + try: + if (self._assembler.process_voicedata(p) + and (lc_data := self._assembler.decode()) is not None): + self._assembler.reset() + return self._add_lc(lc_data) + except Exception as e: + logging.debug(f"Exception while processing lc data: {e}") + return None + + def _add_lc(self, lc_data: bytes) -> LCBase: + lc = LCFactory.fd(lc_data) + self.lcs[lc.flco] = lc + return lc + + @property + def call(self) -> LCCall|None: + for flco in LCCall.FLCOS: + if (flco in self.lcs and + type(lc := self.lcs[flco]) is LCCall): + return lc + return None + + @property + def location(self) -> LCLocation|None: + if (LCLocation.FLCOS[0] in self.lcs and + type(lc := self.lcs[LCLocation.FLCOS[0]]) is LCLocation): + return lc + return None + + @property + def ta(self) -> str|None: + # check if all parts are collected and are instances of LCTalkerAlias + if any((flco not in self.lcs or + type(self.lcs[flco]) is not LCTalkerAlias) + for flco in LCTalkerAlias.FLCOS): + return None + + lcta0: LCTalkerAlias = self.lcs[LCTalkerAlias.FLCOS[0]] # type: ignore[assignment] + + ta_data: bytes = b"" + for flco in LCTalkerAlias.FLCOS: + if type(lc := self.lcs[flco]) is LCTalkerAlias: + ta_data += lc.ta_data + + ta_len = lcta0.len + + match lcta0.format: + case LCTalkerAlias.Format._7BIT: + return "" # Not supported; !!TODO: decode 7-bit + case LCTalkerAlias.Format.ISO8: + return ta_data.decode("iso-8859-1", errors='replace')[:ta_len] + case LCTalkerAlias.Format.UTF8: + return ta_data.decode("utf-8", errors='replace')[:ta_len] + case LCTalkerAlias.Format.UTF16BE: + return ta_data.decode("utf-16-be", errors='replace')[:ta_len] + + return None diff --git a/dmrtools/dmrproto/mmdvm_l1.py b/dmrtools/dmrproto/mmdvm_l1.py index a857716..9611414 100755 --- a/dmrtools/dmrproto/mmdvm_l1.py +++ b/dmrtools/dmrproto/mmdvm_l1.py @@ -12,7 +12,7 @@ from .etsi_l2 import DMRPL2Base, DMRPL2FullLC, DMRPL2VoiceBurst from .exceptions import DMRPBadPacketException from .exceptions import DMRPFieldOutOfRangeException from .exceptions import DMRPUnknownPacketTypeException -from .factory import BaseFactory, IFactoryProduced +from .factory import AbstractFactory, IFactoryProduced ############################# @@ -437,7 +437,7 @@ class DMRPPacketData(DMRPBasePeerPacket): f"vt:{'T' if self.is_voice_term else 'f'}") -class DMRPPacketFactory(BaseFactory): +class DMRPPacketFactory(AbstractFactory[DMRPBasePacket]): """ A factory class responsible for creating instances of DMRP packet classes based on packet data. This class supports both predefined packet types diff --git a/dmrtools/dmrproto/util.py b/dmrtools/dmrproto/util.py deleted file mode 100755 index 789b346..0000000 --- a/dmrtools/dmrproto/util.py +++ /dev/null @@ -1,68 +0,0 @@ -import logging - -from bitarray import bitarray -from dmr_utils3.bptc import decode_emblc - -from .mmdvm_l1 import DMRPPacketData - - -class EmbLCAssembler: - """ - This is auxiliary class to assemble a few voice packets and - assemble embedded LC from them. - - How to use: pass DMRD packets from the same stream id to process_voicedata, if it returns true, then embedded LC can be decoded with decode() - """ - VTYPE_N_MAP: dict[DMRPPacketData.VoiceType, int] = { - DMRPPacketData.VoiceType.BURST_B: 0, - DMRPPacketData.VoiceType.BURST_C: 1, - DMRPPacketData.VoiceType.BURST_D: 2, - DMRPPacketData.VoiceType.BURST_E: 3 - } - - def __init__(self) -> None: - self.reset() - - def reset(self) -> None: - self.lcs: list[bytes] = list() - self.vseq: int = 0 - - def process_voicedata(self, p: DMRPPacketData) -> bool: - if p.voice_type not in EmbLCAssembler.VTYPE_N_MAP: - return False - - burst_n = EmbLCAssembler.VTYPE_N_MAP[p.voice_type] - - # check voice sequence for next packets - if burst_n > 0 and p.vseq != (self.vseq + 1) % 0x100: - logging.error("Embedded LC failed: wrong vseq " - f"({p.vseq}, expected {self.vseq + 1})") - self.reset() - return False - - # check, if burst sequence matches collected - if len(self.lcs) != burst_n: - logging.error("Embedded LC failed: wrong burst N " - f"({burst_n}, expected {len(self.lcs)})") - self.reset() - return False - - # try getting LC and collect - emblc = p.get_emb_lc() - if emblc is None: - logging.error("Embedded LC failed: can't get") - self.reset() - return False - - self.lcs.append(emblc) - self.vseq = p.vseq - - # logging.debug(f"assembled emblc's: {self.lcs}") - return burst_n == 3 - - def decode(self) -> bytes|None: - if len(self.lcs) != 4: - return None - - emblc_data: bitarray = bitarray(b"".join(self.lcs), endian='big') - return decode_emblc(emblc_data) diff --git a/dmrtools/network.py b/dmrtools/network.py index c997e29..7341cb5 100755 --- a/dmrtools/network.py +++ b/dmrtools/network.py @@ -3,16 +3,12 @@ from abc import ABC, abstractmethod class IDatagramReceiver(ABC): @abstractmethod - def recv_dg(self, data: bytes, addr: tuple) -> None: - pass + def recv_dg(self, data: bytes, addr: tuple) -> None: ... class IDatagramSender(ABC): @abstractmethod - def send_dg(self, data: bytes, addr: tuple) -> None: - pass + def send_dg(self, data: bytes, addr: tuple) -> None: ... @abstractmethod - def set_receiver(self, receiver: IDatagramReceiver) -> None: - pass - + def set_receiver(self, receiver: IDatagramReceiver) -> None: ... diff --git a/dmrtools/peer_controller.py b/dmrtools/peer_controller.py index 30425a2..094813d 100755 --- a/dmrtools/peer_controller.py +++ b/dmrtools/peer_controller.py @@ -18,16 +18,13 @@ class IPCDispatcher(ABC): Dispatcher interface to pass into controller to call back """ @abstractmethod - def send_dg(self, data: bytes, addr: tuple) -> None: - pass + def send_dg(self, data: bytes, addr: tuple) -> None: ... @abstractmethod - def get_peer_keeper(self) -> PeerKeeper: - pass + def get_peer_keeper(self) -> PeerKeeper: ... @abstractmethod - def get_peer_auth(self) -> IPeerAuth: - pass + def get_peer_auth(self) -> IPeerAuth: ... class PeerController: diff --git a/doc/dashboard-screenshot.png b/doc/dashboard-screenshot.png new file mode 100755 index 0000000..af9d7ab Binary files /dev/null and b/doc/dashboard-screenshot.png differ