509 строки
16 KiB
Python
Исполняемый файл
509 строки
16 KiB
Python
Исполняемый файл
import enum
|
|
import random
|
|
|
|
from abc import ABC
|
|
from hashlib import sha256
|
|
from typing import Type, Self, Any
|
|
|
|
|
|
#############################
|
|
# Local exception classes
|
|
#############################
|
|
class DMRPFieldOutOfRangeException(Exception):
|
|
"""
|
|
Exception raised when a packet field value is out of the allowed or expected range.
|
|
|
|
Attributes:
|
|
field: The name or identifier of the problematic field.
|
|
typename: The expected type or range description for the field.
|
|
"""
|
|
def __init__(self, field, typename) -> None:
|
|
super().__init__(f"Field out of range: {field} must be {typename}")
|
|
|
|
|
|
class DMRPUnknownPacketTypeException(Exception):
|
|
"""
|
|
Exception raised when the packet factory cannot recognize the packet type
|
|
from the given input data.
|
|
"""
|
|
pass
|
|
|
|
|
|
class DMRPBadPacket(Exception):
|
|
"""
|
|
Exception raised when a packet is structurally invalid or corrupted.
|
|
"""
|
|
pass
|
|
|
|
|
|
#############################
|
|
# Functions
|
|
#############################
|
|
def calc_password_hash(salt: bytes, password: str) -> bytes:
|
|
return sha256(salt + password.encode()).digest()
|
|
|
|
|
|
#############################
|
|
# Field classes
|
|
#############################
|
|
class DMRPFieldBase(ABC):
|
|
def __init__(self, name: str, offset: int, bytelen: int) -> None:
|
|
self.name, self.offset, self.bytelen = name, offset, bytelen
|
|
self.eoffset = offset + bytelen
|
|
self.typename = f"bytes{self.bytelen}"
|
|
|
|
def __get__(self, obj, cls = None) -> Any:
|
|
return bytes(obj._data[self.offset:self.eoffset])
|
|
|
|
def __set__(self, obj, value: Any) -> None:
|
|
if not isinstance(value, bytes) or self.bytelen != len(value):
|
|
raise DMRPFieldOutOfRangeException(self.name, self.typename)
|
|
obj._data[self.offset:self.eoffset] = value
|
|
|
|
|
|
class DMRPFieldBytes(DMRPFieldBase):
|
|
def __get__(self, obj, cls = None) -> bytes:
|
|
return super().__get__(obj, cls)
|
|
|
|
def __set__(self, obj, value: bytes) -> None:
|
|
super().__set__(obj, value)
|
|
|
|
|
|
class DMRPFieldStr(DMRPFieldBase):
|
|
def __init__(self, name: str, offset: int, bytelen: int,
|
|
pad_with: bytes = b'\x20') -> None:
|
|
super().__init__(name, offset, bytelen)
|
|
self.typename = 'str'
|
|
self.pad_with = pad_with
|
|
|
|
def __get__(self, obj, cls = None) -> str:
|
|
return (super().__get__(obj, cls).strip(b'\x20\x00')
|
|
.decode(encoding='ascii', errors='ignore'))
|
|
|
|
def __set__(self, obj, value: str) -> None:
|
|
bvalue = value.encode()
|
|
if len(bvalue) > self.bytelen:
|
|
bvalue = bvalue[:self.bytelen]
|
|
bvalue = bvalue.ljust(self.bytelen, self.pad_with)
|
|
super().__set__(obj, bvalue)
|
|
|
|
|
|
class DMRPFieldInt(DMRPFieldBase):
|
|
def __init__(self, name: str, offset: int, bytelen: int) -> None:
|
|
super().__init__(name, offset, bytelen)
|
|
self.typename = f"uint{str(bytelen * 8)}"
|
|
|
|
def __get__(self, obj, cls = None) -> int:
|
|
return int.from_bytes(super().__get__(obj, cls), byteorder="big")
|
|
|
|
def __set__(self, obj, value: int) -> None:
|
|
if not (0 <= value < 1<<(self.bytelen * 8)):
|
|
raise DMRPFieldOutOfRangeException(self.name, self.typename)
|
|
super().__set__(obj, value.to_bytes(self.bytelen, byteorder="big"))
|
|
|
|
|
|
class DMRPFieldPeerAuto(DMRPFieldInt):
|
|
def fixoffset(self, offset: int) -> None:
|
|
self.offset, self.eoffset = offset, offset + self.bytelen
|
|
|
|
def __init__(self, name: str) -> None:
|
|
super().__init__(name, 4, 4)
|
|
|
|
def __get__(self, obj, cls = None) -> int:
|
|
self.fixoffset(len(obj.PKT_TYPE))
|
|
return super().__get__(obj, cls)
|
|
|
|
def __set__(self, obj, value: int) -> None:
|
|
self.fixoffset(len(obj.PKT_TYPE))
|
|
super().__set__(obj, value)
|
|
|
|
|
|
#############################
|
|
# Packet classes hierarchy
|
|
#############################
|
|
class DMRPBasePacket(ABC):
|
|
"""
|
|
Abstract base packet class.
|
|
|
|
Derived packet class must redefine specific PKT_TYPE and PKT_SIZE
|
|
class variables so that detect_by_data method works
|
|
"""
|
|
PKT_TYPE: bytes = b""
|
|
PKT_SIZE: int = 0
|
|
|
|
def __init__(self, data: bytes|None = None) -> None:
|
|
self._data: bytearray|None = None
|
|
if data is None:
|
|
self.create()
|
|
else:
|
|
self.from_data(data)
|
|
|
|
def copy(self) -> Self:
|
|
new_instance = self.__class__() # Creates the same subclass
|
|
if self._data is not None:
|
|
new_instance._data = self._data[:] # Copy the bytes
|
|
return new_instance
|
|
|
|
@classmethod
|
|
def detect_by_data(cls, data: bytes) -> bool:
|
|
"""
|
|
Detect, if this class can handle given data by matching PKT_TYPE to
|
|
the data header prefix and PKT_SIZE to data length
|
|
"""
|
|
if cls.PKT_SIZE != 0 and cls.PKT_SIZE != len(data):
|
|
return False
|
|
if cls.PKT_TYPE == b"":
|
|
return False
|
|
return data.startswith(cls.PKT_TYPE)
|
|
|
|
def from_data(self, data: bytes) -> None:
|
|
"""
|
|
Set inner data to raw data
|
|
|
|
Args:
|
|
data (bytes): The raw data from which to create a packet.
|
|
|
|
Raises:
|
|
DMRPBadPacket: if size or type doesn't match
|
|
"""
|
|
self._data = None
|
|
if not data.startswith(self.PKT_TYPE):
|
|
raise DMRPBadPacket(
|
|
f"Bad packet type: '{data[0:4]!r}', expected '{self.PKT_TYPE!r}'")
|
|
if len(data) != self.PKT_SIZE:
|
|
raise DMRPBadPacket(
|
|
f"Bad packet size: {len(data)}, expected {self.PKT_SIZE}")
|
|
self._data = bytearray(data)
|
|
|
|
def create(self) -> None:
|
|
"""
|
|
Create an empty data of the current packet type
|
|
"""
|
|
self._data = bytearray(self.PKT_SIZE)
|
|
if self.PKT_TYPE is not None:
|
|
self._data[0:len(self.PKT_TYPE)] = self.PKT_TYPE
|
|
|
|
def get_data(self) -> bytes:
|
|
"""
|
|
Return raw data
|
|
"""
|
|
if self._data is None:
|
|
return b''
|
|
return bytes(self._data)
|
|
|
|
# pkt_type
|
|
@property
|
|
def pkt_type(self) -> str:
|
|
if self.PKT_TYPE is None:
|
|
return ""
|
|
return self.PKT_TYPE.decode(encoding='ascii', errors='ignore')
|
|
|
|
|
|
class DMRPBasePeerPacket(DMRPBasePacket, ABC):
|
|
"""
|
|
Abstract base class (more specific), which handles typical packets with
|
|
type header and peer_id field
|
|
"""
|
|
# peer_id
|
|
peer_id: DMRPFieldInt = DMRPFieldPeerAuto('peer_id')
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.pkt_type} peer:{self.peer_id}"
|
|
|
|
|
|
class DMRPPacketLogin(DMRPBasePeerPacket):
|
|
PKT_TYPE = b'RPTL'
|
|
PKT_SIZE = 8
|
|
|
|
|
|
class DMRPPacketPing(DMRPBasePeerPacket):
|
|
PKT_TYPE = b'RPTPING'
|
|
PKT_SIZE = 11
|
|
|
|
|
|
class DMRPPacketPong(DMRPBasePeerPacket):
|
|
PKT_TYPE = b'MSTPONG'
|
|
PKT_SIZE = 11
|
|
|
|
|
|
class DMRPPacketMasterNoAck(DMRPBasePeerPacket):
|
|
PKT_TYPE = b'MSTNAK'
|
|
PKT_SIZE = 10
|
|
|
|
|
|
class DMRPPacketMasterClose(DMRPBasePeerPacket):
|
|
PKT_TYPE = b'MSTCL'
|
|
PKT_SIZE = 9
|
|
|
|
|
|
class DMRPPacketRepeaterClose(DMRPBasePeerPacket):
|
|
PKT_TYPE = b'RPTCL'
|
|
PKT_SIZE = 9
|
|
|
|
|
|
class DMRPPacketAck(DMRPBasePeerPacket):
|
|
PKT_TYPE = b'RPTACK'
|
|
PKT_SIZE = 10
|
|
|
|
|
|
class DMRPPacketSalt(DMRPBasePacket):
|
|
PKT_TYPE = b'RPTACK'
|
|
PKT_SIZE = 10
|
|
|
|
salt = DMRPFieldBytes('salt', 6, 4)
|
|
|
|
def set_random_salt(self) -> None:
|
|
self.salt = random.randbytes(4)
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.pkt_type} salt:{self.salt.hex()}"
|
|
|
|
|
|
class DMRPPacketAuth(DMRPPacketLogin):
|
|
PKT_TYPE = b'RPTK'
|
|
PKT_SIZE = 40
|
|
|
|
pass_hash = DMRPFieldBytes('pass_hash', 8, 32)
|
|
|
|
def set_password_hash(self, salt: bytes, password: str) -> None:
|
|
"""
|
|
Sets pass_hash field based on text password and salt bytes
|
|
"""
|
|
self.pass_hash = calc_password_hash(salt, password)
|
|
|
|
def check_password_hash(self, salt: bytes, password: str) -> bool:
|
|
"""
|
|
Checks pass_hash field against given password and salt
|
|
"""
|
|
return self.pass_hash == calc_password_hash(salt, password)
|
|
|
|
def __str__(self) -> str:
|
|
return (f"{self.pkt_type} peer:{self.peer_id} "
|
|
f"pass_hash:{self.pass_hash.hex()}")
|
|
|
|
|
|
class DMRPPacketConfig(DMRPBasePeerPacket):
|
|
PKT_TYPE = b'RPTC'
|
|
PKT_SIZE = 302
|
|
|
|
def create(self) -> None:
|
|
self._data = bytearray(b'\x20' * self.PKT_SIZE)
|
|
self._data[0:len(self.PKT_TYPE)] = self.PKT_TYPE
|
|
|
|
callsign = DMRPFieldStr('callsign', 8, 8)
|
|
rx_freq = DMRPFieldStr('rx_freq', 16, 9)
|
|
tx_freq = DMRPFieldStr('tx_freq', 25, 9)
|
|
power = DMRPFieldStr('power', 34, 2)
|
|
color_code = DMRPFieldStr('color_code', 36, 2)
|
|
lat = DMRPFieldStr('lat', 38, 8)
|
|
lon = DMRPFieldStr('lon', 46, 9)
|
|
height = DMRPFieldStr('height', 55, 3)
|
|
location = DMRPFieldStr('location', 58, 20)
|
|
description = DMRPFieldStr('description', 78, 19)
|
|
slots = DMRPFieldStr('slots', 97, 1)
|
|
url = DMRPFieldStr('url', 98, 124)
|
|
software_id = DMRPFieldStr('software_id', 222, 40)
|
|
package_id = DMRPFieldStr('package_id', 262, 40)
|
|
|
|
def __str__(self) -> str:
|
|
return (f"{self.pkt_type} peer:{self.peer_id} "
|
|
f"callsign:'{self.callsign}' rx_freq:'{self.rx_freq}' "
|
|
f"tx_freq:'{self.tx_freq}' power:'{self.power}' "
|
|
f"cc:'{self.color_code}' lat:'{self.lat}' lon:'{self.lon}' "
|
|
f"height:'{self.height}' location:'{self.location}' "
|
|
f"description:'{self.description}' slots:'{self.slots}' "
|
|
f"url:'{self.url}' software_id:'{self.software_id}' "
|
|
f"package_id:'{self.package_id}'")
|
|
|
|
|
|
class DMRPPacketTalkerAlias(DMRPBasePeerPacket):
|
|
PKT_TYPE = b'DMRA'
|
|
PKT_SIZE = 15
|
|
|
|
src_id = DMRPFieldInt('src_id', 8, 3)
|
|
ta_data = DMRPFieldBytes('ta_data', 11, 4)
|
|
ta_str = DMRPFieldStr('ta_str', 11, 4)
|
|
|
|
def __str__(self) -> str:
|
|
return (f"{self.pkt_type} src:{self.src_id} peer:{self.peer_id} "
|
|
f"ta:{self.ta_data!r} ta_str:{self.ta_str}")
|
|
|
|
|
|
class DMRPPacketData(DMRPBasePeerPacket):
|
|
PKT_TYPE = b'DMRD'
|
|
PKT_SIZE = 55
|
|
|
|
CallType = enum.StrEnum('CallType', ['UNIT', 'GROUP'])
|
|
|
|
seq = DMRPFieldInt('seq', 4, 1)
|
|
src_id = DMRPFieldInt('src_id', 5, 3)
|
|
dst_id = DMRPFieldInt('dst_id', 8, 3)
|
|
peer_id = DMRPFieldInt('peer_id', 11, 4)
|
|
stream_id = DMRPFieldInt('stream_id', 16, 4)
|
|
bits = DMRPFieldInt('bits', 15, 1)
|
|
dmr_data = DMRPFieldBytes('dmr_data', 20, 33)
|
|
ber = DMRPFieldInt('ber', 53, 1)
|
|
rssi = DMRPFieldInt('rssi', 54, 1)
|
|
|
|
def set_random_stream_id(self) -> None:
|
|
self.stream_id = int.from_bytes(random.randbytes(4), byteorder="big")
|
|
|
|
# slot
|
|
def get_slot(self) -> int:
|
|
if self._data is None:
|
|
return 1
|
|
return 2 if self._data[15] & 0x80 else 1
|
|
|
|
def set_slot(self, slot: int) -> None:
|
|
if not (1 <= slot <= 2):
|
|
raise DMRPFieldOutOfRangeException("slot", "1..2")
|
|
if self._data is not None:
|
|
self._data[15] = ((self._data[15] & ~0x80) |
|
|
(0x80 if slot == 2 else 0))
|
|
|
|
slot = property(get_slot, set_slot)
|
|
|
|
# call_type
|
|
def get_call_type(self) -> CallType:
|
|
if self._data is None or self._data[15] & 0x40 != 0:
|
|
return self.CallType.UNIT
|
|
return self.CallType.GROUP
|
|
|
|
def set_call_type(self, call_type: CallType) -> None:
|
|
if call_type not in self.CallType:
|
|
raise DMRPFieldOutOfRangeException(
|
|
"call_type", '|'.join(ct.value for ct in self.CallType))
|
|
if self._data is not None:
|
|
self._data[15] = (
|
|
(self._data[15] & ~0x40) |
|
|
(0x40 if call_type == self.CallType.UNIT else 0))
|
|
|
|
call_type = property(get_call_type, set_call_type)
|
|
|
|
# frame_type
|
|
def get_frame_type(self) -> int:
|
|
if self._data is None:
|
|
return 0
|
|
return (self._data[15] & 0x30) >> 4
|
|
|
|
def set_frame_type(self, frame_type: int) -> None:
|
|
if not (0 <= frame_type < 1<<2):
|
|
raise DMRPFieldOutOfRangeException("frame_type", "uint2")
|
|
if self._data is not None:
|
|
self._data[15] = (self._data[15] & ~0x30) | (frame_type << 4)
|
|
|
|
frame_type = property(get_frame_type, set_frame_type)
|
|
|
|
# vseq
|
|
def get_vseq(self) -> int:
|
|
if self._data is None:
|
|
return 0
|
|
return self._data[15] & 0xF
|
|
|
|
def set_vseq(self, vseq: int) -> None:
|
|
if not (0 <= vseq < 1<<4):
|
|
raise DMRPFieldOutOfRangeException("vseq", "uint4")
|
|
if self._data is not None:
|
|
self._data[15] = (self._data[15] & ~0xF) | (vseq & 0xF)
|
|
|
|
vseq = property(get_vseq, set_vseq)
|
|
|
|
# is_voice_term
|
|
@property
|
|
def is_voice_term(self) -> bool:
|
|
return self.frame_type == 0x2 and self.vseq == 0x2
|
|
|
|
def __str__(self) -> str:
|
|
return self.format()
|
|
|
|
def __format__(self, fmtspec: str) -> str:
|
|
return self.format(fmtspec)
|
|
|
|
def format(self, fmtspec: str = '') -> str:
|
|
match fmtspec:
|
|
case 'basic':
|
|
return (f"{self.pkt_type} "
|
|
f"peer:{self.peer_id} "
|
|
f"stream:{self.stream_id} "
|
|
f"{self.call_type.name} TS{self.slot} "
|
|
f"src:{self.src_id} dst:{self.dst_id} "
|
|
f"bits:{self.bits:08b}")
|
|
case 'ext':
|
|
return str(self) + f" data:{self.dmr_data.hex()}"
|
|
|
|
# default
|
|
return (f"{self.pkt_type} "
|
|
f"peer:{self.peer_id} "
|
|
f"stream:{self.stream_id} "
|
|
f"{self.call_type.name} TS{self.slot} "
|
|
f"src:{self.src_id} dst:{self.dst_id} "
|
|
f"bits:{self.bits:08b} frame_type:{self.frame_type} "
|
|
f"vseq:{self.vseq} seq:{self.seq} "
|
|
f"ber:{self.ber} rssi:{self.rssi} "
|
|
f"vt:{'T' if self.is_voice_term else 'f'}")
|
|
|
|
|
|
class DMRPPacketFactory:
|
|
"""
|
|
A factory class responsible for creating instances of DMRP packet classes
|
|
based on packet data. This class supports both predefined packet types
|
|
and user-registered custom packet types.
|
|
"""
|
|
|
|
__instance: Self|None = None
|
|
|
|
@classmethod
|
|
def fd(cls, data: bytes) -> DMRPBasePacket:
|
|
"""
|
|
Short singleton version of from_data method
|
|
"""
|
|
if cls.__instance is None:
|
|
cls.__instance = cls()
|
|
return cls.__instance.from_data(data)
|
|
|
|
def __init__(self) -> None:
|
|
"""
|
|
Initializes the packet factory with a list of all packet classes.
|
|
These classes must implement a `detect_by_data` class method to
|
|
determine whether they can handle the given input data.
|
|
"""
|
|
self.__pclasses = [
|
|
DMRPPacketMasterNoAck,
|
|
DMRPPacketMasterClose, DMRPPacketRepeaterClose,
|
|
DMRPPacketLogin, DMRPPacketAck, DMRPPacketAuth, DMRPPacketConfig,
|
|
DMRPPacketPing, DMRPPacketPong, DMRPPacketSalt,
|
|
DMRPPacketData, DMRPPacketTalkerAlias,
|
|
]
|
|
|
|
def register_custom_packet(self, cls: Type[DMRPBasePacket]) -> None:
|
|
"""
|
|
Registers a custom packet class to the factory.
|
|
|
|
Args:
|
|
cls: A class that implements the static method `detect_by_data`.
|
|
If this method returns True, the class is used to create a packet instance.
|
|
"""
|
|
self.__pclasses.append(cls)
|
|
|
|
def from_data(self, data: bytes) -> DMRPBasePacket:
|
|
"""
|
|
Attempts to create an DMRP packet instance of corresponding packet
|
|
class based on packet data.
|
|
|
|
Args:
|
|
data (bytes): The raw data from which to create a packet.
|
|
|
|
Returns:
|
|
DMRPBasePacket: An instance of a subclass of DMRPBasePacket that matches the data.
|
|
|
|
Raises:
|
|
DMRPUnknownPacketTypeException: If no registered packet class can handle the data.
|
|
"""
|
|
for cls in self.__pclasses:
|
|
if cls.detect_by_data(data):
|
|
return cls(data)
|
|
|
|
ptypestr = data[0:4].decode(encoding='ascii', errors='ignore')
|
|
raise DMRPUnknownPacketTypeException(f"Unknown packet type {ptypestr}")
|
|
|