DMR-данные (SMS) на TYT MD-UV380 под OpenGD77

Восстановлено из транскрипта сессии после потери рабочего дерева на сервере сборки.

Что внутри:
- четыре новых модуля прошивки (приём, сборка исходящего, меню Messages);
- правки существующих файлов в виде скриптов, с порядком применения;
- инструменты стенда: снятие экрана рации по USB, дамп кадров, эталонная сборка пакета;
- NOTES.md: что проверено в эфире, что не доделано, где грабли.

Работало в эфире в августе 2026: данные ходят в обе стороны в режиме хотспота,
рация принимает SMS и показывает на экране. Отправка собрана и сверена с эталоном
сервера побайтово, но в эфире не проверялась.

Claude-Session: https://claude.ai/code/session_01GJtvG5WwqYNe1b7s9LLtgy
Этот коммит содержится в:
2026-09-11 02:39:24 +09:00
Коммит b5a8e92e45
32 изменённых файлов: 3812 добавлений и 0 удалений
+112
Просмотреть файл
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Listen on the radio's MMDVM port and dump every received DMR burst verbatim.
Prints the MMDVM control byte and the raw 33-byte burst as hex, so the frames can be
decoded and compared byte for byte elsewhere. Read-only towards the air: the only thing
sent to the radio is a periodic GET_STATUS, which is required to put/keep it in modem
mode (the firmware leaves modem mode if the host goes quiet, and only enters it when it
sees an MMDVM frame).
"""
import os, sys, termios, time, argparse
MMDVM_FRAME_START = 0xE0
MMDVM_GET_STATUS = 0x01
MMDVM_DMR_DATA1 = 0x18
MMDVM_DMR_DATA2 = 0x1A
DATA_TYPES = {
0x0: "PI header", 0x1: "voice LC header", 0x2: "terminator with LC",
0x3: "CSBK", 0x4: "MBC header", 0x5: "MBC continuation",
0x6: "data header", 0x7: "rate 1/2 data", 0x8: "rate 3/4 data",
0x9: "idle", 0xA: "rate 1 data",
}
def open_port(path):
fd = os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
a = termios.tcgetattr(fd)
a[0] = 0
a[1] = 0
a[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
a[3] = 0
a[6][termios.VMIN] = 0
a[6][termios.VTIME] = 0
termios.tcsetattr(fd, termios.TCSANOW, a)
termios.tcflush(fd, termios.TCIOFLUSH)
return fd
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--port", default="/dev/ttyACM0")
ap.add_argument("--secs", type=int, default=600)
args = ap.parse_args()
fd = open_port(args.port)
buf = b""
deadline = time.time() + args.secs
next_poll = 0.0
counts = {}
total = 0
print("dumping raw DMR bursts for %d s (GET_STATUS keepalive every 3 s)" % args.secs, flush=True)
try:
while time.time() < deadline:
now = time.time()
if now >= next_poll:
# keepalive: also what puts the radio into modem mode in the first place
try:
os.write(fd, bytes([MMDVM_FRAME_START, 3, MMDVM_GET_STATUS]))
except OSError:
pass
next_poll = now + 3.0
try:
chunk = os.read(fd, 4096)
except (BlockingIOError, OSError):
chunk = b""
if chunk:
buf += chunk
else:
time.sleep(0.002)
# parse MMDVM frames: E0 <len> <type> <payload...>
while len(buf) >= 3:
if buf[0] != MMDVM_FRAME_START:
buf = buf[1:]
continue
length = buf[1]
if length < 3:
buf = buf[1:]
continue
if len(buf) < length:
break
frame, buf = buf[:length], buf[length:]
ftype = frame[2]
if ftype in (MMDVM_DMR_DATA1, MMDVM_DMR_DATA2):
total += 1
control = frame[3] if len(frame) > 3 else 0
burst = frame[4:37]
slot = 1 if ftype == MMDVM_DMR_DATA1 else 2
if control & 0x40: # data sync burst
dt = control & 0x0F
label = "data/%s" % DATA_TYPES.get(dt, "type %d" % dt)
elif control & 0x20:
label = "voice sync"
else:
label = "voice seq %d" % (control & 0x0F)
counts[label] = counts.get(label, 0) + 1
print("%s slot%d ctrl=%02X %-28s %s" % (
time.strftime("%H:%M:%S", time.gmtime()), slot, control, label, burst.hex()),
flush=True)
finally:
os.close(fd)
print("\n--- summary: %d DMR bursts ---" % total, flush=True)
for k in sorted(counts, key=lambda x: -counts[x]):
print(" %-28s %d" % (k, counts[k]), flush=True)
if __name__ == "__main__":
main()
+115
Просмотреть файл
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Grab the radio's screen over USB and save it as a PNG.
Uses the CPS read command (area 6 = display buffer), which the firmware answers only
while the radio is NOT in modem mode - and, importantly, such requests do not switch it
into modem mode either. So the screen can be watched while the radio does its normal job.
Display is 160x128, RGB565, 2 bytes per pixel.
"""
import os, sys, termios, time, zlib, struct, argparse
WIDTH, HEIGHT = 160, 128
CPS_ACCESS_DISPLAY_BUFFER = 6
CHUNK = 1024
def open_port(path):
fd = os.open(path, os.O_RDWR | os.O_NOCTTY)
a = termios.tcgetattr(fd)
a[0] = 0
a[1] = 0
a[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
a[3] = 0
a[6][termios.VMIN] = 0
a[6][termios.VTIME] = 20
termios.tcsetattr(fd, termios.TCSANOW, a)
termios.tcflush(fd, termios.TCIOFLUSH)
return fd
def read_exact(fd, n, timeout=3.0):
buf = b""
deadline = time.time() + timeout
while len(buf) < n and time.time() < deadline:
try:
part = os.read(fd, n - len(buf))
except BlockingIOError:
part = b""
if part:
buf += part
else:
time.sleep(0.003)
return buf
def read_block(fd, area, addr, length):
req = bytes([ord('R'), area,
(addr >> 24) & 0xFF, (addr >> 16) & 0xFF, (addr >> 8) & 0xFF, addr & 0xFF,
(length >> 8) & 0xFF, length & 0xFF])
termios.tcflush(fd, termios.TCIFLUSH)
os.write(fd, req)
head = read_exact(fd, 3)
if len(head) < 3 or head[0:1] != b'R':
return None
got = (head[1] << 8) | head[2]
data = read_exact(fd, got)
return data if len(data) == got else None
def write_png(path, rgb_rows):
"""Minimal PNG writer (stdlib only)."""
raw = b"".join(b"\x00" + row for row in rgb_rows)
def chunk(tag, data):
c = struct.pack(">I", len(data)) + tag + data
return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
png = b"\x89PNG\r\n\x1a\n"
png += chunk(b"IHDR", struct.pack(">IIBBBBB", WIDTH, HEIGHT, 8, 2, 0, 0, 0))
png += chunk(b"IDAT", zlib.compress(raw, 6))
png += chunk(b"IEND", b"")
open(path, "wb").write(png)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--port", default="/dev/ttyACM0")
ap.add_argument("--out", default="/home/viktor/screen.png")
args = ap.parse_args()
fd = open_port(args.port)
try:
total = WIDTH * HEIGHT * 2
data = b""
while len(data) < total:
n = min(CHUNK, total - len(data))
part = read_block(fd, CPS_ACCESS_DISPLAY_BUFFER, len(data), n)
if part is None:
print("read failed at offset %d - is the radio in modem mode?" % len(data))
return 2
data += part
finally:
os.close(fd)
rows = []
for y in range(HEIGHT):
row = bytearray()
for x in range(WIDTH):
i = (y * WIDTH + x) * 2
# RGB565, little endian as stored by the display driver
v = data[i] | (data[i + 1] << 8)
r = ((v >> 11) & 0x1F) << 3
g = ((v >> 5) & 0x3F) << 2
b = (v & 0x1F) << 3
row += bytes((r, g, b))
rows.append(bytes(row))
write_png(args.out, rows)
nonblack = sum(1 for i in range(0, len(data), 2) if data[i] or data[i + 1])
print("saved %s (%d of %d pixels non-black)" % (args.out, nonblack, WIDTH * HEIGHT))
return 0
if __name__ == "__main__":
sys.exit(main())
+117
Просмотреть файл
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Reference implementation of outgoing SMS packet assembly, mirrored from the spec.
Written on the PC first so the tricky parts (CRC-32 with its byte-pair swap, the IP and
UDP checksums, the block accounting) can be checked against a known-good packet before
any of it goes into firmware. The firmware version will be a literal translation of this.
"""
def crc32_dmr(data: bytes) -> bytes:
"""ETSI/MOTOTRBO CRC-32: swap bytes within each 16-bit pair, then MSB-first
CRC32 (poly 0x04C11DB7, init 0, no final xor). Returned little endian."""
swapped = bytearray(data)
for i in range(0, len(swapped) - 1, 2):
swapped[i], swapped[i + 1] = swapped[i + 1], swapped[i]
crc = 0
for b in swapped:
crc ^= b << 24
for _ in range(8):
crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF if (crc & 0x80000000) else ((crc << 1) & 0xFFFFFFFF)
return crc.to_bytes(4, "little")
def ones_complement(data: bytes) -> int:
if len(data) % 2:
data += b"\x00"
total = 0
for i in range(0, len(data), 2):
total += (data[i] << 8) | data[i + 1]
total = (total & 0xFFFF) + (total >> 16)
return (~total) & 0xFFFF
def crc16_ccitt(data: bytes) -> int:
crc = 0x0000
for b in data:
crc ^= b << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if (crc & 0x8000) else ((crc << 1) & 0xFFFF)
return crc
def build_tms(text: str, flag: int = 0xA0, seq: int = 0x81) -> bytes:
"""[len 2B BE][flag][00][seq][04 0D 00 0A 00][text UTF-16LE]"""
body = bytes([flag, 0x00, seq]) + bytes([0x04, 0x0D, 0x00, 0x0A, 0x00]) + text.encode("utf-16-le")
tms_len = 8 + 2 * len(text)
assert tms_len == len(body), "length mismatch: %d vs %d" % (tms_len, len(body))
return tms_len.to_bytes(2, "big") + body
def build_packet(src_id: int, dst_id: int, is_group: bool, text: str,
flag: int = 0xA0, seq: int = 0x81, ip_id: int = 0x0001) -> bytes:
tms = build_tms(text, flag, seq)
udp_len = 8 + len(tms)
src_ip = (0x0C << 24) | src_id
dst_ip = ((0xE1 if is_group else 0x0C) << 24) | dst_id
ttl = 0x01 if is_group else 0x40
udp = bytearray()
udp += (0x0FA7).to_bytes(2, "big") # source port 4007
udp += (0x0FA7).to_bytes(2, "big") # destination port 4007
udp += udp_len.to_bytes(2, "big")
udp += b"\x00\x00" # checksum placeholder
udp += tms
pseudo = src_ip.to_bytes(4, "big") + dst_ip.to_bytes(4, "big") + b"\x00\x11" + udp_len.to_bytes(2, "big")
udp_csum = ones_complement(pseudo + bytes(udp))
udp[6:8] = udp_csum.to_bytes(2, "big")
ip_len = 20 + udp_len
ip = bytearray()
ip += b"\x45\x00"
ip += ip_len.to_bytes(2, "big")
ip += ip_id.to_bytes(2, "big")
ip += b"\x00\x00"
ip += bytes([ttl, 0x11])
ip += b"\x00\x00" # header checksum placeholder
ip += src_ip.to_bytes(4, "big")
ip += dst_ip.to_bytes(4, "big")
ip[10:12] = ones_complement(bytes(ip)).to_bytes(2, "big")
packet = bytes(ip) + bytes(udp)
# pad to a multiple of 12, then append the CRC-32 as its own tail
pad = (-len(packet)) % 12
packet += b"\x00" * pad
packet += crc32_dmr(packet)
return packet, pad
def build_data_header(src_id: int, dst_id: int, is_group: bool, blocks: int, pad: int) -> bytes:
h = bytearray(12)
h[0] = (0x80 if is_group else 0x00) | 0x02 # DPF=2 unconfirmed
h[1] = 0x40 | (pad & 0x0F) # SAP=4 (IP), pad octets
h[2:5] = dst_id.to_bytes(3, "big")
h[5:8] = src_id.to_bytes(3, "big")
h[8] = 0x80 | (blocks & 0x7F)
h[9] = 0x10
crc = crc16_ccitt(bytes(h[0:10])) ^ 0xCCCC
h[10] = (crc >> 8) & 0xFF
h[11] = crc & 0xFF
return bytes(h)
if __name__ == "__main__":
text = "PRIVET"
packet, pad = build_packet(777777, 6, True, text)
blocks = len(packet) // 12
hdr = build_data_header(777777, 6, True, blocks, pad)
print("text : %r" % text)
print("packet len : %d bytes, pad %d, blocks %d" % (len(packet), pad, blocks))
print("packet hex : %s" % packet.hex(" "))
print("last 4 bytes: %s (reference ends with 92 ba 1c 62)" % packet[-4:].hex(" "))
print("data header : %s" % hdr.hex(" "))