FM100B = Sicomm SCT3258TD (документирован); Talker Alias, агент, SPI-инструменты, восстановление TX

Главное:
- Опознан DMR-чип: FM100B = Sicomm SCT3258TD. Датащит Packet Interface (126 стр) в docs/.
  Открывает калибровку/AMBE/вокодер по документации. Родня: UV-4R, китайские клоны.

Реализовано:
- Приём Talker Alias (cmd 0x60) — позывной крупно на экране (build_ta.py)
- Интерактивный агент — управление FM100B по COM без перешивки (build_agent.py + agent.py)
- Полная карта команд FM100B с живой рации (fm100b-commands.md)
- Работа с SPI: чтение/запись/восстановление codeplug и калибровки (spi_read/spi_wr/spi_restore)
- Конструктор дисплея + редактор прошивки (tools/display-editor)

Документация:
- docs/SCT3258_packet_interface.txt/.pdf — спека DMR-чипа
- docs/fm100b-commands.md — карта команд
- docs/dmr-tx-recovery.md — диагноз/восстановление DMR-передачи

Инструменты: flash_ps.py (прошивка через WSL/powershell), sniff.py (снифер кадров),
hotspot_rxoffset_sweep.py, apply_patch.py, build_* сборщики.

Важно: DMR TX-калибровка сбита в экспериментах со SET-командами модулю; восстановление
через offset-компенсацию / .updat Radtel / калибровку по прибору (docs/dmr-tx-recovery.md).
Этот коммит содержится в:
2026-07-24 02:52:51 +09:00
родитель 5f4d207aa5
Коммит 6d49ba3cba
21 изменённых файлов: 11662 добавлений и 0 удалений
+111
Просмотреть файл
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Захват кадров DMR-модуля с рации, прошитой снифером.
python3 tools/sniff.py [секунды] [--port COM4] [--raw]
Рация должна быть прошита stock-fw/rt4d_sniffer.bin и включена обычным образом.
Формат потока: F1 F2 <len> <кадр len байт>, кадр — обычный `68 cmd b sub CK CK len len … 10`.
Под WSL порт открывается через powershell.exe.
"""
import argparse, subprocess, sys
from collections import Counter
# что уже известно про команды модуля (из re/dmr.md и разбора образа FM100B)
KNOWN = {
0x05: "конфигурация канала", 0x06: "установка вызова", 0x07: "данные канала",
0x09: "разное", 0x0A: "передача/PTT", 0x25: "версия модуля",
0x3E: "обновление прошивки модуля", 0x42: "параметр", 0x49: "свой DMR ID",
0x51: "вокодер", 0x59: "активность приёма", 0x62: "параметр",
0x66: "девиация", 0x67: "компандер", 0x81: "блок SMS", 0x82: "отправка SMS",
0x84: "запрос контакта",
}
PS = r'''
$p = New-Object System.IO.Ports.SerialPort "{port}",115200,"None",8,"one"
$p.ReadTimeout = 100
try {{ $p.Open() }} catch {{ Write-Output "ERR:$($_.Exception.Message)"; exit 1 }}
$sw = [Diagnostics.Stopwatch]::StartNew()
$sb = New-Object System.Text.StringBuilder
while ($sw.Elapsed.TotalSeconds -lt {secs}) {{
try {{
$b = $p.ReadByte()
[void]$sb.Append("{{0:x2}}" -f $b)
if ($sb.Length -ge 512) {{ Write-Output $sb.ToString(); [void]$sb.Clear() }}
}} catch {{
if ($sb.Length -gt 0) {{ Write-Output $sb.ToString(); [void]$sb.Clear() }}
}}
}}
if ($sb.Length -gt 0) {{ Write-Output $sb.ToString() }}
$p.Close()
'''
def find_port():
out = subprocess.run(["powershell.exe", "-NoProfile", "-Command",
"[System.IO.Ports.SerialPort]::GetPortNames() -join ','"],
capture_output=True, text=True).stdout
ports = [p for p in out.strip().replace("\r", "").split(",") if p]
if not ports:
sys.exit("COM-порт не найден — подключите рацию.")
return ports[0]
def parse(stream: bytes):
"""выделить кадры по маркеру F1 F2 <len>"""
out, i = [], 0
while i < len(stream) - 3:
if stream[i] == 0xF1 and stream[i + 1] == 0xF2:
n = stream[i + 2]
if 9 <= n <= 220 and i + 3 + n <= len(stream):
out.append(stream[i + 3:i + 3 + n])
i += 3 + n
continue
i += 1
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("secs", nargs="?", type=int, default=30)
ap.add_argument("--port")
ap.add_argument("--raw", action="store_true", help="печатать каждый кадр")
a = ap.parse_args()
port = a.port or find_port()
print(f"[слушаю {port} {a.secs} с — начинайте передачу]", flush=True)
r = subprocess.run(["powershell.exe", "-NoProfile", "-Command",
PS.format(port=port, secs=a.secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
hexs = "".join(l.strip() for l in r.stdout.splitlines()
if l.strip() and not l.startswith("ERR:"))
if "ERR:" in r.stdout:
sys.exit(r.stdout.strip())
raw = bytes.fromhex(hexs) if hexs else b""
frames = parse(raw)
print(f"принято {len(raw)} байт, кадров {len(frames)}\n")
cnt = Counter(f[1] for f in frames if len(f) > 1)
print("команды (код — сколько раз — что известно):")
for cmd, n in cnt.most_common():
print(f" 0x{cmd:02X} x{n:<5d} {KNOWN.get(cmd,'— НЕИЗВЕСТНО —')}")
print("\nпервые кадры каждого кода:")
seen = set()
for f in frames:
if len(f) < 2 or f[1] in seen:
continue
seen.add(f[1])
pl = f[8:-1] if len(f) > 9 else b""
txt = "".join(chr(c) if 32 <= c < 127 else "." for c in pl)
print(f" 0x{f[1]:02X}: {f.hex(' ')}")
if pl:
print(f" payload {len(pl)} Б «{txt}»")
if a.raw:
print("\nвсе кадры:")
for f in frames:
print(" " + f.hex(" "))
if __name__ == "__main__":
main()