You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
914 lines
29 KiB
914 lines
29 KiB
#!/usr/bin/env python
|
|
#
|
|
# Embedded LC codec helpers for FreeDMR.
|
|
#
|
|
# The embedded LC BPTC layout follows MMDVMHost CDMREmbeddedData,
|
|
# CHamming::encode16114/decode16114, and CCRC::encodeFiveBit.
|
|
# MMDVMHost is Copyright (C) Jonathan Naylor G4KLX and licensed GPLv2+
|
|
# (or later). FreeDMR is GPLv3+, so this port is used under GPLv3+.
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import OrderedDict
|
|
from dataclasses import dataclass
|
|
import re
|
|
|
|
from bitarray import bitarray
|
|
|
|
|
|
FULL_LC_BITS = 196
|
|
FULL_LC_BYTES = 9
|
|
FULL_LC_PARITY_BYTES = 3
|
|
FULL_LC_CODE_BYTES = 12
|
|
FULL_LC_INFO_BITS = 196
|
|
EMBEDDED_LC_FRAGMENT_BITS = 32
|
|
EMBEDDED_LC_RAW_BITS = 128
|
|
EMBEDDED_LC_PAYLOAD_BITS = 72
|
|
EMBEDDED_LC_PAYLOAD_BYTES = 9
|
|
SLOT_TYPE_BITS = 20
|
|
SLOT_TYPE_DATA_BITS = 8
|
|
LC_FLCO_GROUP_VOICE = 0x00
|
|
LC_FLCO_UNIT_VOICE = 0x03
|
|
LC_FID_ETSI = 0x00
|
|
LC_SERVICE_OPTIONS_NORMAL = 0x00
|
|
LC_SERVICE_OPTIONS_OVCM = 0x04
|
|
LC_SERVICE_OPTIONS_HBLINK_LEGACY = 0x20
|
|
GROUP_VOICE_LC_OPT = b'\x00\x00\x00'
|
|
UNIT_VOICE_LC_OPT = b'\x03\x00\x00'
|
|
BS_VOICE_SYNC = bitarray('011101010101111111010111110111110111010111110111')
|
|
BS_DATA_SYNC = bitarray('110111111111010101111101011101011101111101011101')
|
|
EMB = {
|
|
'BURST_B': bitarray('0001001110010001'),
|
|
'BURST_C': bitarray('0001011101110100'),
|
|
'BURST_D': bitarray('0001011101110100'),
|
|
'BURST_E': bitarray('0001010100000111'),
|
|
'BURST_F': bitarray('0001000111100010'),
|
|
}
|
|
SLOT_TYPE = {
|
|
'PI_HEAD': bitarray('00010000001101100111'),
|
|
'VOICE_LC_HEAD': bitarray('00010001101110001100'),
|
|
'VOICE_LC_TERM': bitarray('00010010101001011001'),
|
|
'CSBK': bitarray('00010011001010110010'),
|
|
'MBC_HEAD': bitarray('00010100100111110000'),
|
|
'MBC_CONT': bitarray('00010101000100011011'),
|
|
'DATA_HEAD': bitarray('00010110000011001110'),
|
|
'1/2_DATA': bitarray('00010111100000100101'),
|
|
'3/4_DATA': bitarray('00011000111010100001'),
|
|
'IDLE': bitarray('00011001011001001010'),
|
|
'1/1_DATA': bitarray('00011010011110011111'),
|
|
'RES_1': bitarray('00011011111101110100'),
|
|
'RES_2': bitarray('00011100010000110110'),
|
|
'RES_3': bitarray('00011101110011011101'),
|
|
'RES_4': bitarray('00011110110100001000'),
|
|
'RES_5': bitarray('00011111010111100011'),
|
|
}
|
|
|
|
FULL_LC_INTERLEAVE_19696 = (
|
|
0, 181, 166, 151, 136, 121, 106, 91, 76, 61, 46, 31, 16, 1, 182, 167, 152, 137,
|
|
122, 107, 92, 77, 62, 47, 32, 17, 2, 183, 168, 153, 138, 123, 108, 93, 78, 63,
|
|
48, 33, 18, 3, 184, 169, 154, 139, 124, 109, 94, 79, 64, 49, 34, 19, 4, 185, 170,
|
|
155, 140, 125, 110, 95, 80, 65, 50, 35, 20, 5, 186, 171, 156, 141, 126, 111, 96,
|
|
81, 66, 51, 36, 21, 6, 187, 172, 157, 142, 127, 112, 97, 82, 67, 52, 37, 22, 7,
|
|
188, 173, 158, 143, 128, 113, 98, 83, 68, 53, 38, 23, 8, 189, 174, 159, 144, 129,
|
|
114, 99, 84, 69, 54, 39, 24, 9, 190, 175, 160, 145, 130, 115, 100, 85, 70, 55, 40,
|
|
25, 10, 191, 176, 161, 146, 131, 116, 101, 86, 71, 56, 41, 26, 11, 192, 177, 162,
|
|
147, 132, 117, 102, 87, 72, 57, 42, 27, 12, 193, 178, 163, 148, 133, 118, 103, 88,
|
|
73, 58, 43, 28, 13, 194, 179, 164, 149, 134, 119, 104, 89, 74, 59, 44, 29, 14,
|
|
195, 180, 165, 150, 135, 120, 105, 90, 75, 60, 45, 30, 15,
|
|
)
|
|
|
|
FULL_LC_PAYLOAD_INDEXES = (
|
|
136, 121, 106, 91, 76, 61, 46, 31,
|
|
152, 137, 122, 107, 92, 77, 62, 47, 32, 17, 2,
|
|
123, 108, 93, 78, 63, 48, 33, 18, 3, 184, 169,
|
|
94, 79, 64, 49, 34, 19, 4, 185, 170, 155, 140,
|
|
65, 50, 35, 20, 5, 186, 171, 156, 141, 126, 111,
|
|
36, 21, 6, 187, 172, 157, 142, 127, 112, 97, 82,
|
|
7, 188, 173, 158, 143, 128, 113, 98, 83,
|
|
)
|
|
|
|
RS129_HEADER_MASK = (0x96, 0x96, 0x96)
|
|
RS129_TERMINATOR_MASK = (0x99, 0x99, 0x99)
|
|
RS129_POLY = (64, 56, 14, 1)
|
|
|
|
GOLAY_2087_PARITY = (
|
|
0x0000, 0xB08E, 0xE093, 0x501D, 0x70A9, 0xC027, 0x903A, 0x20B4,
|
|
0x60DC, 0xD052, 0x804F, 0x30C1, 0x1075, 0xA0FB, 0xF0E6, 0x4068,
|
|
0x7036, 0xC0B8, 0x90A5, 0x202B, 0x009F, 0xB011, 0xE00C, 0x5082,
|
|
0x10EA, 0xA064, 0xF079, 0x40F7, 0x6043, 0xD0CD, 0x80D0, 0x305E,
|
|
0xD06C, 0x60E2, 0x30FF, 0x8071, 0xA0C5, 0x104B, 0x4056, 0xF0D8,
|
|
0xB0B0, 0x003E, 0x5023, 0xE0AD, 0xC019, 0x7097, 0x208A, 0x9004,
|
|
0xA05A, 0x10D4, 0x40C9, 0xF047, 0xD0F3, 0x607D, 0x3060, 0x80EE,
|
|
0xC086, 0x7008, 0x2015, 0x909B, 0xB02F, 0x00A1, 0x50BC, 0xE032,
|
|
0x90D9, 0x2057, 0x704A, 0xC0C4, 0xE070, 0x50FE, 0x00E3, 0xB06D,
|
|
0xF005, 0x408B, 0x1096, 0xA018, 0x80AC, 0x3022, 0x603F, 0xD0B1,
|
|
0xE0EF, 0x5061, 0x007C, 0xB0F2, 0x9046, 0x20C8, 0x70D5, 0xC05B,
|
|
0x8033, 0x30BD, 0x60A0, 0xD02E, 0xF09A, 0x4014, 0x1009, 0xA087,
|
|
0x40B5, 0xF03B, 0xA026, 0x10A8, 0x301C, 0x8092, 0xD08F, 0x6001,
|
|
0x2069, 0x90E7, 0xC0FA, 0x7074, 0x50C0, 0xE04E, 0xB053, 0x00DD,
|
|
0x3083, 0x800D, 0xD010, 0x609E, 0x402A, 0xF0A4, 0xA0B9, 0x1037,
|
|
0x505F, 0xE0D1, 0xB0CC, 0x0042, 0x20F6, 0x9078, 0xC065, 0x70EB,
|
|
0xA03D, 0x10B3, 0x40AE, 0xF020, 0xD094, 0x601A, 0x3007, 0x8089,
|
|
0xC0E1, 0x706F, 0x2072, 0x90FC, 0xB048, 0x00C6, 0x50DB, 0xE055,
|
|
0xD00B, 0x6085, 0x3098, 0x8016, 0xA0A2, 0x102C, 0x4031, 0xF0BF,
|
|
0xB0D7, 0x0059, 0x5044, 0xE0CA, 0xC07E, 0x70F0, 0x20ED, 0x9063,
|
|
0x7051, 0xC0DF, 0x90C2, 0x204C, 0x00F8, 0xB076, 0xE06B, 0x50E5,
|
|
0x108D, 0xA003, 0xF01E, 0x4090, 0x6024, 0xD0AA, 0x80B7, 0x3039,
|
|
0x0067, 0xB0E9, 0xE0F4, 0x507A, 0x70CE, 0xC040, 0x905D, 0x20D3,
|
|
0x60BB, 0xD035, 0x8028, 0x30A6, 0x1012, 0xA09C, 0xF081, 0x400F,
|
|
0x30E4, 0x806A, 0xD077, 0x60F9, 0x404D, 0xF0C3, 0xA0DE, 0x1050,
|
|
0x5038, 0xE0B6, 0xB0AB, 0x0025, 0x2091, 0x901F, 0xC002, 0x708C,
|
|
0x40D2, 0xF05C, 0xA041, 0x10CF, 0x307B, 0x80F5, 0xD0E8, 0x6066,
|
|
0x200E, 0x9080, 0xC09D, 0x7013, 0x50A7, 0xE029, 0xB034, 0x00BA,
|
|
0xE088, 0x5006, 0x001B, 0xB095, 0x9021, 0x20AF, 0x70B2, 0xC03C,
|
|
0x8054, 0x30DA, 0x60C7, 0xD049, 0xF0FD, 0x4073, 0x106E, 0xA0E0,
|
|
0x90BE, 0x2030, 0x702D, 0xC0A3, 0xE017, 0x5099, 0x0084, 0xB00A,
|
|
0xF062, 0x40EC, 0x10F1, 0xA07F, 0x80CB, 0x3045, 0x6058, 0xD0D6,
|
|
)
|
|
|
|
SLOT_TYPE_NAMES = {
|
|
0x0: "PI_HEAD",
|
|
0x1: "VOICE_LC_HEAD",
|
|
0x2: "VOICE_LC_TERM",
|
|
0x3: "CSBK",
|
|
0x4: "MBC_HEAD",
|
|
0x5: "MBC_CONT",
|
|
0x6: "DATA_HEAD",
|
|
0x7: "1/2_RATE",
|
|
0x8: "3/4_RATE",
|
|
0x9: "IDLE",
|
|
0xA: "1/1_RATE",
|
|
0xB: "RES_1",
|
|
0xC: "RES_2",
|
|
0xD: "RES_3",
|
|
0xE: "RES_4",
|
|
0xF: "RES_5",
|
|
}
|
|
DMR_DATA_BURST_TYPES = frozenset((0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0xA))
|
|
DMR_DATA_CODING = {
|
|
0x3: "BPTC_196_96",
|
|
0x4: "BPTC_196_96",
|
|
0x5: "BPTC_196_96",
|
|
0x6: "BPTC_196_96",
|
|
0x7: "BPTC_196_96",
|
|
0x8: "TRELLIS_3_4",
|
|
0xA: "RATE_1",
|
|
}
|
|
DATA_PACKET_FORMAT_NAMES = {
|
|
0x0: "UDT",
|
|
0x1: "RESPONSE",
|
|
0x2: "UNCONFIRMED",
|
|
0x3: "CONFIRMED",
|
|
0xD: "DEFINED_SHORT_DATA",
|
|
0xE: "RAW_OR_STATUS_SHORT_DATA",
|
|
0xF: "PROPRIETARY",
|
|
}
|
|
EMBEDDED_LC_PAYLOAD_RANGES = (
|
|
(0, 11),
|
|
(16, 27),
|
|
(32, 42),
|
|
(48, 58),
|
|
(64, 74),
|
|
(80, 90),
|
|
(96, 106),
|
|
)
|
|
EMBEDDED_LC_CRC_POSITIONS = (42, 58, 74, 90, 106)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EmbeddedLC:
|
|
data: bytes
|
|
flco: int
|
|
raw: bitarray
|
|
corrected: int = 0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FullLC:
|
|
data: bytes
|
|
flco: int
|
|
source_id: int
|
|
target_id: int
|
|
service_options: int
|
|
is_group_call: bool
|
|
is_unit_call: bool
|
|
|
|
|
|
def build_group_voice_lc(
|
|
dst_id: bytes,
|
|
src_id: bytes,
|
|
service_options: int = LC_SERVICE_OPTIONS_NORMAL,
|
|
) -> bytes:
|
|
if len(dst_id) != 3 or len(src_id) != 3:
|
|
raise FullLCError("DMR LC target and source IDs must be three bytes")
|
|
if service_options < 0 or service_options > 0xFF:
|
|
raise FullLCError("DMR LC service options must fit in one byte")
|
|
|
|
return bytes([LC_FLCO_GROUP_VOICE, LC_FID_ETSI, service_options]) + dst_id + src_id
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SlotType:
|
|
color_code: int
|
|
data_type: int
|
|
name: str
|
|
corrected: int = 0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DmrDataObservation:
|
|
wrapper_data_type: int
|
|
wrapper_name: str
|
|
color_code: int
|
|
air_data_type: int
|
|
air_data_type_name: str
|
|
type_matches: bool
|
|
coding: str
|
|
sync: bytes
|
|
raw_payload: bytes
|
|
logical_pdu: bytes | None = None
|
|
data_packet_format: int | None = None
|
|
data_packet_format_name: str | None = None
|
|
csbko: int | None = None
|
|
fid: int | None = None
|
|
crc_status: str = "NOT_CHECKED"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DmrHumanText:
|
|
text: str
|
|
encoding: str
|
|
profile_hint: str
|
|
application: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DmrHumanLocation:
|
|
sentence_type: str
|
|
latitude: float
|
|
longitude: float
|
|
status: str
|
|
utc: str
|
|
speed_knots: float | None = None
|
|
course: float | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DmrHumanObservation:
|
|
texts: tuple[DmrHumanText, ...] = ()
|
|
location: DmrHumanLocation | None = None
|
|
|
|
|
|
class EmbeddedLCError(ValueError):
|
|
pass
|
|
|
|
|
|
class FullLCError(ValueError):
|
|
pass
|
|
|
|
|
|
class SlotTypeError(ValueError):
|
|
pass
|
|
|
|
|
|
class DmrDataError(ValueError):
|
|
pass
|
|
|
|
|
|
def _profile_hint(data: bytes) -> tuple[str, str]:
|
|
if b"\x13\x98\x13\x98" in data:
|
|
return "ETSI/ANYTONE", "SMS"
|
|
if b"\x0f\xa7\x0f\xa7" in data:
|
|
return "MOTOROLA", "SMS"
|
|
return "UNKNOWN", "TEXT"
|
|
|
|
|
|
def _useful_text(text: str) -> bool:
|
|
return len(text) >= 4 and any(char.isalpha() for char in text)
|
|
|
|
|
|
def _ascii_texts(data: bytes) -> list[tuple[str, str]]:
|
|
texts = []
|
|
for match in re.finditer(rb"[\x20-\x7e]{4,}", data):
|
|
if match.end() == len(data):
|
|
continue
|
|
text = match.group().decode("ascii")
|
|
if _useful_text(text) and "$GPRMC" not in text and "$GNRMC" not in text:
|
|
texts.append((text, "ASCII"))
|
|
return texts
|
|
|
|
|
|
def _utf16_texts(data: bytes, little_endian: bool) -> list[tuple[str, str]]:
|
|
texts = []
|
|
encoding = "UTF-16LE" if little_endian else "UTF-16BE"
|
|
pattern = rb"(?:[\x20-\x7e]\x00){4,}" if little_endian else rb"(?:\x00[\x20-\x7e]){4,}"
|
|
for match in re.finditer(pattern, data):
|
|
if match.end() == len(data):
|
|
continue
|
|
text = match.group().decode("utf-16-le" if little_endian else "utf-16-be")
|
|
if _useful_text(text):
|
|
texts.append((text, encoding))
|
|
return texts
|
|
|
|
|
|
def _nmea_coordinate(value: str, hemisphere: str, degree_digits: int) -> float:
|
|
degrees = float(value[:degree_digits])
|
|
minutes = float(value[degree_digits:])
|
|
coordinate = degrees + (minutes / 60.0)
|
|
return -coordinate if hemisphere in ("S", "W") else coordinate
|
|
|
|
|
|
def _nmea_rmc_location(data: bytes) -> DmrHumanLocation | None:
|
|
match = re.search(rb"\$(?:GP|GN)RMC,[^\r\n\x00]*?(?:\*[0-9A-Fa-f]{2})?(?=[\r\n\x00])", data)
|
|
if not match:
|
|
return None
|
|
try:
|
|
fields = match.group().decode("ascii").split(',')
|
|
if len(fields) < 9 or not fields[3] or not fields[5]:
|
|
return None
|
|
return DmrHumanLocation(
|
|
sentence_type=fields[0][1:],
|
|
utc=fields[1],
|
|
status=fields[2],
|
|
latitude=_nmea_coordinate(fields[3], fields[4], 2),
|
|
longitude=_nmea_coordinate(fields[5], fields[6], 3),
|
|
speed_knots=float(fields[7]) if fields[7] else None,
|
|
course=float(fields[8]) if fields[8] else None,
|
|
)
|
|
except (ValueError, IndexError, UnicodeDecodeError):
|
|
return None
|
|
|
|
|
|
class DmrDataHumanCollector:
|
|
"""Bounded, passive reconstruction for obvious text and NMEA data blocks."""
|
|
|
|
def __init__(self, max_streams: int = 32, max_bytes: int = 256):
|
|
if max_streams < 1 or max_bytes < 12:
|
|
raise ValueError("DMR human collector limits are too small")
|
|
self.max_streams = max_streams
|
|
self.max_bytes = max_bytes
|
|
self._streams = OrderedDict()
|
|
|
|
@property
|
|
def stream_count(self) -> int:
|
|
return len(self._streams)
|
|
|
|
@property
|
|
def largest_buffer(self) -> int:
|
|
return max((len(state['data']) for state in self._streams.values()), default=0)
|
|
|
|
def _state(self, key):
|
|
state = self._streams.pop(key, None)
|
|
if state is None:
|
|
state = {'data': bytearray(), 'emitted': set(), 'location': None}
|
|
self._streams[key] = state
|
|
while len(self._streams) > self.max_streams:
|
|
self._streams.popitem(last=False)
|
|
return state
|
|
|
|
def observe(self, key, observation: DmrDataObservation) -> DmrHumanObservation:
|
|
if observation.wrapper_data_type == 0x6:
|
|
self._streams.pop(key, None)
|
|
self._state(key)
|
|
return DmrHumanObservation()
|
|
if observation.wrapper_data_type != 0x7 or observation.logical_pdu is None:
|
|
return DmrHumanObservation()
|
|
|
|
state = self._state(key)
|
|
state['data'].extend(observation.logical_pdu)
|
|
if len(state['data']) > self.max_bytes:
|
|
del state['data'][:-self.max_bytes]
|
|
data = bytes(state['data'])
|
|
profile_hint, application = _profile_hint(data)
|
|
|
|
found = _ascii_texts(data)
|
|
found.extend(_utf16_texts(data, little_endian=False))
|
|
found.extend(_utf16_texts(data, little_endian=True))
|
|
texts = []
|
|
for text, encoding in found:
|
|
identity = text
|
|
if identity in state['emitted']:
|
|
continue
|
|
state['emitted'].add(identity)
|
|
texts.append(DmrHumanText(text, encoding, profile_hint, application))
|
|
|
|
location = _nmea_rmc_location(data)
|
|
if location == state['location']:
|
|
location = None
|
|
elif location is not None:
|
|
state['location'] = location
|
|
return DmrHumanObservation(tuple(texts), location)
|
|
|
|
|
|
def bytes_to_bits(data: bytes) -> bitarray:
|
|
bits = bitarray(endian="big")
|
|
bits.frombytes(data)
|
|
return bits
|
|
|
|
|
|
def bits_to_int(bits: bitarray) -> int:
|
|
value = 0
|
|
for bit in bits:
|
|
value = (value << 1) | int(bit)
|
|
return value
|
|
|
|
|
|
def _bits_from_int(value: int, length: int) -> bitarray:
|
|
return bitarray((bool(value & (1 << bit)) for bit in range(length - 1, -1, -1)), endian="big")
|
|
|
|
|
|
def _hamming_distance(a: int, b: int) -> int:
|
|
return (a ^ b).bit_count()
|
|
|
|
|
|
def _gf256_mul(left: int, right: int) -> int:
|
|
result = 0
|
|
while right:
|
|
if right & 0x01:
|
|
result ^= left
|
|
right >>= 1
|
|
left <<= 1
|
|
if left & 0x100:
|
|
left ^= 0x11D
|
|
return result & 0xFF
|
|
|
|
|
|
def encode_hamming_15113(data: bitarray) -> bitarray:
|
|
if len(data) != 11:
|
|
raise FullLCError("Hamming(15,11,3) input must be 11 bits")
|
|
return bitarray((
|
|
data[0] ^ data[1] ^ data[2] ^ data[3] ^ data[5] ^ data[7] ^ data[8],
|
|
data[1] ^ data[2] ^ data[3] ^ data[4] ^ data[6] ^ data[8] ^ data[9],
|
|
data[2] ^ data[3] ^ data[4] ^ data[5] ^ data[7] ^ data[9] ^ data[10],
|
|
data[0] ^ data[1] ^ data[2] ^ data[4] ^ data[6] ^ data[7] ^ data[10],
|
|
), endian="big")
|
|
|
|
|
|
def encode_hamming_1393(data: bitarray) -> bitarray:
|
|
if len(data) != 9:
|
|
raise FullLCError("Hamming(13,9,3) input must be 9 bits")
|
|
return bitarray((
|
|
data[0] ^ data[1] ^ data[3] ^ data[5] ^ data[6],
|
|
data[0] ^ data[1] ^ data[2] ^ data[4] ^ data[6] ^ data[7],
|
|
data[0] ^ data[1] ^ data[2] ^ data[3] ^ data[5] ^ data[7] ^ data[8],
|
|
data[0] ^ data[2] ^ data[4] ^ data[5] ^ data[8],
|
|
), endian="big")
|
|
|
|
|
|
def rs129_parity(data: bytes) -> bytes:
|
|
if len(data) != FULL_LC_BYTES:
|
|
raise FullLCError("RS(12,9) LC payload must be 9 bytes")
|
|
|
|
parity = [0x00, 0x00, 0x00]
|
|
for byte in data:
|
|
dbyte = byte ^ parity[2]
|
|
parity[2] = parity[1] ^ _gf256_mul(RS129_POLY[2], dbyte)
|
|
parity[1] = parity[0] ^ _gf256_mul(RS129_POLY[1], dbyte)
|
|
parity[0] = _gf256_mul(RS129_POLY[0], dbyte)
|
|
return bytes((parity[2], parity[1], parity[0]))
|
|
|
|
|
|
def encode_full_lc_parity(data: bytes, terminator: bool = False) -> bytes:
|
|
mask = RS129_TERMINATOR_MASK if terminator else RS129_HEADER_MASK
|
|
return bytes(value ^ mask[index] for index, value in enumerate(rs129_parity(data)))
|
|
|
|
|
|
def _encode_19696(data: bytes) -> bitarray:
|
|
if len(data) != FULL_LC_CODE_BYTES:
|
|
raise FullLCError("BPTC(196,96) input must be 12 bytes")
|
|
|
|
bits = bytes_to_bits(data)
|
|
for _index in range(4):
|
|
bits.insert(0, 0)
|
|
|
|
for index in range(9):
|
|
start = (index * 15) + 1
|
|
end = start + 11
|
|
parity = encode_hamming_15113(bits[start:end])
|
|
for pbit in range(4):
|
|
bits.insert(end + pbit, parity[pbit])
|
|
|
|
for _index in range(60):
|
|
bits.append(0)
|
|
|
|
column = bitarray(9, endian="big")
|
|
for col in range(15):
|
|
start = col + 1
|
|
for index in range(9):
|
|
column[index] = bits[start]
|
|
start += 15
|
|
parity = encode_hamming_1393(column)
|
|
|
|
target = 136 + col
|
|
for pbit in range(4):
|
|
bits[target] = parity[pbit]
|
|
target += 15
|
|
|
|
return bits
|
|
|
|
|
|
def interleave_19696(data: bitarray) -> bitarray:
|
|
if len(data) != FULL_LC_BITS:
|
|
raise FullLCError("BPTC(196,96) data must be 196 bits")
|
|
interleaved = bitarray(FULL_LC_BITS, endian="big")
|
|
for index in range(FULL_LC_BITS):
|
|
interleaved[FULL_LC_INTERLEAVE_19696[index]] = data[index]
|
|
return interleaved
|
|
|
|
|
|
def encode_full_lc(data: bytes, terminator: bool = False) -> bitarray:
|
|
if len(data) != FULL_LC_BYTES:
|
|
raise FullLCError("full LC payload must be 9 bytes")
|
|
code = data + encode_full_lc_parity(data, terminator=terminator)
|
|
return interleave_19696(_encode_19696(code))
|
|
|
|
|
|
def encode_bptc_19696(data: bytes) -> bitarray:
|
|
"""Encode one generic 12-byte logical PDU with BPTC(196,96)."""
|
|
|
|
return interleave_19696(_encode_19696(data))
|
|
|
|
|
|
def decode_bptc_19696(bits: bitarray) -> bytes:
|
|
"""Extract the 96 information bits without claiming FEC/CRC validation."""
|
|
|
|
if len(bits) != FULL_LC_BITS:
|
|
raise DmrDataError("BPTC(196,96) data must be 196 bits")
|
|
|
|
deinterleaved = bitarray(FULL_LC_BITS, endian="big")
|
|
for index, target in enumerate(FULL_LC_INTERLEAVE_19696):
|
|
deinterleaved[index] = bits[target]
|
|
|
|
row_data = bitarray(endian="big")
|
|
for row in range(9):
|
|
start = 1 + (row * 15)
|
|
row_data.extend(deinterleaved[start:start + 11])
|
|
|
|
return row_data[3:].tobytes()
|
|
|
|
|
|
def inspect_data_burst(payload: bytes, wrapper_data_type: int) -> DmrDataObservation:
|
|
"""Return bounded, read-only metadata for one non-voice DMR data burst."""
|
|
|
|
if len(payload) != 33:
|
|
raise DmrDataError("DMR burst payload must be exactly 33 bytes")
|
|
if wrapper_data_type not in DMR_DATA_BURST_TYPES:
|
|
raise DmrDataError("wrapper data type is not a DMR data/control burst")
|
|
|
|
bits = bytes_to_bits(payload)
|
|
info = bits[0:98] + bits[166:264]
|
|
slot_type_bits = bits[98:108] + bits[156:166]
|
|
air_slot_value = bits_to_int(slot_type_bits[:8])
|
|
color_code = air_slot_value >> 4
|
|
air_data_type = air_slot_value & 0x0F
|
|
logical_pdu = None
|
|
if DMR_DATA_CODING[wrapper_data_type] == "BPTC_196_96":
|
|
logical_pdu = decode_bptc_19696(info)
|
|
|
|
data_packet_format = None
|
|
data_packet_format_name = None
|
|
csbko = None
|
|
fid = None
|
|
if wrapper_data_type == 0x6 and logical_pdu is not None:
|
|
data_packet_format = logical_pdu[0] & 0x0F
|
|
data_packet_format_name = DATA_PACKET_FORMAT_NAMES.get(data_packet_format, "UNKNOWN")
|
|
elif wrapper_data_type == 0x3 and logical_pdu is not None:
|
|
csbko = logical_pdu[0] & 0x3F
|
|
fid = logical_pdu[1]
|
|
|
|
return DmrDataObservation(
|
|
wrapper_data_type=wrapper_data_type,
|
|
wrapper_name=SLOT_TYPE_NAMES[wrapper_data_type],
|
|
color_code=color_code,
|
|
air_data_type=air_data_type,
|
|
air_data_type_name=SLOT_TYPE_NAMES[air_data_type],
|
|
type_matches=air_data_type == wrapper_data_type,
|
|
coding=DMR_DATA_CODING[wrapper_data_type],
|
|
sync=bits[108:156].tobytes(),
|
|
raw_payload=payload,
|
|
logical_pdu=logical_pdu,
|
|
data_packet_format=data_packet_format,
|
|
data_packet_format_name=data_packet_format_name,
|
|
csbko=csbko,
|
|
fid=fid,
|
|
)
|
|
|
|
|
|
def encode_header_lc(data: bytes) -> bitarray:
|
|
return encode_full_lc(data, terminator=False)
|
|
|
|
|
|
def encode_terminator_lc(data: bytes) -> bitarray:
|
|
return encode_full_lc(data, terminator=True)
|
|
|
|
|
|
def decode_full_lc(bits: bitarray) -> FullLC:
|
|
if len(bits) != FULL_LC_INFO_BITS:
|
|
raise FullLCError("full LC data must be 196 bits")
|
|
payload = bitarray((bits[index] for index in FULL_LC_PAYLOAD_INDEXES), endian="big")
|
|
data = payload.tobytes()
|
|
flco = data[0] & 0x3F
|
|
return FullLC(
|
|
data=data,
|
|
flco=flco,
|
|
source_id=int.from_bytes(data[6:9], "big"),
|
|
target_id=int.from_bytes(data[3:6], "big"),
|
|
service_options=data[2],
|
|
is_group_call=flco == LC_FLCO_GROUP_VOICE,
|
|
is_unit_call=flco == LC_FLCO_UNIT_VOICE,
|
|
)
|
|
|
|
|
|
def _padded_bits_to_bytes(bits: bitarray) -> bytes:
|
|
padded = bits.copy()
|
|
add_bits = 8 - (len(padded) % 8)
|
|
if add_bits < 8:
|
|
for _bit in range(add_bits):
|
|
padded.insert(0, 0)
|
|
return padded.tobytes()
|
|
|
|
|
|
def voice_head_term(payload: bytes) -> dict[str, object]:
|
|
bits = bytes_to_bits(payload)
|
|
info = bits[0:98] + bits[166:264]
|
|
slot_type = bits[98:108] + bits[156:166]
|
|
sync = bits[108:156]
|
|
lc = decode_full_lc(info).data
|
|
return {
|
|
"LC": lc,
|
|
"CC": _padded_bits_to_bytes(slot_type[0:4]),
|
|
"DTYPE": _padded_bits_to_bytes(slot_type[4:8]),
|
|
"SYNC": sync,
|
|
}
|
|
|
|
|
|
def voice_sync(payload: bytes) -> dict[str, object]:
|
|
bits = bytes_to_bits(payload)
|
|
return {
|
|
"AMBE": [
|
|
bits[0:72],
|
|
bits[72:108] + bits[156:192],
|
|
bits[192:264],
|
|
],
|
|
"SYNC": bits[108:156],
|
|
}
|
|
|
|
|
|
def voice(payload: bytes) -> dict[str, object]:
|
|
bits = bytes_to_bits(payload)
|
|
emb = bits[108:116] + bits[148:156]
|
|
return {
|
|
"AMBE": [
|
|
bits[0:72],
|
|
bits[72:108] + bits[156:192],
|
|
bits[192:264],
|
|
],
|
|
"CC": _padded_bits_to_bytes(emb[0:4]),
|
|
"LCSS": _padded_bits_to_bytes(emb[5:7]),
|
|
"EMBED": bits[116:148],
|
|
}
|
|
|
|
|
|
def encode_slot_type(color_code: int, data_type: int) -> bitarray:
|
|
if not 0 <= color_code <= 0x0F:
|
|
raise SlotTypeError("slot color code must fit in four bits")
|
|
if not 0 <= data_type <= 0x0F:
|
|
raise SlotTypeError("slot data type must fit in four bits")
|
|
|
|
value = (color_code << 4) | data_type
|
|
checksum = GOLAY_2087_PARITY[value]
|
|
code = (value << 12) | ((checksum & 0xFF) << 4) | (checksum >> 12)
|
|
return _bits_from_int(code, SLOT_TYPE_BITS)
|
|
|
|
|
|
def decode_slot_type(bits: bitarray) -> SlotType:
|
|
if len(bits) != SLOT_TYPE_BITS:
|
|
raise SlotTypeError("slot type must be 20 bits")
|
|
|
|
observed = bits_to_int(bits)
|
|
candidates = []
|
|
for value in range(256):
|
|
encoded = bits_to_int(encode_slot_type(value >> 4, value & 0x0F))
|
|
candidates.append((_hamming_distance(observed, encoded), value))
|
|
candidates.sort()
|
|
|
|
distance, value = candidates[0]
|
|
if len(candidates) > 1 and candidates[1][0] == distance:
|
|
raise SlotTypeError("ambiguous Golay(20,8,7) slot type")
|
|
if distance > 3:
|
|
raise SlotTypeError("slot type Golay(20,8,7) check failed")
|
|
|
|
data_type = value & 0x0F
|
|
return SlotType(
|
|
color_code=value >> 4,
|
|
data_type=data_type,
|
|
name=SLOT_TYPE_NAMES[data_type],
|
|
corrected=distance,
|
|
)
|
|
|
|
|
|
def crc5(data: bytes | bitarray) -> int:
|
|
bits = bytes_to_bits(data) if isinstance(data, bytes) else data
|
|
if len(bits) != EMBEDDED_LC_PAYLOAD_BITS:
|
|
raise EmbeddedLCError("embedded LC payload must be 72 bits")
|
|
|
|
total = 0
|
|
for offset in range(0, EMBEDDED_LC_PAYLOAD_BITS, 8):
|
|
total += bits_to_int(bits[offset:offset + 8])
|
|
return total % 31
|
|
|
|
|
|
def encode_hamming_16114(row: bitarray) -> None:
|
|
if len(row) != 16:
|
|
raise EmbeddedLCError("Hamming row must be 16 bits")
|
|
|
|
row[11] = row[0] ^ row[1] ^ row[2] ^ row[3] ^ row[5] ^ row[7] ^ row[8]
|
|
row[12] = row[1] ^ row[2] ^ row[3] ^ row[4] ^ row[6] ^ row[8] ^ row[9]
|
|
row[13] = row[2] ^ row[3] ^ row[4] ^ row[5] ^ row[7] ^ row[9] ^ row[10]
|
|
row[14] = row[0] ^ row[1] ^ row[2] ^ row[4] ^ row[6] ^ row[7] ^ row[10]
|
|
row[15] = row[0] ^ row[2] ^ row[5] ^ row[6] ^ row[8] ^ row[9] ^ row[10]
|
|
|
|
|
|
def decode_hamming_16114(row: bitarray) -> bool:
|
|
if len(row) != 16:
|
|
raise EmbeddedLCError("Hamming row must be 16 bits")
|
|
|
|
c0 = row[0] ^ row[1] ^ row[2] ^ row[3] ^ row[5] ^ row[7] ^ row[8]
|
|
c1 = row[1] ^ row[2] ^ row[3] ^ row[4] ^ row[6] ^ row[8] ^ row[9]
|
|
c2 = row[2] ^ row[3] ^ row[4] ^ row[5] ^ row[7] ^ row[9] ^ row[10]
|
|
c3 = row[0] ^ row[1] ^ row[2] ^ row[4] ^ row[6] ^ row[7] ^ row[10]
|
|
c4 = row[0] ^ row[2] ^ row[5] ^ row[6] ^ row[8] ^ row[9] ^ row[10]
|
|
|
|
syndrome = 0
|
|
syndrome |= 0x01 if c0 != row[11] else 0
|
|
syndrome |= 0x02 if c1 != row[12] else 0
|
|
syndrome |= 0x04 if c2 != row[13] else 0
|
|
syndrome |= 0x08 if c3 != row[14] else 0
|
|
syndrome |= 0x10 if c4 != row[15] else 0
|
|
|
|
corrections = {
|
|
0x01: 11,
|
|
0x02: 12,
|
|
0x04: 13,
|
|
0x08: 14,
|
|
0x10: 15,
|
|
0x19: 0,
|
|
0x0B: 1,
|
|
0x1F: 2,
|
|
0x07: 3,
|
|
0x0E: 4,
|
|
0x15: 5,
|
|
0x1A: 6,
|
|
0x0D: 7,
|
|
0x13: 8,
|
|
0x16: 9,
|
|
0x1C: 10,
|
|
}
|
|
|
|
if syndrome == 0:
|
|
return True
|
|
if syndrome not in corrections:
|
|
return False
|
|
row[corrections[syndrome]] = not row[corrections[syndrome]]
|
|
return True
|
|
|
|
|
|
def encode_embedded_lc(lc: bytes | bitarray) -> tuple[bitarray, bitarray, bitarray, bitarray]:
|
|
payload = bytes_to_bits(lc) if isinstance(lc, bytes) else lc.copy()
|
|
if len(payload) != EMBEDDED_LC_PAYLOAD_BITS:
|
|
raise EmbeddedLCError("embedded LC must be 9 bytes / 72 bits")
|
|
|
|
data = bitarray([0] * EMBEDDED_LC_RAW_BITS, endian="big")
|
|
checksum = crc5(payload)
|
|
data[106] = bool(checksum & 0x01)
|
|
data[90] = bool(checksum & 0x02)
|
|
data[74] = bool(checksum & 0x04)
|
|
data[58] = bool(checksum & 0x08)
|
|
data[42] = bool(checksum & 0x10)
|
|
|
|
position = 0
|
|
for start, end in EMBEDDED_LC_PAYLOAD_RANGES:
|
|
data[start:end] = payload[position:position + (end - start)]
|
|
position += end - start
|
|
|
|
for row_start in range(0, 112, 16):
|
|
row = data[row_start:row_start + 16]
|
|
encode_hamming_16114(row)
|
|
data[row_start:row_start + 16] = row
|
|
|
|
for column in range(16):
|
|
data[column + 112] = (
|
|
data[column + 0]
|
|
^ data[column + 16]
|
|
^ data[column + 32]
|
|
^ data[column + 48]
|
|
^ data[column + 64]
|
|
^ data[column + 80]
|
|
^ data[column + 96]
|
|
)
|
|
|
|
raw = bitarray([0] * EMBEDDED_LC_RAW_BITS, endian="big")
|
|
position = 0
|
|
for index in range(EMBEDDED_LC_RAW_BITS):
|
|
raw[index] = data[position]
|
|
position += 16
|
|
if position > 127:
|
|
position -= 127
|
|
|
|
return tuple(
|
|
raw[index:index + EMBEDDED_LC_FRAGMENT_BITS]
|
|
for index in range(0, EMBEDDED_LC_RAW_BITS, EMBEDDED_LC_FRAGMENT_BITS)
|
|
)
|
|
|
|
|
|
def encode_emblc(lc: bytes | bitarray) -> dict[int, bitarray]:
|
|
fragments = encode_embedded_lc(lc)
|
|
return {
|
|
1: fragments[0],
|
|
2: fragments[1],
|
|
3: fragments[2],
|
|
4: fragments[3],
|
|
}
|
|
|
|
|
|
def decode_embedded_lc(fragments: tuple[bitarray, bitarray, bitarray, bitarray] | list[bitarray]) -> EmbeddedLC:
|
|
if len(fragments) != 4:
|
|
raise EmbeddedLCError("embedded LC requires four fragments")
|
|
|
|
raw = bitarray(endian="big")
|
|
for fragment in fragments:
|
|
if len(fragment) != EMBEDDED_LC_FRAGMENT_BITS:
|
|
raise EmbeddedLCError("embedded LC fragments must be 32 bits")
|
|
raw += fragment
|
|
|
|
data = bitarray([0] * EMBEDDED_LC_RAW_BITS, endian="big")
|
|
position = 0
|
|
for index in range(EMBEDDED_LC_RAW_BITS):
|
|
data[position] = raw[index]
|
|
position += 16
|
|
if position > 127:
|
|
position -= 127
|
|
|
|
corrected = 0
|
|
for row_start in range(0, 112, 16):
|
|
row = data[row_start:row_start + 16]
|
|
before = row.copy()
|
|
if not decode_hamming_16114(row):
|
|
raise EmbeddedLCError("embedded LC Hamming check failed")
|
|
if row != before:
|
|
corrected += 1
|
|
data[row_start:row_start + 16] = row
|
|
|
|
for column in range(16):
|
|
parity = (
|
|
data[column + 0]
|
|
^ data[column + 16]
|
|
^ data[column + 32]
|
|
^ data[column + 48]
|
|
^ data[column + 64]
|
|
^ data[column + 80]
|
|
^ data[column + 96]
|
|
^ data[column + 112]
|
|
)
|
|
if parity:
|
|
raise EmbeddedLCError("embedded LC column parity check failed")
|
|
|
|
payload = bitarray(endian="big")
|
|
for start, end in EMBEDDED_LC_PAYLOAD_RANGES:
|
|
payload += data[start:end]
|
|
|
|
checksum = 0
|
|
if data[42]:
|
|
checksum += 16
|
|
if data[58]:
|
|
checksum += 8
|
|
if data[74]:
|
|
checksum += 4
|
|
if data[90]:
|
|
checksum += 2
|
|
if data[106]:
|
|
checksum += 1
|
|
|
|
if crc5(payload) != checksum:
|
|
raise EmbeddedLCError("embedded LC 5-bit checksum failed")
|
|
|
|
decoded = payload.tobytes()
|
|
return EmbeddedLC(data=decoded, flco=decoded[0] & 0x3F, raw=raw, corrected=corrected)
|
|
|
|
|
|
def embedded_lc_fragment_from_payload(payload: bytes) -> bitarray:
|
|
if len(payload) != 33:
|
|
raise EmbeddedLCError("DMR payload must be 33 bytes")
|
|
bits = bytes_to_bits(payload)
|
|
return bits[116:148]
|
|
|
|
|
|
def payload_with_embedded_lc_fragment(payload: bytes, fragment: bitarray) -> bytes:
|
|
if len(payload) != 33:
|
|
raise EmbeddedLCError("DMR payload must be 33 bytes")
|
|
if len(fragment) != EMBEDDED_LC_FRAGMENT_BITS:
|
|
raise EmbeddedLCError("embedded LC fragment must be 32 bits")
|
|
bits = bytes_to_bits(payload)
|
|
bits = bits[:116] + fragment + bits[148:264]
|
|
return bits.tobytes()
|