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 удалений
+8
Просмотреть файл
@@ -18,6 +18,11 @@
-**Прошивка разложена на запчасти:** два процессора + внешний SPI = три независимых канала внедрения. Карта — [`docs/firmware-parts.md`](docs/firmware-parts.md). -**Прошивка разложена на запчасти:** два процессора + внешний SPI = три независимых канала внедрения. Карта — [`docs/firmware-parts.md`](docs/firmware-parts.md).
-**Найден штатный загрузчик ресурсов** (`Unicode Download.exe`, внутри `FontVoicePicture`) — официальный путь замены **шрифтов, голосовых подсказок и картинок** (в т.ч. загрузочного логотипа) без патча MCU. -**Найден штатный загрузчик ресурсов** (`Unicode Download.exe`, внутри `FontVoicePicture`) — официальный путь замены **шрифтов, голосовых подсказок и картинок** (в т.ч. загрузочного логотипа) без патча MCU.
-**Разобран спектроанализатор REFV DualTachyon** — та самая реализация, которую хвалят пользователи. См. [`docs/refw-spectrum.md`](docs/refw-spectrum.md). -**Разобран спектроанализатор REFV DualTachyon** — та самая реализация, которую хвалят пользователи. См. [`docs/refw-spectrum.md`](docs/refw-spectrum.md).
- 🎯 **DMR-чип опознан: FM100B = Sicomm SCT3258TD** — документированный baseband. Датащит и Packet Interface (126 стр) в [`docs/SCT3258_packet_interface.txt`](docs/SCT3258_packet_interface.txt). Открывает калибровку/AMBE/вокодер по спеке. Родня: UV-4R, китайские клоны (та же связка SCT3258+AT32).
-**Приём Talker Alias** — позывной вызывающего крупно на экране (cmd `0x60`). Сборка [`tools/build_ta.py`](tools/build_ta.py).
-**Интерактивный агент** — управление FM100B через COM без перешивки ([`tools/build_agent.py`](tools/build_agent.py) + [`tools/agent.py`](tools/agent.py)); снят полный [список команд FM100B](docs/fm100b-commands.md).
-**Работа с SPI** — чтение/запись/восстановление codeplug и калибровки из дампа ([`tools/spi_read.py`](tools/spi_read.py), [`tools/spi_wr.py`](tools/spi_wr.py), [`tools/spi_restore.py`](tools/spi_restore.py)).
- ⚠️ **DMR TX-калибровка** — сбита в экспериментах, восстановление документировано в [`docs/dmr-tx-recovery.md`](docs/dmr-tx-recovery.md) (offset-компенсация / .updat Radtel / калибровка по прибору).
--- ---
@@ -91,6 +96,9 @@ python flasher/build_exe.py # -> пересобрать .exe (PyInsta
- **[docs/baseband-and-audio.md](docs/baseband-and-audio.md)** — управление DMR-баузбендом и аудио-трактом (PA2 — питание УНЧ, PA14 — гейт звука от модуля). - **[docs/baseband-and-audio.md](docs/baseband-and-audio.md)** — управление DMR-баузбендом и аудио-трактом (PA2 — питание УНЧ, PA14 — гейт звука от модуля).
- **[docs/refw-spectrum.md](docs/refw-spectrum.md)** — разбор спектроанализатора кастомной прошивки REFV DualTachyon: как вызывается, из каких функций состоит, что переносимо. - **[docs/refw-spectrum.md](docs/refw-spectrum.md)** — разбор спектроанализатора кастомной прошивки REFV DualTachyon: как вызывается, из каких функций состоит, что переносимо.
- **[docs/refw-analysis.md](docs/refw-analysis.md)**, **[docs/refw-strings.md](docs/refw-strings.md)** — анализ образа REFV DualTachyon и полная выгрузка его строк/меню. - **[docs/refw-analysis.md](docs/refw-analysis.md)**, **[docs/refw-strings.md](docs/refw-strings.md)** — анализ образа REFV DualTachyon и полная выгрузка его строк/меню.
- **[docs/SCT3258_packet_interface.txt](docs/SCT3258_packet_interface.txt)** — документация DMR-чипа Sicomm SCT3258 (= FM100B): формат пакетов, команды, калибровка, вокодер/AMBE.
- **[docs/fm100b-commands.md](docs/fm100b-commands.md)** — карта команд FM100B, снятая агентом с живой рации.
- **[docs/dmr-tx-recovery.md](docs/dmr-tx-recovery.md)** — диагноз и пути восстановления DMR-передачи (частотная калибровка).
- **[research/re/](research/re/)** — рабочие заметки по подсистемам (в т.ч. `dmr.md` — таблицы команд FM100B в обе стороны). - **[research/re/](research/re/)** — рабочие заметки по подсистемам (в т.ч. `dmr.md` — таблицы команд FM100B в обе стороны).
## История извлечения прошивки ## История извлечения прошивки
Двоичные данные
Двоичный файл не отображается.
Разница между файлами не показана из-за своего большого размера Загрузить разницу
+54
Просмотреть файл
@@ -0,0 +1,54 @@
# RT-4D: DMR не передаёт на хотспот — диагноз и восстановление
## Симптом
DMR-приём работает (рация принимает из хотспота), **DMR-передача не декодируется хотспотом**
(в логе MMDVMHost 0 «received RF», MMDVMCal рацию не видит). Аналог RX/TX работает.
Модуль отвечает в мосте, версия V1.2.0.32. Проблема одинакова на стоке и REFV.
## Корень (подтверждён реверсом + техподдержкой Radtel)
**TX-частота рации смещена > ±0.2 кГц** от номинала. По Q&A Radtel даже ±0.2 кГц не даёт
хотспоту синхронизироваться с передачей. Приём работает, т.к. RX-смещение в допуске.
Причина смещения: заводская частотная калибровка была сброшена/затёрта, а **Radio Tuning
Software «Write» залил ДЕФОЛТНЫЕ** значения смещения — неточные для конкретного экземпляра.
### Где что лежит
- **Калибровка ОСНОВНОГО тракта рации** — SPI region 0x40 (0x000000, 4 КБ). Восстановлена
из `radio-spi-dump.bin`**приём заработал**. Весь codeplug тоже восстановлен из дампа.
- **Частотная калибровка / DMR-девиация** — в NVRAM модуля FM100B. Команды модуля:
`SPCali_FreqOpt`, `SPCali_DigMod` (цифр. модуляция), `SPCali_PowerOpt`, `ATC_RfDeviation_SetReq`.
Перепрошивка кода модуля их НЕ трогает; сток при старте их НЕ шлёт → сброшенное значение держится.
### Radio Tuning Software — как устроена (reverse .NET)
- Работает через **прозрачный мост** (режим МЕНЮ), маркер кадров `0xAAAA`,
команды `CmdConnect / CmdOpenCal / CmdWriteAll / CmdEnd`.
- Калибровка = **15 частотных смещений** `nOffset_1..15` + `num_U/V_DigFreq` (DMR-частота UHF/VHF).
- Кнопки: `Read` (с рации в UI), `Write/WriteAll` (в рацию), `Save``.updat` файл.
- **ВАЖНО:** по Q&A Radtel после калибровки жать «Finish» (сохранение). Возможно Write без
этого не фиксирует. И для точной калибровки нужен **прибор** (SDR/частотомер) — крутить
offset до ±0.2 кГц.
## Пути восстановления (по приоритету)
1. **Компенсация на хотспоте (RXOffset)** — БЕЗ прибора. Если TX рации смещена на постоянный
X Гц, выставить на хотспоте `RXOffset` так, чтобы приёмник поймал смещённую передачу.
Перебор RXOffset в MMDVM.ini (±1000 Гц шагом ~100), рестарт MMDVMHost, при передаче рации
(PTT) искать «received RF» в логе. Требует, чтобы владелец жал PTT в момент перебора.
Инструмент: `tools/hotspot_rxoffset_sweep.py` (готовится).
2. **Точная калибровка частоты по прибору** — Radio Tuning Software + SDR/частотомер:
Read → крутить `num_U_DigFreq`/`nOffset` до ±0.2 кГц по прибору → **Finish/Save + WriteAll**.
3. **Заводской `.updat`** — запросить у Radtel техподдержки файл калибровки для серийника,
либо debug-софт. Тогда Radio Tuning: Open `.updat` → WriteAll.
## Что уже сделано
- ✅ SPI-калибровка рации (region 0x40) восстановлена из эталона → приём работает.
- ✅ Весь codeplug (каналы/зоны/контакты/группы/ключи/лог/SMS/FM) восстановлен из `radio-spi-dump.bin`.
- ✅ Диагноз локализован: TX-частотное смещение модуля.
- ⏳ Осталось: вернуть точное TX-смещение (пути 1–3 выше).
## Инструменты
- `tools/spi_read.py` / `tools/spi_wr.py` — чтение/запись SPI через CPS (WSL powershell).
- `tools/fm100b.py` — команды модулю через мост (режим МЕНЮ).
- `tools/agent.py` — интерактивный агент (обычный режим).
+57
Просмотреть файл
@@ -0,0 +1,57 @@
# FM100B — карта команд (снята интерактивным агентом)
Снято `tools/agent.py` на прошивке `rt4d_agent.bin` — запрос каждой команды пустым
payload (`68 cmd 01 01 CK CK 00 00 10`), ответ модуля через снифер. Рация V1.2.0.32.
`status` (байт [3] ответа): **0x00** = OK/есть данные, **0x01** = не поддержано или нужен payload.
## Отвечают данными (status 0x00, len>0)
| cmd | len | ответ | смысл |
|---|---|---|---|
| `0x04` | 1 | `03` | параметр |
| `0x24` | 4 | `01 00 40 01` | возможности/идентификатор модуля |
| `0x25` | 4 | `01 02 00 32` | **версия прошивки = V1.2.0.32** |
| `0x27` | 1 | `00` | статус |
| `0x28` | 1 | `00` | статус |
| `0x34` | 2 | `00 00` | параметр (2 байта) |
| **`0x35`** | 35 | `10 02 21 6a df 62 e1 4f 53 90 38 …` | **таблица** (записи с сигнатурой `e1 4f 53 90`) |
| `0x36` | 1 | `00` | статус |
| `0x3B` | 1 | `01` | флаг |
| **`0x43`** | 33 | `10 01 01 70 65 57 5b e1 4f 53 90 31 … 32 …` | **таблица**: две записи `…e1 4f 53 90` + индекс 1/2 (контакты/RX-группы?) |
| `0x46` | 1 | `00` | статус |
| `0x85` | 1 | `00` | статус |
## Приняты без данных (status 0x00, len 0) — действия/OK
`0x01 0x03 0x1A 0x40 0x4B 0x65 0x66`
(`0x66` = девиация ш/у полосы по changelog; `0x65` рядом — вероятно связанное)
## Не поддержано / нужен payload (status 0x01)
`0x0D 0x0E 0x10-0x19 0x22 0x2B 0x2C 0x2D 0x30 0x37 0x3A 0x3C 0x3D 0x3F 0x41 0x44 0x45
0x47 0x4A 0x4E 0x4F 0x50 0x51 0x52 0x54 0x56 0x58 0x5C 0x5D 0x5E 0x60 0x61 0x63 0x64
0x67 0x83 0x84 0x86 0x87`
(это SET/enable-команды — на пустой запрос отвечают отказом; проверять с payload)
## Не опрошены (опасные SET — меняют состояние)
`0x05` конфиг канала · `0x06` вызов · `0x07` данные канала · `0x0A` PTT ·
`0x49` свой DMR ID · `0x62` параметры вызова · `0x81/0x82` SMS · `0x2A 0x42 0x48
0x4C 0x4D 0x55 0x57` · **`0x3E` прошивка модуля — НЕ СЛАТЬ**
## Известны из снифера входящего вызова (FM100B → MCU)
| cmd | смысл |
|---|---|
| `0x62` | параметры вызова (src/dst/TG) |
| `0x06` | setup (sub 0x60) / end (sub 0x6f) |
| `0x60` | **Talker Alias (позывной)** — см. docs/talker-alias |
| `0x59` | активность приёма (гейт звука PA14) |
| `0x05` | конфигурация канала |
## Открыто
- **AMBE-выдача** (для модема): по умолчанию нет. Кандидаты — SET-команды со `status 0x01`,
проверять с payload `enable` + активный вызов + наблюдение потока через агента.
- Таблицы `0x35`/`0x43`: расшифровать формат записи (сигнатура `e1 4f 53 90`).
+120
Просмотреть файл
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Клиент интерактивного агента RT-4D (прошивка rt4d_agent.bin).
Шлёт рации `0xAA <len> <сырой FM100B-кадр>`, агент блитит кадр модулю, ответы
модуля возвращаются в порт как `F1 F2 <len> <кадр>`. Работает через powershell.exe (WSL).
python3 tools/agent.py send 25 01 01 # cmd=25 b=01 sub=01, payload пуст -> версия
python3 tools/agent.py send 25 01 01 --data 00
python3 tools/agent.py raw 68 25 01 01 00 00 00 00 10 # готовый кадр целиком
python3 tools/agent.py listen 10 # просто слушать порт N секунд
"""
import argparse, subprocess, sys, struct
def cksum(frame: bytes) -> int:
s = 0; i = 0
while len(frame) - i > 1:
s += (frame[i] << 8) | frame[i + 1]; i += 2
if len(frame) - i:
s += frame[i] << 8
while s >> 16:
s = (s & 0xFFFF) + (s >> 16)
return (~s) & 0xFFFF
def build_frame(cmd, b, sub, payload: bytes) -> bytes:
f = bytearray([0x68, cmd, b, sub, 0xFF, 0xFF,
(len(payload) >> 8) & 0xFF, len(payload) & 0xFF])
f += payload; f.append(0x10)
ck = cksum(bytes(f)); f[4], f[5] = (ck >> 8) & 0xFF, ck & 0xFF
return bytes(f)
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]
PS = r'''
$p = New-Object System.IO.Ports.SerialPort "{port}",115200,"None",8,"one"
$p.ReadTimeout = 200
try {{ $p.Open() }} catch {{ Write-Output "ERR"; exit 1 }}
$p.DiscardInBuffer()
$tx = [byte[]]@({txlist})
if ($tx.Length -gt 0) {{ $p.Write($tx,0,$tx.Length) }}
$sb = New-Object System.Text.StringBuilder
$sw = [Diagnostics.Stopwatch]::StartNew()
$last = 0
while ($sw.Elapsed.TotalSeconds -lt {secs}) {{
try {{ [void]$sb.Append("{{0:x2}}" -f $p.ReadByte()); $last=$sw.ElapsedMilliseconds }} catch {{}}
if ($sb.Length -gt 0 -and ($sw.ElapsedMilliseconds - $last) -gt {idle}) {{ break }}
}}
$p.Close()
Write-Output $sb.ToString()
'''
def xfer(port, tx: bytes, secs=3, idle=400):
txlist = ",".join(str(x) for x in tx)
r = subprocess.run(["powershell.exe", "-NoProfile", "-Command",
PS.format(port=port, txlist=txlist, secs=secs, idle=idle)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
hexs = "".join(l.strip() for l in r.stdout.splitlines() if l.strip() and l.strip() != "ERR")
return bytes.fromhex(hexs) if hexs else b""
def parse_frames(raw: bytes):
out, i = [], 0
while i < len(raw) - 2:
if raw[i] == 0xF1 and raw[i + 1] == 0xF2:
n = raw[i + 2]
if 9 <= n <= 220 and i + 3 + n <= len(raw):
out.append(raw[i + 3:i + 3 + n]); i += 3 + n; continue
i += 1
return out
def show(raw):
print(f"принято {len(raw)} байт")
frames = parse_frames(raw)
if not frames:
if raw: print("сырые байты:", raw.hex(' '))
else: print("(тишина)")
return
for f in frames:
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" cmd 0x{f[1]:02X} len={len(pl)} {f.hex(' ')}" + (f" «{txt}»" if pl else ""))
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="op", required=True)
s = sub.add_parser("send"); s.add_argument("cmd"); s.add_argument("b"); s.add_argument("sub")
s.add_argument("--data", default=""); s.add_argument("--secs", type=float, default=3)
r = sub.add_parser("raw"); r.add_argument("bytes", nargs="+"); r.add_argument("--secs", type=float, default=3)
l = sub.add_parser("listen"); l.add_argument("secs", type=float, nargs="?", default=10)
ap.add_argument("--port")
a = ap.parse_args()
port = a.port or find_port()
if a.op == "listen":
show(xfer(port, b"", secs=a.secs, idle=99999))
return
if a.op == "raw":
frame = bytes(int(x, 16) for x in a.bytes)
else:
payload = bytes.fromhex(a.data) if a.data else b""
frame = build_frame(int(a.cmd, 16), int(a.b, 16), int(a.sub, 16), payload)
tx = bytes([0xAA, len(frame)]) + frame
print(f"TX кадр модулю: {frame.hex(' ')}")
show(xfer(port, tx, secs=a.secs))
if __name__ == "__main__":
main()
+87
Просмотреть файл
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Применить патч из редактора прошивки к образу.
python3 apply_patch.py patch.json [--src stock-fw/rt4d_ru_batt.bin]
[--out stock-fw/rt4d_custom.bin]
Формат патча (его выдаёт кнопка «Скопировать патч»):
{"texts":[{"va":134370285,"txt":"ОСН. НАСТР"}, ...],
"logo":{"va":134500045,"b64":"..."}}
Текст пишется в CP1251 и добивается пробелами ровно до исходной ширины поля —
записи в прошивке фиксированной длины, сдвигать их нельзя.
"""
import argparse, base64, json, sys
BASE = 0x08002800
# доступные глифы: ASCII + 33 заглавные кириллические буквы, дописанные при русификации
CYR = "АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯЁ"
def widths(src_len):
"""допустимые ширины полей — берём из таблиц редактора"""
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("patch")
ap.add_argument("--src", default="stock-fw/rt4d_ru_batt.bin")
ap.add_argument("--out", default="stock-fw/rt4d_custom.bin")
ap.add_argument("--meta", default="tools/display-editor/field-widths.json",
help="ширины полей (va -> max), пишется при сборке редактора")
a = ap.parse_args()
patch = json.load(open(a.patch, encoding="utf-8"))
img = bytearray(open(a.src, "rb").read())
try:
W = {int(k): v for k, v in json.load(open(a.meta, encoding="utf-8")).items()}
except Exception:
sys.exit(f"нет файла ширин полей {a.meta} — пересоберите редактор")
bad = []
n = 0
for t in patch.get("texts", []):
va, txt = t["va"], t["txt"]
if va not in W:
bad.append(f"неизвестный адрес 0x{va:08X}")
continue
w = W[va]
for ch in txt:
if ch not in CYR and not (0x20 <= ord(ch) <= 0x7E):
bad.append(f"0x{va:08X}: нет глифа для «{ch}»")
if len(txt) > w:
bad.append(f"0x{va:08X}: «{txt}» длиннее поля ({len(txt)} > {w})")
continue
try:
enc = txt.encode("cp1251")
except UnicodeEncodeError as e:
bad.append(f"0x{va:08X}: не кодируется в CP1251 ({e})")
continue
img[va - BASE: va - BASE + w] = enc.ljust(w, b" ")
n += 1
lg = patch.get("logo")
if lg:
raw = base64.b64decode(lg["b64"])
if len(raw) != 288:
bad.append(f"логотип: ожидалось 288 байт, пришло {len(raw)}")
else:
va = lg["va"]
img[va - BASE: va - BASE + 288] = raw
print("логотип обновлён (288 Б)")
if bad:
print("ОШИБКИ:")
for b in bad:
print(" " + b)
if not n:
sys.exit(1)
open(a.out, "wb").write(bytes(img))
print(f"применено текстов: {n}")
print(f"готово: {a.out}, {len(img)} Б")
if __name__ == "__main__":
main()
+249
Просмотреть файл
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Интерактивный агент в прошивке: управление FM100B через COM-порт с ПК, без перешивки.
Протокол (ПК -> рация по USART6):
0xAA <len> <len байт «сырого» FM100B-кадра>
агент блитит эти len байт в USART3 (модулю). Ответы модуля идут обратно в порт
через снифер-хук on_frame (кадр: F1 F2 <len> <кадр>). Так я с ПК шлю любой кадр
и сразу вижу реакцию — перебор команд (AMBE, record и пр.) вживую.
Приём с порта — по байту за тик в главном цикле (не блокирует UI, в отличие от зонда).
CPS одновременно не подключать. Основа rt4d_ru_batt.bin, откат — она же.
python3 build_agent.py
python3 tools/agent.py send 25 01 01 # спросить версию модуля
python3 tools/agent.py raw 68 25 01 01 00 00 00 00 10
"""
import struct
from keystone import Ks, KS_ARCH_ARM, KS_MODE_THUMB
BASE = 0x08002800
SRC = "stock-fw/rt4d_ru_batt.bin"
OUT = "stock-fw/rt4d_agent.bin"
ON_FRAME = 0x08006348
FRAME_CALL = 0x08018DD8
UI_HOOK = 0x08020806
TICK_FUNC = 0x0801DFD0
TX_BYTE = 0x08018BFC # usart6_tx_byte(u8)
USART3_TXBUF = 0x08006C9C # usart3_tx_buf(buf,len)
# приём CPS идёт через USART6-ISR, который складывает байты в это кольцо.
# Читаем кольцо по своему указателю — порт/ISR не трогаем, CPS не ломаем.
RING_BUF = 0x200082EF # 4K кольцо RX (маска 0xFFF)
RING_HEAD = 0x20000C60 # u32, растущий индекс записи (из ISR)
MAGIC_ADDR = 0x2000B000
MAGIC_VAL = 0x41474E54 # 'AGNT'
ST_STATE = 0x2000B040 # 0=ждём AA, 1=ждём len, 2=собираем
ST_LEN = 0x2000B041
ST_IDX = 0x2000B042
ST_BUF = 0x2000B044 # до 64 байт -> 0x2000B084
MY_TAIL = 0x2000B088 # u32, наш указатель чтения кольца
EQ, NE, HS, LS, LO = 0, 1, 2, 9, 3
def thumb_bl(src, dst):
off = (dst - (src + 4)) >> 1
s=(off>>23)&1; i1=(off>>22)&1; i2=(off>>21)&1
imm10=(off>>11)&0x3FF; imm11=off&0x7FF
j1=(~i1^s)&1; j2=(~i2^s)&1
return struct.pack("<HH",0xF000|(s<<10)|imm10,0xD000|(j1<<13)|(j2<<11)|imm11)
class Asm:
def __init__(self, ks, base): self.ks=ks; self.base=base; self.items=[]
def asm(self,c): self.items.append(("a",c))
def label(self,n): self.items.append(("l",n))
def b(self,t,cond=None): self.items.append(("b",cond,t))
def _sz(self,it):
if it[0]=="a": return len(bytes(self.ks.asm(it[1],0)[0]))
if it[0]=="b": return 2
return 0
def build(self):
pos={}; off=0
for it in self.items:
if it[0]=="l": pos[it[1]]=off
off+=self._sz(it)
out=bytearray(); off=0
for it in self.items:
if it[0]=="a": out+=bytes(self.ks.asm(it[1],self.base+off)[0])
elif it[0]=="b":
_,cond,tgt=it; d=(self.base+pos[tgt])-(self.base+off+4)
out+= struct.pack("<H",0xE000|((d>>1)&0x7FF)) if cond is None \
else struct.pack("<H",0xD000|(cond<<8)|((d>>1)&0xFF))
off+=self._sz(it)
return bytes(out)
def main():
img=bytearray(open(SRC,"rb").read())
end=BASE+len(img); code_at=(end+3)&~3
img+=b"\xff"*(code_at-end)
ks=Ks(KS_ARCH_ARM,KS_MODE_THUMB)
def lo(a): return a&0xFFFF
def hi(a): return (a>>16)&0xFFFF
# ---- agent_poll: читать кольцо RX по своему указателю ----
# r7 = my_tail (u32). Пока my_tail != head — брать ring[my_tail & 0xFFF].
poll_at=code_at
a=Asm(ks,poll_at)
a.asm(f"""
push {{r4, r5, r6, r7, lr}}
movw r7, #{lo(MY_TAIL)}
movt r7, #{hi(MY_TAIL)}
ldr r7, [r7]
""")
a.label("ploop")
# head?
a.asm(f"""
movw r0, #{lo(RING_HEAD)}
movt r0, #{hi(RING_HEAD)}
ldr r0, [r0]
cmp r7, r0
""")
a.b("pret", EQ)
# byte = ring[my_tail & 0xFFF]; my_tail++
a.asm(f"""
movw r0, #{lo(RING_BUF)}
movt r0, #{hi(RING_BUF)}
ubfx r1, r7, #0, #12
ldrb r6, [r0, r1]
adds r7, #1
movw r4, #{lo(ST_STATE)}
movt r4, #{hi(ST_STATE)}
ldrb r5, [r4]
cmp r5, #0
""")
a.b("s_wait_aa", EQ)
a.asm("cmp r5, #1")
a.b("s_len", EQ)
# state 2: собираем в буфер
a.asm(f"""
movw r0, #{lo(ST_IDX)}
movt r0, #{hi(ST_IDX)}
ldrb r1, [r0]
movw r2, #{lo(ST_BUF)}
movt r2, #{hi(ST_BUF)}
strb r6, [r2, r1]
adds r1, #1
strb r1, [r0]
movw r0, #{lo(ST_LEN)}
movt r0, #{hi(ST_LEN)}
ldrb r0, [r0]
cmp r1, r0
""")
a.b("ploop", LO) # idx<len? продолжаем; когда idx==len — отправляем
# idx==len -> отправить в USART3
a.asm(f"""
movw r0, #{lo(ST_BUF)}
movt r0, #{hi(ST_BUF)}
movw r1, #{lo(ST_LEN)}
movt r1, #{hi(ST_LEN)}
ldrb r1, [r1]
bl #{USART3_TXBUF}
movs r0, #0
strb r0, [r4]
""")
a.b("ploop")
# state 0: ждём 0xAA
a.label("s_wait_aa")
a.asm("cmp r6, #0xAA")
a.b("ploop", NE)
a.asm("movs r0, #1\nstrb r0, [r4]")
a.b("ploop")
# state 1: принять len
a.label("s_len")
a.asm(f"""
movw r0, #{lo(ST_LEN)}
movt r0, #{hi(ST_LEN)}
strb r6, [r0]
movw r0, #{lo(ST_IDX)}
movt r0, #{hi(ST_IDX)}
movs r1, #0
strb r1, [r0]
movs r0, #2
strb r0, [r4]
""")
a.b("ploop")
a.label("pret")
a.asm(f"""
movw r0, #{lo(MY_TAIL)}
movt r0, #{hi(MY_TAIL)}
str r7, [r0]
pop {{r4, r5, r6, r7, pc}}
""")
poll=a.build()
# ---- tick_router: init магии один раз + agent_poll каждый тик ----
tick_at=poll_at+len(poll)
r=Asm(ks,tick_at)
r.asm(f"""
push {{lr}}
bl #{TICK_FUNC}
movw r0, #{lo(MAGIC_ADDR)}
movt r0, #{hi(MAGIC_ADDR)}
ldr r1, [r0]
movw r2, #{lo(MAGIC_VAL)}
movt r2, #{hi(MAGIC_VAL)}
cmp r1, r2
""")
r.b("inited", EQ)
r.asm(f"""
str r2, [r0]
movw r0, #{lo(ST_STATE)}
movt r0, #{hi(ST_STATE)}
movs r1, #0
strb r1, [r0]
movw r0, #{lo(RING_HEAD)}
movt r0, #{hi(RING_HEAD)}
ldr r1, [r0]
movw r0, #{lo(MY_TAIL)}
movt r0, #{hi(MY_TAIL)}
str r1, [r0]
""")
r.label("inited")
r.asm(f"bl #{poll_at | 1}\npop {{pc}}")
tick=r.build()
# ---- снифер on_frame ----
hook_at=tick_at+len(tick)
h=Asm(ks,hook_at)
h.asm(f"""
push {{r4, r5, r6, lr}}
mov r4, r0
ldrb r5, [r4, #6]
ldrb r6, [r4, #7]
orr.w r5, r6, r5, lsl #8
adds r5, #9
uxtb r5, r5
movs r0, #0xF1
bl #{TX_BYTE}
movs r0, #0xF2
bl #{TX_BYTE}
mov r0, r5
bl #{TX_BYTE}
movs r6, #0
""")
h.label("sloop")
h.asm("cmp r6, r5")
h.b("sdone", HS)
h.asm(f"ldrb r0, [r4, r6]\nbl #{TX_BYTE}\nadds r6, #1")
h.b("sloop")
h.label("sdone")
h.asm(f"mov r0, r4\nbl #{ON_FRAME}\npop {{r4, r5, r6, pc}}")
hook=h.build()
code=poll+tick+hook
img+=code
while len(img)%4: img.append(0xFF)
print(f"poll 0x{poll_at:08X} ({len(poll)}Б), tick 0x{tick_at:08X}, hook 0x{hook_at:08X}")
img[UI_HOOK-BASE:UI_HOOK-BASE+4]=thumb_bl(UI_HOOK,tick_at)
img[FRAME_CALL-BASE:FRAME_CALL-BASE+4]=thumb_bl(FRAME_CALL,hook_at)
open(OUT,"wb").write(bytes(img))
print(f"готово: {OUT}, {len(img)} Б")
if __name__=="__main__":
main()
+161
Просмотреть файл
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Напряжение батареи вместо штатной иконки + уборка полосы у всплывающего окна.
Патчи:
1. Свой код в свободном месте после конца образа (0x08029000):
stub — пустышка `bx lr`
batt_draw — рисует "8.4V" мелким шрифтом 5x8 на странице 0
tick_router — вызывает оригинал, затем batt_draw (только на главном экране)
2. `bl 0x08008300` @0x080148E2 — отрисовка штатной шкалы -> stub (иконки больше нет).
3. `bl 0x08010A00` @0x0801E2A4 — «напряжение -> индекс 0..3» убрано (2 x nop),
чтобы флаг перерисовки взводился на каждые 0.1 В, а не только на порогах.
4. `bl 0x0800842C` @0x080085DE — верхняя полоса рамки всплывающего окна убрана.
5. `bl 0x0801DFD0` @0x08020806 — последний вызов цепочки отрисовки в ui_tick_normal
перенаправлен на tick_router.
Почему через конец цикла: раньше мы рисовали из середины кадра (из цепочки обновления
батареи), и любой более поздний вывод затирал цифры — при входящем вызове «7» теряла
перекладину и читалась как «1». Теперь наш текст ложится последним и перекрывает всё.
"""
import struct
from keystone import Ks, KS_ARCH_ARM, KS_MODE_THUMB
BASE = 0x08002800
SRC = "stock-fw/rt4d_ru_full.bin"
OUT = "stock-fw/rt4d_ru_batt.bin"
SMALL_CHAR = 0x080089AC # void small_char(u8 page, u8 x, u8 ch, u8 dim)
BATT_VOLT = 0x200008B0 # u16, напряжение * 10
G_SCREEN = 0x200008B3 # u8, текущий экран (0 = главный)
GAUGE_CALL = 0x080148E2 # bl 0x08008300 — штатная шкала батареи
GAUGE_FUNC = 0x08008300
LEVEL_CALL = 0x0801E2A4 # bl 0x08010A00 — напряжение -> индекс
LEVEL_FUNC = 0x08010A00
TOPBAR_CALL = 0x080085DE # bl fill_col_run(page=1) — верхняя полоса рамки
FILL_RUN = 0x0800842C
TICK_CALL = 0x08020806 # bl 0x0801DFD0 — последний вызов отрисовки за кадр
TICK_FUNC = 0x0801DFD0
MYCODE = 0x08029000
X0, X1, X2, X3 = 108, 113, 118, 123 # позиции четырёх символов
def thumb_bl(src: int, dst: int) -> bytes:
off = dst - (src + 4)
assert -(1 << 24) <= off < (1 << 24), "цель BL слишком далеко"
off >>= 1
s = (off >> 23) & 1
i1 = (off >> 22) & 1
i2 = (off >> 21) & 1
imm10 = (off >> 11) & 0x3FF
imm11 = off & 0x7FF
j1 = (~i1 ^ s) & 1
j2 = (~i2 ^ s) & 1
return struct.pack("<HH", 0xF000 | (s << 10) | imm10,
0xD000 | (j1 << 13) | (j2 << 11) | imm11)
def main():
img = bytearray(open(SRC, "rb").read())
print(f"исходный образ: {len(img)} Б, конец на 0x{BASE + len(img) - 1:08X}")
assert BASE + len(img) == MYCODE, "образ не заканчивается там, где ждём код"
ks = Ks(KS_ARCH_ARM, KS_MODE_THUMB)
def check(addr, target, what):
o = addr - BASE
cur = bytes(img[o:o + 4])
assert cur == thumb_bl(addr, target), \
f"по 0x{addr:08X} ({what}) не тот вызов: {cur.hex(' ')}"
return o
o_gauge = check(GAUGE_CALL, GAUGE_FUNC, "шкала батареи")
o_level = check(LEVEL_CALL, LEVEL_FUNC, "напряжение->индекс")
o_bar = check(TOPBAR_CALL, FILL_RUN, "верхняя полоса рамки")
o_tick = check(TICK_CALL, TICK_FUNC, "хвост отрисовки")
print("все четыре точки патча подтверждены")
# ---- раскладка своего кода ----
stub_addr = MYCODE
stub = bytes(ks.asm("bx lr", stub_addr)[0])
batt_addr = stub_addr + len(stub)
batt_asm = f"""
push {{r4, r5, r6, lr}}
movw r4, #{BATT_VOLT & 0xFFFF}
movt r4, #{(BATT_VOLT >> 16) & 0xFFFF}
ldrh r4, [r4]
uxtb r4, r4
movs r1, #205
muls r1, r4, r1
lsrs r5, r1, #11
movs r1, #10
muls r1, r5, r1
subs r4, r4, r1
movs r0, #0
movs r1, #{X0}
mov r2, r5
add r2, #0x30
movs r3, #0
bl #{SMALL_CHAR}
movs r0, #0
movs r1, #{X1}
movs r2, #0x2E
movs r3, #0
bl #{SMALL_CHAR}
movs r0, #0
movs r1, #{X2}
mov r2, r4
add r2, #0x30
movs r3, #0
bl #{SMALL_CHAR}
movs r0, #0
movs r1, #{X3}
movs r2, #0x56
movs r3, #0
bl #{SMALL_CHAR}
pop {{r4, r5, r6, pc}}
"""
batt = bytes(ks.asm(batt_asm, batt_addr)[0])
# tick_router: оригинал -> проверка экрана -> batt_draw.
# keystone не умеет метки, поэтому условный переход кодируем вручную.
tick_addr = batt_addr + len(batt)
partA = bytes(ks.asm(f"""
push {{r4, lr}}
bl #{TICK_FUNC}
movw r4, #{G_SCREEN & 0xFFFF}
movt r4, #{(G_SCREEN >> 16) & 0xFFFF}
ldrb r4, [r4]
cmp r4, #0
""", tick_addr)[0])
bne_addr = tick_addr + len(partA)
partB = bytes(ks.asm(f"bl #{batt_addr}", bne_addr + 2)[0])
bne = struct.pack("<H", 0xD100 | (((len(partB) - 2) // 2) & 0xFF)) # bne -> за partB
partC = bytes(ks.asm("pop {r4, pc}", bne_addr + 2 + len(partB))[0])
tick = partA + bne + partB + partC
code = stub + batt + tick
print(f"свой код: {len(code)} Б (stub 0x{stub_addr:08X}, "
f"batt_draw 0x{batt_addr:08X}, tick_router 0x{tick_addr:08X})")
img += code
while len(img) % 4:
img.append(0xFF)
# ---- патчи ----
img[o_gauge:o_gauge + 4] = thumb_bl(GAUGE_CALL, stub_addr)
print("штатная шкала батареи отключена")
img[o_level:o_level + 4] = b"\x00\xbf\x00\xbf"
print("преобразование в индекс убрано — перерисовка на каждые 0.1 В")
img[o_bar:o_bar + 4] = b"\x00\xbf\x00\xbf"
print("верхняя полоса рамки убрана")
img[o_tick:o_tick + 4] = thumb_bl(TICK_CALL, tick_addr)
print("вывод напряжения перевешен на конец кадра")
open(OUT, "wb").write(bytes(img))
print(f"готово: {OUT}, {len(img)} Б")
if __name__ == "__main__":
main()
+213
Просмотреть файл
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""Зонд достижимости AMBE: при старте шлёт FM100B «enable» по диапазону команд,
затем работает обычной рацией + снифер логирует всё, что модуль отдаёт.
Идея: если какая-то из перебираемых команд включает выдачу голосового AMBE
(модемный примитив ATC_RecordEn/RecvAmbe), то при входящем вызове в порт пойдёт
поток ~27-байтовых кадров, которых в стоке нет. Диапазон сужаем бинарно, меняя LIST.
python3 build_probe.py "0x40-0x6F" # первый прогон — весь диапазон
python3 build_probe.py "0x50-0x5F" # сузить
python3 tools/sniff.py 30 --raw # смотреть поток при вызове
Безопасность: исключены 0x3E (прошивка модуля) и уже используемые команды.
Основа rt4d_ru_batt.bin; откат — она же.
"""
import struct, sys
from keystone import Ks, KS_ARCH_ARM, KS_MODE_THUMB
BASE = 0x08002800
SRC = "stock-fw/rt4d_ru_batt.bin"
OUT = "stock-fw/rt4d_probe.bin"
ON_FRAME = 0x08006348
FRAME_CALL = 0x08018DD8
UI_HOOK = 0x08020806
TICK_FUNC = 0x0801DFD0
FM_SEND1 = 0x0801B044 # void fm100b_send1(cmd,b,sub,data, respbuf, timeout)
TX_BYTE = 0x08018BFC # порт к ПК
MAGIC_ADDR = 0x2000B000
MAGIC_VAL = 0x50524F42 # 'PROB'
RESP_BUF = 0x2000B020 # scratch для respbuf fm100b_send1
USED = {0x3E, 0x42, 0x48, 0x49, 0x4C, 0x4D, 0x55, 0x57, 0x62,
0x05, 0x06, 0x07, 0x09, 0x0A, 0x25, 0x59, 0x60}
EQ, NE, HS, LS = 0, 1, 2, 9
def parse_range(s):
a, b = s.split("-")
lo, hi = int(a, 16), int(b, 16)
return [c for c in range(lo, hi + 1) if c not in USED]
def thumb_bl(src, dst):
off = (dst - (src + 4)) >> 1
s = (off >> 23) & 1; i1 = (off >> 22) & 1; i2 = (off >> 21) & 1
imm10 = (off >> 11) & 0x3FF; imm11 = off & 0x7FF
j1 = (~i1 ^ s) & 1; j2 = (~i2 ^ s) & 1
return struct.pack("<HH", 0xF000 | (s << 10) | imm10,
0xD000 | (j1 << 13) | (j2 << 11) | imm11)
class Asm:
def __init__(self, ks, base): self.ks=ks; self.base=base; self.items=[]
def asm(self, c): self.items.append(("a", c))
def label(self, n): self.items.append(("l", n))
def b(self, t, cond=None): self.items.append(("b", cond, t))
def raw(self, bs): self.items.append(("r", bs))
def _sz(self, it):
if it[0]=="a": return len(bytes(self.ks.asm(it[1],0)[0]))
if it[0]=="b": return 2
if it[0]=="r": return len(it[1])
return 0
def build(self):
pos={}; off=0
for it in self.items:
if it[0]=="l": pos[it[1]]=off
off+=self._sz(it)
out=bytearray(); off=0
for it in self.items:
if it[0]=="a": out+=bytes(self.ks.asm(it[1], self.base+off)[0])
elif it[0]=="r": out+=it[1]
elif it[0]=="b":
_,cond,tgt=it; d=(self.base+pos[tgt])-(self.base+off+4)
if cond is None: out+=struct.pack("<H",0xE000|((d>>1)&0x7FF))
else: out+=struct.pack("<H",0xD000|(cond<<8)|((d>>1)&0xFF))
off+=self._sz(it)
return bytes(out)
def main():
rng = sys.argv[1] if len(sys.argv) > 1 else "0x40-0x6F"
cand = parse_range(rng)
print(f"диапазон {rng} -> {len(cand)} кандидатов: {' '.join('%02X'%c for c in cand)}")
img = bytearray(open(SRC, "rb").read())
end = BASE + len(img); code_at = (end+3)&~3
img += b"\xff"*(code_at-end)
ks = Ks(KS_ARCH_ARM, KS_MODE_THUMB)
def lo(a): return a & 0xFFFF
def hi(a): return (a>>16)&0xFFFF
# таблица кандидатов (байты) + завершающий 0x00
tbl = bytes(cand) + b"\x00"
# ---- probe_once: разослать enable всем кандидатам ----
# табличка лежит сразу после кода; адрес вычислим после сборки — двухпроходно:
# проще положить таблицу ПЕРЕД функцией и адресовать movw.
tbl_at = code_at
probe_at = tbl_at + len(tbl)
if probe_at & 1: probe_at += 1 # выравнивание для кода
pad = probe_at - (tbl_at + len(tbl))
p = Asm(ks, probe_at)
p.asm(f"""
push {{r4, r5, lr}}
movw r4, #{lo(tbl_at)}
movt r4, #{hi(tbl_at)}
""")
p.label("ploop")
p.asm("ldrb r5, [r4]\ncmp r5, #0")
p.b("pdone", EQ)
# маркер в порт: F3 F3 cmd
p.asm(f"""
movs r0, #0xF3
bl #{TX_BYTE}
movs r0, #0xF3
bl #{TX_BYTE}
mov r0, r5
bl #{TX_BYTE}
""")
# fm100b_send1(cmd=r5, b=1, sub=1, data=1, respbuf, timeout=0x64)
p.asm(f"""
sub sp, #8
movw r0, #{lo(RESP_BUF)}
movt r0, #{hi(RESP_BUF)}
str r0, [sp]
movs r0, #0x64
str r0, [sp, #4]
mov r0, r5
movs r1, #1
movs r2, #1
movs r3, #1
bl #{FM_SEND1}
add sp, #8
adds r4, #1
""")
p.b("ploop")
p.label("pdone")
p.asm("pop {r4, r5, pc}")
probe = p.build()
# ---- tick_router: один раз вызвать probe_once ----
tick_at = probe_at + len(probe)
r = Asm(ks, tick_at)
r.asm(f"""
push {{lr}}
bl #{TICK_FUNC}
movw r0, #{lo(MAGIC_ADDR)}
movt r0, #{hi(MAGIC_ADDR)}
ldr r1, [r0]
movw r2, #{lo(MAGIC_VAL)}
movt r2, #{hi(MAGIC_VAL)}
cmp r1, r2
""")
r.b("done", EQ)
r.asm(f"""
str r2, [r0]
bl #{probe_at | 1}
""")
r.label("done")
r.asm("pop {pc}")
tick = r.build()
# ---- ta_hook = снифер: F1 F2 len + кадр + passthrough ----
hook_at = tick_at + len(tick)
h = Asm(ks, hook_at)
h.asm(f"""
push {{r4, r5, r6, lr}}
mov r4, r0
ldrb r5, [r4, #6]
ldrb r6, [r4, #7]
orr.w r5, r6, r5, lsl #8
adds r5, #9
uxtb r5, r5
movs r0, #0xF1
bl #{TX_BYTE}
movs r0, #0xF2
bl #{TX_BYTE}
mov r0, r5
bl #{TX_BYTE}
movs r6, #0
""")
h.label("sloop")
h.asm("cmp r6, r5")
h.b("sdone", HS)
h.asm(f"""
ldrb r0, [r4, r6]
bl #{TX_BYTE}
adds r6, #1
""")
h.b("sloop")
h.label("sdone")
h.asm(f"""
mov r0, r4
bl #{ON_FRAME}
pop {{r4, r5, r6, pc}}
""")
hook = h.build()
code = tbl + b"\x00"*pad + probe + tick + hook
img += code
while len(img) % 4: img.append(0xFF)
print(f"таблица 0x{tbl_at:08X}, probe 0x{probe_at:08X}, tick 0x{tick_at:08X}, hook 0x{hook_at:08X}")
img[UI_HOOK-BASE:UI_HOOK-BASE+4] = thumb_bl(UI_HOOK, tick_at)
img[FRAME_CALL-BASE:FRAME_CALL-BASE+4] = thumb_bl(FRAME_CALL, hook_at)
open(OUT,"wb").write(bytes(img))
print(f"готово: {OUT}, {len(img)} Б")
if __name__ == "__main__":
main()
+99
Просмотреть файл
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Диагностическая сборка: зеркалит все кадры от DMR-модуля в USB-порт.
Перехватывается единственный вызов `fm100b_on_frame` (0x08006348) из разборщика
кадров. Наша функция выдаёт в компьютерный порт маркер F1 F2, длину и сам кадр,
после чего передаёт управление штатному обработчику — логика радио не меняется.
python3 build_sniffer.py # -> stock-fw/rt4d_sniffer.bin
python3 tools/flash_ps.py stock-fw/rt4d_sniffer.bin
python3 tools/sniff.py 60 # слушать 60 секунд
Сборка временная: для обычной работы вернуть stock-fw/rt4d_ru_batt.bin.
"""
import struct
from keystone import Ks, KS_ARCH_ARM, KS_MODE_THUMB
BASE = 0x08002800
SRC = "stock-fw/rt4d_ru_batt.bin"
OUT = "stock-fw/rt4d_sniffer.bin"
ON_FRAME = 0x08006348 # void fm100b_on_frame(u8 *frame)
CALL_SITE = 0x08018DD8 # единственный bl на него
TX_BYTE = 0x08018BFC # void usart6_tx_byte(u8) — порт к компьютеру
def thumb_bl(src, dst):
off = (dst - (src + 4)) >> 1
s = (off >> 23) & 1; i1 = (off >> 22) & 1; i2 = (off >> 21) & 1
imm10 = (off >> 11) & 0x3FF; imm11 = off & 0x7FF
j1 = (~i1 ^ s) & 1; j2 = (~i2 ^ s) & 1
return struct.pack("<HH", 0xF000 | (s << 10) | imm10,
0xD000 | (j1 << 13) | (j2 << 11) | imm11)
def main():
img = bytearray(open(SRC, "rb").read())
end = BASE + len(img)
code_at = (end + 3) & ~3
img += b"\xff" * (code_at - end)
print(f"основа: {SRC}, {len(img)} Б; свой код с 0x{code_at:08X}")
o = CALL_SITE - BASE
assert bytes(img[o:o + 4]) == thumb_bl(CALL_SITE, ON_FRAME), \
f"по 0x{CALL_SITE:08X} не вызов обработчика кадров"
print("точка перехвата подтверждена")
ks = Ks(KS_ARCH_ARM, KS_MODE_THUMB)
# --- пролог: длина кадра = 9 + BE16(len), маркер F1 F2 + длина ---
partA = bytes(ks.asm(f"""
push {{r4, r5, r6, lr}}
mov r4, r0
ldrb r5, [r4, #6]
ldrb r6, [r4, #7]
orr.w r5, r6, r5, lsl #8
adds r5, #9
uxtb r5, r5
movs r0, #0xF1
bl #{TX_BYTE}
movs r0, #0xF2
bl #{TX_BYTE}
mov r0, r5
bl #{TX_BYTE}
movs r6, #0
""", code_at)[0])
# --- тело цикла (без обратного перехода) ---
loop_at = code_at + len(partA)
body = bytes(ks.asm(f"""
ldrb r0, [r4, r6]
bl #{TX_BYTE}
adds r6, #1
cmp r6, r5
""", loop_at)[0])
# --- blt назад на начало цикла (keystone не умеет метки) ---
br_at = loop_at + len(body)
off = (loop_at - (br_at + 4)) >> 1
blt = struct.pack("<H", 0xDB00 | (off & 0xFF))
# --- эпилог: передать кадр штатному обработчику ---
tail_at = br_at + 2
tail = bytes(ks.asm(f"""
mov r0, r4
bl #{ON_FRAME}
pop {{r4, r5, r6, pc}}
""", tail_at)[0])
code = partA + body + blt + tail
img += code
img[o:o + 4] = thumb_bl(CALL_SITE, code_at)
print(f"снифер: {len(code)} Б, цикл на 0x{loop_at:08X}, blt смещение {off}")
open(OUT, "wb").write(bytes(img))
print(f"готово: {OUT}, {len(img)} Б")
if __name__ == "__main__":
main()
+292
Просмотреть файл
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""Talker Alias — приём: показ позывного вызывающего на экране (шаг 1 из 3).
Механика (подтверждена снифером на живом вызове):
DMR-модуль присылает позывной кадром cmd 0x60:
68 60 02 00 CK CK 00 LEN 02 01 <len> <ASCII-позывной> 00.. 10
Сток payload этой команды выбрасывает (обработчик = nop; b default).
Наши патчи (поверх rt4d_ru_batt.bin — там уже вольтметр и роутер отрисовки):
• ta_hook перехватывает fm100b_on_frame: на 0x60 копирует позывной в буфер RAM
и ставит ta_valid; на 0x59 payload=0 (конец приёма) сбрасывает ta_valid;
затем всегда вызывает штатный обработчик.
• new_tick_router (последним за кадр): самоинициализация настроек по «магии»,
затем на главном экране рисует вольтметр и — если включено и есть свежий
позывной — сам позывной внизу экрана.
Буфер RAM 0x2000C7FC..: [magic4][en][valid][len][txt16] (свободный зазор SRAM).
Настройка ta_en инициализируется в 1 при каждом включении (постоянное хранение —
шаг 2, вместе с меню). Откат — rt4d_ru_batt.bin.
"""
import struct
from keystone import Ks, KS_ARCH_ARM, KS_MODE_THUMB
BASE = 0x08002800
SRC = "stock-fw/rt4d_ru_batt.bin"
OUT = "stock-fw/rt4d_ta.bin"
DRAW_STRING = 0x08008A50 # void draw_string(page,x,s,len, mode@[sp])
BIG_CHAR = 0x08008100 # void big_char(page,x,ch,mode) — крупный шрифт 11x24 частоты
ON_FRAME = 0x08006348 # void fm100b_on_frame(u8 *frame)
FRAME_CALL = 0x08018DD8 # единственный bl on_frame
UI_HOOK = 0x08020806 # последний вызов отрисовки в ui_tick_normal
TICK_FUNC = 0x0801DFD0 # оригинал этого вызова
BATT_DRAW = 0x08029002 # уже в rt4d_ru_batt.bin
G_SCREEN = 0x200008B3
# Буфер в окне [0x2000AE48 .. 0x2000B108): выше вершины стека (SP=0x2000AE48,
# растёт вниз), ниже первого глобала (0x2000B108). Ничья 192-байтовая зона —
# в отличие от прошлого 0x2000C7FC, который оказался внутри чужого буфера.
MAGIC_ADDR = 0x2000B000
MAGIC_VAL = 0x52543444 # 'RT4D'
TA_EN = 0x2000B004
TA_VAL = 0x2000B005
TA_LEN = 0x2000B006
TA_TXT = 0x2000B007 # 16 байт -> до 0x2000B017
TA_PAGE = 3 # ниже ID, крупный шрифт 24px (страницы 3-5)
EQ, NE, HS, LS, GE = 0, 1, 2, 9, 10
def thumb_bl(src, dst):
off = (dst - (src + 4)) >> 1
s = (off >> 23) & 1; i1 = (off >> 22) & 1; i2 = (off >> 21) & 1
imm10 = (off >> 11) & 0x3FF; imm11 = off & 0x7FF
j1 = (~i1 ^ s) & 1; j2 = (~i2 ^ s) & 1
return struct.pack("<HH", 0xF000 | (s << 10) | imm10,
0xD000 | (j1 << 13) | (j2 << 11) | imm11)
class Asm:
"""Мини-ассемблер с метками: keystone-чанки + ручные ветвления."""
def __init__(self, ks, base):
self.ks = ks; self.base = base; self.items = []
def asm(self, code): self.items.append(("a", code))
def label(self, name): self.items.append(("l", name))
def b(self, target, cond=None): self.items.append(("b", cond, target))
def _sizes(self):
sz = []
for it in self.items:
if it[0] == "a":
sz.append(len(bytes(self.ks.asm(it[1], 0)[0])))
elif it[0] == "b":
sz.append(2)
else:
sz.append(0)
return sz
def build(self):
sz = self._sizes()
pos = {}; off = 0
for it, s in zip(self.items, sz):
if it[0] == "l":
pos[it[1]] = off
off += s
out = bytearray(); off = 0
for it, s in zip(self.items, sz):
if it[0] == "a":
out += bytes(self.ks.asm(it[1], self.base + off)[0])
elif it[0] == "b":
_, cond, tgt = it
frm = self.base + off
d = (self.base + pos[tgt]) - (frm + 4)
imm = (d >> 1) & 0xFF
if cond is None:
out += struct.pack("<H", 0xE000 | ((d >> 1) & 0x7FF))
else:
out += struct.pack("<H", 0xD000 | (cond << 8) | imm)
off += s
return bytes(out)
def main():
img = bytearray(open(SRC, "rb").read())
end = BASE + len(img)
code_at = (end + 3) & ~3
img += b"\xff" * (code_at - end)
print(f"основа {SRC}: {len(img)} Б; свой код с 0x{code_at:08X}")
# проверки точек патча
assert bytes(img[FRAME_CALL - BASE: FRAME_CALL - BASE + 4]) == thumb_bl(FRAME_CALL, ON_FRAME), \
"0x08018DD8 не вызов on_frame"
assert bytes(img[UI_HOOK - BASE: UI_HOOK - BASE + 4])[0:1] != b"\xff", "UI hook пуст"
print("точки перехвата на месте")
ks = Ks(KS_ARCH_ARM, KS_MODE_THUMB)
def hexlo(a): return a & 0xFFFF
def hexhi(a): return (a >> 16) & 0xFFFF
# ---- ta_draw: позывной крупным шрифтом (11x24) по центру экрана ----
# ширина символа 12px, высота 24px (страницы 2-4 = вертикальный центр).
# x0 = (128 - len*12)/2 = 64 - len*6. Длину зажимаем к 10 (10*12=120<=128).
ta_draw_at = code_at
a = Asm(ks, ta_draw_at)
a.asm(f"""
push {{r4, r5, r6, r7, lr}}
movw r4, #{hexlo(TA_LEN)}
movt r4, #{hexhi(TA_LEN)}
ldrb r4, [r4]
cmp r4, #10
""")
a.b("len_ok", LS)
a.asm("movs r4, #10")
a.label("len_ok")
a.asm(f"""
movs r0, #6
muls r0, r4, r0
movs r5, #64
subs r5, r5, r0
movw r6, #{hexlo(TA_TXT)}
movt r6, #{hexhi(TA_TXT)}
movs r7, #0
""")
a.label("cloop")
a.asm("cmp r7, r4")
a.b("cdone", HS)
a.asm(f"""
ldrb r2, [r6, r7]
mov r1, r5
movs r0, #{TA_PAGE}
movs r3, #0
bl #{BIG_CHAR}
adds r5, #12
adds r7, #1
""")
a.b("cloop")
a.label("cdone")
a.asm("pop {r4, r5, r6, r7, pc}")
ta_draw = a.build()
# ---- new_tick_router ----
tick_at = ta_draw_at + len(ta_draw)
r = Asm(ks, tick_at)
r.asm(f"""
push {{r4, lr}}
bl #{TICK_FUNC}
movw r0, #{hexlo(MAGIC_ADDR)}
movt r0, #{hexhi(MAGIC_ADDR)}
ldr r1, [r0]
movw r2, #{hexlo(MAGIC_VAL)}
movt r2, #{hexhi(MAGIC_VAL)}
cmp r1, r2
""")
r.b("skipinit", EQ)
r.asm(f"""
str r2, [r0]
movw r0, #{hexlo(TA_EN)}
movt r0, #{hexhi(TA_EN)}
movs r3, #1
strb r3, [r0]
movw r0, #{hexlo(TA_VAL)}
movt r0, #{hexhi(TA_VAL)}
movs r3, #0
strb r3, [r0]
""")
r.label("skipinit")
# вольтметр — только на главном экране (screen==0)
r.asm(f"""
movw r4, #{hexlo(G_SCREEN)}
movt r4, #{hexhi(G_SCREEN)}
ldrb r4, [r4]
cmp r4, #0
""")
r.b("no_batt", NE)
r.asm(f"bl #{BATT_DRAW}")
r.label("no_batt")
# ДИАГНОСТИКА: позывной рисуем на ЛЮБОМ экране, если есть свежий TA
# (условие en временно убрано — сначала убедимся, что приём работает)
r.asm(f"""
movw r0, #{hexlo(TA_VAL)}
movt r0, #{hexhi(TA_VAL)}
ldrb r0, [r0]
cmp r0, #0
""")
r.b("end", EQ)
r.asm(f"bl #{ta_draw_at | 1}")
r.label("end")
r.asm("pop {r4, pc}")
tick = r.build()
# ---- ta_hook ----
hook_at = tick_at + len(tick)
h = Asm(ks, hook_at)
h.asm("""
push {r4, r5, r6, lr}
mov r4, r0
ldrb r5, [r4, #1]
cmp r5, #0x60
""")
h.b("check59", NE)
# handle 0x60: len = frame[10], clamp 16
h.asm("""
ldrb r5, [r4, #10]
cmp r5, #16
""")
h.b("len_ok", LS)
h.asm("movs r5, #16")
h.label("len_ok")
h.asm("movs r6, #0")
h.label("loop")
h.asm("""
cmp r6, r5
""")
h.b("afterloop", HS)
h.asm(f"""
adds r0, r4, #11
ldrb r0, [r0, r6]
movw r1, #{hexlo(TA_TXT)}
movt r1, #{hexhi(TA_TXT)}
strb r0, [r1, r6]
adds r6, #1
""")
h.b("loop")
h.label("afterloop")
h.asm(f"""
movw r1, #{hexlo(TA_LEN)}
movt r1, #{hexhi(TA_LEN)}
strb r5, [r1]
movw r1, #{hexlo(TA_VAL)}
movt r1, #{hexhi(TA_VAL)}
movs r0, #1
strb r0, [r1]
""")
h.b("pass")
h.label("check59")
# кадр 0x06 = начало (sub 0x60) или конец (sub 0x6f) вызова -> сброс позывного.
# На начало сброс срабатывает ДО прихода 0x60 (TA), поэтому не мешает показу.
h.asm("cmp r5, #0x06")
h.b("pass", NE)
h.asm(f"""
movw r1, #{hexlo(TA_VAL)}
movt r1, #{hexhi(TA_VAL)}
movs r0, #0
strb r0, [r1]
""")
h.label("pass")
h.asm(f"""
mov r0, r4
bl #{ON_FRAME}
pop {{r4, r5, r6, pc}}
""")
hook = h.build()
code = ta_draw + tick + hook
img += code
while len(img) % 4:
img.append(0xFF)
print(f"код: ta_draw 0x{ta_draw_at:08X} ({len(ta_draw)}Б), "
f"tick 0x{tick_at:08X} ({len(tick)}Б), hook 0x{hook_at:08X} ({len(hook)}Б)")
# репоинты
img[UI_HOOK - BASE: UI_HOOK - BASE + 4] = thumb_bl(UI_HOOK, tick_at)
img[FRAME_CALL - BASE: FRAME_CALL - BASE + 4] = thumb_bl(FRAME_CALL, hook_at)
print("UI-хук -> new_tick_router; on_frame -> ta_hook")
open(OUT, "wb").write(bytes(img))
print(f"готово: {OUT}, {len(img)} Б")
if __name__ == "__main__":
main()
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
+462
Просмотреть файл
@@ -0,0 +1,462 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>RT-4D — конструктор дисплея</title>
<style>
:root{
--lcd-off:#A7B5A4; --lcd-on:#161C18; --lcd-grid:#93A191; --lcd-edge:#7E8C7D;
--ground:#0F1214; --panel:#171B1D; --panel-2:#1E2426; --line:#2A3134;
--ink:#D6DCD8; --muted:#74827C; --accent:#E2803A; --accent-dim:#8A5326;
--warn:#D8B14A; --bad:#C2564A; --ok:#6FA88A;
--mono:ui-monospace,"SF Mono","Cascadia Mono","Roboto Mono",Consolas,monospace;
--sans:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
}
@media (prefers-color-scheme: light){
:root{ --ground:#E6E8E4; --panel:#F4F5F2; --panel-2:#EAECE7; --line:#CDD2CB;
--ink:#1C2220; --muted:#5F6C66; }
}
:root[data-theme="dark"]{ --ground:#0F1214; --panel:#171B1D; --panel-2:#1E2426;
--line:#2A3134; --ink:#D6DCD8; --muted:#74827C; }
:root[data-theme="light"]{ --ground:#E6E8E4; --panel:#F4F5F2; --panel-2:#EAECE7;
--line:#CDD2CB; --ink:#1C2220; --muted:#5F6C66; }
*{box-sizing:border-box}
body{margin:0;background:var(--ground);color:var(--ink);font:15px/1.5 var(--sans)}
.wrap{max-width:1240px;margin:0 auto;padding:20px 18px 56px}
header{display:flex;flex-wrap:wrap;align-items:baseline;gap:10px 16px;
padding-bottom:14px;border-bottom:1px solid var(--line);margin-bottom:18px}
h1{margin:0;font-size:19px;font-weight:650;letter-spacing:-.01em}
.sub{color:var(--muted);font-size:13px}
.spec{margin-left:auto;font:12px/1 var(--mono);color:var(--muted);
letter-spacing:.04em;text-transform:uppercase}
.cols{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:18px;align-items:start}
@media (max-width:900px){ .cols{grid-template-columns:minmax(0,1fr)} }
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px}
.card>h2{margin:0;padding:11px 14px;font-size:11px;font-weight:600;
letter-spacing:.09em;text-transform:uppercase;color:var(--muted);
border-bottom:1px solid var(--line)}
.card>.body{padding:14px}
/* --- дисплей --- */
.stage{padding:16px 14px 14px;overflow-x:auto}
.bezel{display:inline-block;background:var(--panel-2);border:1px solid var(--line);
border-radius:8px;padding:10px 12px 12px}
canvas{display:block;image-rendering:pixelated;cursor:crosshair;
border:1px solid var(--lcd-edge);border-radius:2px;touch-action:none}
.rulerinfo{display:flex;gap:14px;margin-top:9px;font:11px/1 var(--mono);
color:var(--muted);letter-spacing:.04em}
.rulerinfo b{color:var(--ink);font-weight:600}
.toolbar{display:flex;flex-wrap:wrap;gap:7px;padding:12px 14px;
border-bottom:1px solid var(--line)}
button{font:13px/1 var(--sans);color:var(--ink);background:var(--panel-2);
border:1px solid var(--line);border-radius:7px;padding:7px 11px;cursor:pointer}
button:hover{border-color:var(--muted)}
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
button.primary{background:var(--accent-dim);border-color:var(--accent);color:#FFF1E4}
button.ghost{background:transparent}
label.chk{display:inline-flex;align-items:center;gap:7px;font-size:13px;
color:var(--muted);cursor:pointer;padding:7px 4px}
/* --- список элементов --- */
ul.items{list-style:none;margin:0;padding:0;max-height:270px;overflow-y:auto}
ul.items li{display:flex;align-items:center;gap:9px;padding:8px 14px;
border-bottom:1px solid var(--line);cursor:pointer;font-size:13px}
ul.items li:last-child{border-bottom:0}
ul.items li[aria-selected="true"]{background:var(--panel-2);
box-shadow:inset 3px 0 0 var(--accent)}
.tag{font:10px/1 var(--mono);letter-spacing:.06em;text-transform:uppercase;
color:var(--muted);border:1px solid var(--line);border-radius:4px;padding:3px 5px}
.nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pos{font:11px/1 var(--mono);color:var(--muted);font-variant-numeric:tabular-nums}
.lock{font:10px/1 var(--mono);color:var(--warn)}
/* --- инспектор --- */
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.f{display:flex;flex-direction:column;gap:5px}
.f label{font:10px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;
color:var(--muted)}
input,select{font:13px/1 var(--mono);color:var(--ink);background:var(--panel-2);
border:1px solid var(--line);border-radius:6px;padding:8px 9px;width:100%;
font-variant-numeric:tabular-nums}
input:focus,select:focus{outline:2px solid var(--accent);outline-offset:1px}
.hint{color:var(--muted);font-size:12px;margin:10px 0 0}
.empty{color:var(--muted);font-size:13px;padding:4px 0}
/* --- вывод --- */
pre.out{margin:0;padding:14px;font:12.5px/1.65 var(--mono);color:var(--ink);
background:var(--panel-2);border-radius:0 0 9px 9px;overflow-x:auto;
white-space:pre;font-variant-numeric:tabular-nums}
.warnbar{display:flex;align-items:flex-start;gap:9px;padding:10px 14px;
border-bottom:1px solid var(--line);font-size:13px;color:var(--warn)}
.warnbar.ok{color:var(--ok)}
.warnbar .dot{width:7px;height:7px;border-radius:50%;background:currentColor;
margin-top:6px;flex:none}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Конструктор дисплея RT-4D</h1>
<span class="sub">шрифты и координаты — из самой прошивки</span>
<span class="spec">128 × 64 · 8 страниц по 8 строк</span>
</header>
<div class="cols">
<div>
<div class="card">
<div class="toolbar">
<button data-add="small">+ Текст 5×8</button>
<button data-add="big">+ Текст 7×16</button>
<button data-add="bar">+ Полоса</button>
<button data-add="rect">+ Рамка</button>
<button id="dup" class="ghost">Дублировать</button>
<button id="del" class="ghost">Удалить</button>
<label class="chk"><input type="checkbox" id="showStock" checked> занятое стоком</label>
<label class="chk"><input type="checkbox" id="showGrid" checked> сетка</label>
</div>
<div class="stage">
<div class="bezel"><canvas id="lcd" width="1040" height="536"></canvas></div>
<div class="rulerinfo">
<span>курсор <b id="cur"></b></span>
<span>страница <b id="curpg"></b></span>
<span>элементов <b id="cnt">0</b></span>
</div>
</div>
</div>
<div class="card" style="margin-top:16px">
<h2>Вызовы отрисовки</h2>
<div class="warnbar ok" id="warn"><span class="dot"></span><span id="warntext">коллизий нет</span></div>
<pre class="out" id="out"></pre>
</div>
</div>
<div>
<div class="card">
<h2>Элементы</h2>
<ul class="items" id="list"></ul>
</div>
<div class="card" style="margin-top:16px">
<h2>Свойства</h2>
<div class="body" id="insp"><div class="empty">Выберите элемент на дисплее.</div></div>
</div>
<div class="card" style="margin-top:16px">
<h2>Макет</h2>
<div class="body">
<div style="display:flex;gap:7px;flex-wrap:wrap">
<button id="preset" class="ghost">Загрузить сток</button>
<button id="clear" class="ghost">Очистить</button>
<button id="exp" class="primary">Скопировать JSON</button>
</div>
<p class="hint">Стрелками — сдвиг на 1 пиксель, с Shift — на 8. Текст по вертикали
встаёт только на границу страницы: вывод адресуется страницами, а не строками.</p>
</div>
</div>
</div>
</div>
</div>
<script>
const FONTS = {"small5x8": "AAAAAAAAAL4AAAAOAA4ASPxI/EgoVNZUKIhAIBCISLS0qEAAAIwAADhEggAAAACCRDiIUPxQiBAQ/hAQAIBgAAAQEBAQEAAAgAAAQCAQCAT+goKC/gAA/gAA8pKSkp6SkpKS/h4QEBD+npKSkvL+kpKS8gICAgL+/pKSkv6ekpKS/gAAKAAAgEAoAAAQKESCACgoKCgoAIJEKBAEArIKBHyyysq8+CQiJPj+kpKSbHyCgoJE/oKCRDj+kpKSkv4SEhISfIKSkuT+EBAQ/oKC/oKCYICCfgL+EChEgv6AgICA/gQIBP7+CBAg/nyCgoJ8/hISEgx8oqJCvP4SMlKMTJKSkmQCAv4CAn6AgIB+PkCAQD5+gH6AfsYoECjGBgjwCAbCopKKhv6CggAACBAgQIAAAIKC/gwCDAAAgICAgIACBAgAAGSUlGT4/pCQkGB4hISESGCQkJD+eJSUlFgQEPwSEpikpKR8/hAQ4AAAAOgAAGCAgHoA/iBQiAAAAP4AAPgE+AT4/AQE+AB4hISEeP4SEhIMDBISEv74IBAIAJiUtKRkCAh8iIh8gIBAfDxAgEA8fIB8gHyIUCBQiIyQUDwAiMiomIgQfIIAAAAA/gAAAACCfBAwCBAgGBA4fP4A", "big7x16": "AAAAAAAAAAAAAAAAAAAAAAAAcAD4DfgNcAAAAAAAOAA4AAAAAAA4ADgAAAAgAvgP+A8gAvgP+A8AADAEeAzOOI45GA8QBhgAPAYkA7wN2B5gEjAeAACwB/gPSAh4CTAHAA8AAAAAAAA4ADgAAAAAAAAAAADAB/AfODgIIAAAAAAAAAggODjwH8AHAAAAAIAAoALgA8AB4AOgAgAAgACAAOAD4AOAAIAAAAAAAAAAACwAPAAcAAAAAIAAgACAAIAAgACAAAAAAAAAAAAMAAwADAAAAAAAGAAegAfgAXgAGAAAAPAH+A+ICEgI+A/wBwAAAAAQAPgP+A8AAAAAAAAwDjgOCAuICfgIcAgAADAOOA6ICMgI+A9wBwACgAPAA3AC+A/4DwACAAD4DPgMSAhICMgPiAcAAPAH+A/ICEgI2A+QBwAACAAIDogP6AF4ABgAAABwD/gPyAjICPgPcAcAAPAM+A0ICQgJ+A/wBwAAAAAAAGAMYAxgDAAAAAAAAAAAYCxgPGAcAAAAAIAAwAFgAzAGGAwICAAAQAFAAUABQAFAAUABAAAICBgMMAZgA8ABgAAAADAAOACIDcgNeAAwAPAH+A8ICIgJyAtICvgLAADgD/APGAEYAfAP4A8AAPgP+A+ICIgI+A9wBwAA8Af4DwgICAg4DjAGAAD4D/gPCAgYDPAH4AMAAPgP+A+ICIgIiAgICAAA+A/4D4gAiACIAAgAAADwB/gPCAgICTgPMA8AAPgP+A+AAIAA+A/4DwAAAAAICPgP+A8ICAAAAAAABgAOAAgACPgP+AcAAPgP+A+AAOADeA8YDAAA+A/4DwAIAAgACAAIAAD4D/gPIADAASAA+A8AAPgP+A9gAMAAgAH4DwAA8Af4DwgICAj4D/AHAAD4D/gPiACIAPgAcAAAAPAH+A8ICAgY+D/wJwAA+A/4D4gAiAH4D3AOAAAwBHgMyAiICRgPEAYAAAgACAD4D/gPCAAIAAAA+Af4DwAIAAj4D/gHAAD4A/gHAAwADPgH+AMAAPgB+A8ADsABAA74DwAAGA44D+AAwAE4DxgOAAB4APgAgA+AD/gAeAAAAAgOCA+ICcgIeAg4CAAAAAD4f/h/CEAIQAAAAAAYAHgA4AGABwAeABgAAAAACEAIQPh/+H8AAAAACAAMAAYABgAMAAgAAEAAQABAAEAAQABAAEAAAAAAAgAGAA4ACAAAAAAAAAYgDyAJIAngD8APAAD4D/gPIAggCOAPwAcAAMAH4A8gCCAIYAxABAAAwAfgDyAIIAj4D/gPAADAB+APIAkgCeAJwAEAAIAA8A/4D4gAiACIAAAAwEfgTyBIIEjgf+A/AAD4D/gPIAAgAOAPwA8AACAIIAjsD+wPAAgACAAAAEAgQCBA7H/sPwAAAAD4D/gPAAGAA+AOYAwAAAgICAj4D/gPAAgACAAA4A/gDyAA4AcgAOAPAADgD+APIAAgAOAPwA8AAMAH4A8gCCAI4A/ABwAA4H/gfyAIIAjgD8AHAADAB+APIAggCOB/4H8AAOAP4A+AAEAAYABgAAAAwAjgCSAJIAkgDyAGAAAgAPgH+A8gCCAIIAgAAOAH4A8ACAAI4A/gDwAA4APgBwAMAAzgB+ADAADgA+APAAzAAwAM4A8AAGAM4A6AA4AD4A5gDABA4EfgTwBoADjgH+AHAAAgDCAOIAugCeAIYAgAAAABgAPwHng8CCAAAAAAAAAAAPh/+H8AAAAAAAAAAAggeDzwHoADAAEwABgACAAYADAAIAAwAAAAAAAAAAAAAAAAAAAA", "cyr": {"\u0410": "AADgD/APGAEYAfAP4A8=", "\u0412": "AAD4D/gPiAiICPgPcAc=", "\u0415": "AAD4D/gPiAiICIgICAg=", "\u0417": "AAAwDjgOiAjICPgPcAc=", "\u041a": "AAD4D/gPgADgA3gPGAw=", "\u041c": "AAD4D/gPIADAASAA+A8=", "\u041d": "AAD4D/gPgACAAPgP+A8=", "\u041e": "AADwB/gPCAgICPgP8Ac=", "\u0420": "AAD4D/gPiACIAPgAcAA=", "\u0421": "AADwB/gPCAgICDgOMAY=", "\u0422": "AAAIAAgA+A/4DwgACAA=", "\u0425": "AAAYDjgP4ADAATgPGA4=", "\u0411": "AAD4D/gPSAhICMgPiAc=", "\u0413": "AAD4D/gPCAAIAAgACAA=", "\u0414": "AAAADPgP+AcIBAgM+A8=", "\u0416": "AAAYDiAB+A8gARgOAAA=", "\u0418": "AAD4D/gPwAHgAPgPOA8=", "\u0419": "AADgD+gPmAHYAOgPYA8=", "\u041b": "AAAADvgP+AEIAAgA+A8=", "\u041f": "AAD4D/gPCAAIAPgP+A8=", "\u0423": "AAAYDDgG4AP4ARgAAAA=", "\u0424": "AADwARAB+A/4DxAB8AE=", "\u0426": "AAD4B/gHAAT4D/gPAAA=", "\u0427": "AAB4AHgAQABAAPgP+A8=", "\u0428": "AAD4DwAI+A/4DwAI+A8=", "\u0429": "AAD4BwAE+Af4BwAE+A8=", "\u042a": "AAAIAPgP+A+ACIAPAAc=", "\u042b": "AAD4D/gPgAiADwAA+A8=", "\u042c": "AAD4D/gPgAiACIAPAAc=", "\u042d": "AAAYDBgMSAhICPgP8Ac=", "\u042e": "AAD4D0AA8AdICAgI8Ac=", "\u042f": "AAB4CHgESAJIAfgA+A8=", "\u0401": "AADoD+APqAigCKAIIAg="}};
/* ---------- декодирование шрифтов ---------- */
function b64(s){const r=atob(s),a=new Uint8Array(r.length);
for(let i=0;i<r.length;i++)a[i]=r.charCodeAt(i);return a;}
const SMALL=b64(FONTS.small5x8), BIG=b64(FONTS.big7x16);
const CYR={}; for(const k in FONTS.cyr) CYR[k]=b64(FONTS.cyr[k]);
/* колонки символа: массив чисел-битовых масок, бит0 = верхняя строка */
function glyphSmall(ch){const c=ch.charCodeAt(0);
if(c<0x20||c>0x7F)return [0,0,0,0,0];
const o=(c-0x20)*5,out=[];for(let i=0;i<5;i++)out.push(SMALL[o+i]);return out;}
function glyphBig(ch){
if(CYR[ch]){const g=CYR[ch],out=[];for(let i=0;i<7;i++)out.push(g[2*i]|(g[2*i+1]<<8));return out;}
const c=ch.charCodeAt(0);
if(c<0x20||c>0x7F)return [0,0,0,0,0,0,0];
const o=(c-0x20)*14,out=[];for(let i=0;i<7;i++)out.push(BIG[o+i*2]|(BIG[o+i*2+1]<<8));return out;}
const FONT={small:{cols:5,stride:6,rows:8,pages:1,glyph:glyphSmall,call:"draw_str_spaced / small_char"},
big: {cols:7,stride:7,rows:16,pages:2,glyph:glyphBig, call:"draw_string"}};
/* ---------- модель ---------- */
const W=128,H=64,S=8; // масштаб
let items=[], sel=null, uid=1;
const STOCK=[
{name:"строка режима",page:1,x:0,w:96},
{name:"признак канала",page:1,x:104,w:24},
{name:"зона A",page:2,x:0,w:112,h:2},
{name:"зона B",page:5,x:0,w:112,h:2},
{name:"стрелки A/B",page:2,x:123,w:5},
{name:"стрелки A/B",page:5,x:123,w:5},
];
function mk(type){
const base={id:uid++,type,page:0,x:0,invert:0};
if(type==="small") return {...base,text:"8.4V",font:"small"};
if(type==="big") return {...base,text:"CH-01",font:"big",page:2};
if(type==="bar") return {...base,page:1,x:4,count:120};
return {...base,page:1,x:4,w:120,h:6};
}
/* ---------- растеризация ---------- */
function raster(){
const px=new Uint8Array(W*H);
const set=(x,y)=>{if(x>=0&&x<W&&y>=0&&y<H)px[y*W+x]=1;};
for(const it of items){
if(it.type==="small"||it.type==="big"){
const F=FONT[it.font];
[...it.text].forEach((ch,ci)=>{
const g=F.glyph(ch);
for(let c=0;c<F.cols;c++){
const x=it.x+ci*F.stride+c;
for(let r=0;r<F.rows;r++){
const on=(g[c]>>r)&1;
if(it.invert? !on : on) set(x, it.page*8+r);
}
}
});
} else if(it.type==="bar"){
for(let i=0;i<it.count;i++)for(let r=0;r<8;r++)
if((0x18>>r)&1) set(it.x+i, it.page*8+r);
} else {
const y0=it.page*8, y1=y0+it.h*8-1;
for(let i=0;i<it.w;i++){set(it.x+i,y0);set(it.x+i,y1);}
for(let y=y0;y<=y1;y++){set(it.x,y);set(it.x+it.w-1,y);}
}
}
return px;
}
/* ---------- отрисовка ---------- */
const cv=document.getElementById("lcd"), cx=cv.getContext("2d");
const OFFX=16, OFFY=16; // место под линейки
function css(v){return getComputedStyle(document.documentElement).getPropertyValue(v).trim();}
function draw(){
const off=css("--lcd-off"), on=css("--lcd-on"), grid=css("--lcd-grid"),
muted=css("--muted"), accent=css("--accent");
cx.clearRect(0,0,cv.width,cv.height);
// линейки
cx.font="9px "+css("--mono").split(",")[0]+", monospace";
cx.fillStyle=muted; cx.textBaseline="middle";
for(let p=0;p<8;p++){cx.textAlign="right";
cx.fillText(String(p),OFFX-4,OFFY+p*8*S+4*S);}
cx.textAlign="center";
for(let c=0;c<=W;c+=16) cx.fillText(String(c),OFFX+c*S,OFFY-7);
// стекло
cx.fillStyle=off; cx.fillRect(OFFX,OFFY,W*S,H*S);
// занятое стоком
if(document.getElementById("showStock").checked){
cx.save(); cx.globalAlpha=.20; cx.fillStyle=accent;
for(const s of STOCK) cx.fillRect(OFFX+s.x*S,OFFY+s.page*8*S,s.w*S,(s.h||1)*8*S);
cx.restore();
}
// сетка страниц
if(document.getElementById("showGrid").checked){
cx.strokeStyle=grid; cx.lineWidth=1;
cx.beginPath();
for(let c=8;c<W;c+=8){cx.moveTo(OFFX+c*S+.5,OFFY);cx.lineTo(OFFX+c*S+.5,OFFY+H*S);}
cx.stroke();
cx.strokeStyle=css("--lcd-edge"); cx.beginPath();
for(let p=1;p<8;p++){cx.moveTo(OFFX,OFFY+p*8*S+.5);cx.lineTo(OFFX+W*S,OFFY+p*8*S+.5);}
cx.stroke();
}
// пиксели
const px=raster(); cx.fillStyle=on;
for(let y=0;y<H;y++)for(let x=0;x<W;x++)
if(px[y*W+x]) cx.fillRect(OFFX+x*S,OFFY+y*S,S,S);
// выделение
if(sel){const b=bbox(sel);
cx.strokeStyle=accent;cx.lineWidth=2;
cx.strokeRect(OFFX+b.x*S-1,OFFY+b.y*S-1,b.w*S+2,b.h*S+2);}
}
function bbox(it){
if(it.type==="small"||it.type==="big"){
const F=FONT[it.font];
return {x:it.x,y:it.page*8,w:Math.max(1,it.text.length*F.stride-(F.stride-F.cols)),h:F.rows};
}
if(it.type==="bar") return {x:it.x,y:it.page*8,w:it.count,h:8};
return {x:it.x,y:it.page*8,w:it.w,h:it.h*8};
}
/* ---------- список и инспектор ---------- */
function label(it){
if(it.type==="small") return "«"+it.text+"» 5×8";
if(it.type==="big") return "«"+it.text+"» 7×16";
if(it.type==="bar") return "полоса "+it.count+" кол.";
return "рамка "+it.w+"×"+it.h*8;
}
function renderList(){
const ul=document.getElementById("list"); ul.innerHTML="";
document.getElementById("cnt").textContent=items.length;
if(!items.length){ul.innerHTML='<li style="cursor:default;color:var(--muted)">Пусто — добавьте элемент.</li>';return;}
for(const it of items){
const li=document.createElement("li");
li.setAttribute("aria-selected", sel&&sel.id===it.id ? "true":"false");
li.innerHTML=`<span class="tag">${it.type==="bar"?"BAR":it.type==="rect"?"BOX":it.font==="small"?"5×8":"7×16"}</span>
<span class="nm">${label(it).replace(/</g,"&lt;")}</span>
<span class="pos">p${it.page} · ${it.x}</span>`;
li.onclick=()=>{sel=it;sync();};
ul.appendChild(li);
}
}
function field(lb,key,attrs=""){
return `<div class="f"><label>${lb}</label><input data-k="${key}" ${attrs} value="${sel[key]}"></div>`;
}
function renderInsp(){
const el=document.getElementById("insp");
if(!sel){el.innerHTML='<div class="empty">Выберите элемент на дисплее.</div>';return;}
let h='<div class="grid2">'+field("страница","page",'type="number" min="0" max="7"')
+field("колонка","x",'type="number" min="0" max="127"')+'</div>';
if(sel.type==="small"||sel.type==="big"){
h+='<div class="f" style="margin-top:10px"><label>текст</label><input data-k="text" value="'+
sel.text.replace(/"/g,"&quot;")+'"></div>';
h+='<div class="grid2" style="margin-top:10px"><div class="f"><label>шрифт</label>'+
'<select data-k="font"><option value="small"'+(sel.font==="small"?" selected":"")+'>5×8 мелкий</option>'+
'<option value="big"'+(sel.font==="big"?" selected":"")+'>7×16 основной</option></select></div>'+
'<div class="f"><label>инверсия</label><select data-k="invert">'+
'<option value="0"'+(!sel.invert?" selected":"")+'>нет</option>'+
'<option value="1"'+(sel.invert?" selected":"")+'>да</option></select></div></div>';
} else if(sel.type==="bar"){
h+='<div class="grid2" style="margin-top:10px">'+field("длина","count",'type="number" min="1" max="128"')+'</div>';
} else {
h+='<div class="grid2" style="margin-top:10px">'+field("ширина","w",'type="number" min="2" max="128"')
+field("высота, стр.","h",'type="number" min="1" max="8"')+'</div>';
}
el.innerHTML=h;
el.querySelectorAll("[data-k]").forEach(inp=>{
inp.oninput=()=>{const k=inp.dataset.k;
sel[k] = (k==="text"||k==="font") ? inp.value : parseInt(inp.value||0,10)||0;
if(k==="page") sel.page=Math.max(0,Math.min(7,sel.page));
sync(false);};
});
}
/* ---------- вывод вызовов ---------- */
function output(){
const L=[];
for(const it of items){
if(it.type==="small"||it.type==="big"){
const F=FONT[it.font];
if(it.font==="small")
L.push(`draw_str_spaced(page=${it.page}, x=${it.x}, n=${it.text.length}, "${it.text}", mode=${it.invert});`);
else
L.push(`draw_string(page=${it.page}, x=${it.x}, "${it.text}", n=${it.text.length}, mode=${it.invert});`);
L.push(` // ${F.cols}px/симв, шаг ${F.stride}px, занимает стр. ${it.page}` +
(F.pages>1?`${it.page+1}`:"") + `, кол. ${it.x}${it.x+it.text.length*F.stride-1}`);
} else if(it.type==="bar"){
L.push(`fill_col_run(page=${it.page}, x=${it.x}, count=${it.count});`);
L.push(` // байт 0x18 — полоса 2px посередине страницы ${it.page}`);
} else {
L.push(`// рамка: полосы на стр. ${it.page} и ${it.page+it.h-1}, стенки на кол. ${it.x} и ${it.x+it.w-2}`);
L.push(`fill_col_run(page=${it.page}, x=${it.x}, count=${it.w});`);
L.push(`draw_box(col=${it.x}); draw_box(col=${it.x+it.w-2});`);
}
}
document.getElementById("out").textContent = L.length?L.join("\n"):"— добавьте элементы —";
// коллизии
const occ=new Map(); const bad=new Set();
const mark=(name,x,y,w,h)=>{
for(let yy=y;yy<y+h;yy++)for(let xx=x;xx<x+w;xx++){
const k=yy*W+xx; if(k<0||k>=W*H)continue;
if(occ.has(k)&&occ.get(k)!==name) bad.add(occ.get(k)+" ↔ "+name);
else occ.set(k,name);
}};
for(const it of items){const b=bbox(it);mark(label(it),b.x,b.y,b.w,b.h);}
if(document.getElementById("showStock").checked)
for(const s of STOCK) mark("сток: "+s.name,s.x,s.page*8,s.w,(s.h||1)*8);
const wb=document.getElementById("warn"), wt=document.getElementById("warntext");
if(bad.size){wb.className="warnbar";wt.textContent="перекрытие: "+[...bad].slice(0,3).join("; ");}
else{wb.className="warnbar ok";wt.textContent="коллизий нет";}
}
function sync(rebuild=true){ if(rebuild)renderInsp(); renderList(); draw(); output(); }
/* ---------- мышь ---------- */
let drag=null;
function hit(mx,my){
for(let i=items.length-1;i>=0;i--){const b=bbox(items[i]);
if(mx>=b.x&&mx<b.x+b.w&&my>=b.y&&my<b.y+b.h)return items[i];}
return null;
}
function pos(e){const r=cv.getBoundingClientRect();
const sx=cv.width/r.width;
return {x:Math.floor(((e.clientX-r.left)*sx-OFFX)/S), y:Math.floor(((e.clientY-r.top)*sx-OFFY)/S)};}
cv.addEventListener("pointerdown",e=>{
const p=pos(e); const it=hit(p.x,p.y);
sel=it; if(it){drag={dx:p.x-it.x, dy:p.y-it.page*8}; cv.setPointerCapture(e.pointerId);}
sync();
});
cv.addEventListener("pointermove",e=>{
const p=pos(e);
document.getElementById("cur").textContent =
(p.x>=0&&p.x<W&&p.y>=0&&p.y<H) ? p.x+", "+p.y : "—";
document.getElementById("curpg").textContent =
(p.y>=0&&p.y<H) ? Math.floor(p.y/8) : "—";
if(drag&&sel){
sel.x=Math.max(0,Math.min(127,p.x-drag.dx));
sel.page=Math.max(0,Math.min(7,Math.round((p.y-drag.dy)/8)));
sync(false);
}
});
addEventListener("pointerup",()=>{drag=null;});
addEventListener("keydown",e=>{
if(!sel||/INPUT|SELECT/.test(document.activeElement.tagName))return;
const step=e.shiftKey?8:1; let used=true;
if(e.key==="ArrowLeft")sel.x=Math.max(0,sel.x-step);
else if(e.key==="ArrowRight")sel.x=Math.min(127,sel.x+step);
else if(e.key==="ArrowUp")sel.page=Math.max(0,sel.page-1);
else if(e.key==="ArrowDown")sel.page=Math.min(7,sel.page+1);
else if(e.key==="Delete"||e.key==="Backspace"){items=items.filter(i=>i!==sel);sel=null;}
else used=false;
if(used){e.preventDefault();sync();}
});
/* ---------- кнопки ---------- */
document.querySelectorAll("[data-add]").forEach(b=>b.onclick=()=>{
const it=mk(b.dataset.add); items.push(it); sel=it; sync();});
document.getElementById("del").onclick=()=>{if(sel){items=items.filter(i=>i!==sel);sel=null;sync();}};
document.getElementById("dup").onclick=()=>{if(sel){const c={...sel,id:uid++,page:Math.min(7,sel.page+1)};
items.push(c);sel=c;sync();}};
document.getElementById("clear").onclick=()=>{items=[];sel=null;sync();};
document.getElementById("showStock").onchange=sync;
document.getElementById("showGrid").onchange=draw;
document.getElementById("preset").onclick=()=>{
items=[ {id:uid++,type:"small",font:"small",text:"8.4V",page:0,x:108,invert:0},
{id:uid++,type:"big",font:"big",text:"438.80000",page:2,x:20,invert:0},
{id:uid++,type:"big",font:"big",text:"CH-0002",page:5,x:20,invert:0},
{id:uid++,type:"small",font:"small",text:"DMR",page:1,x:0,invert:0} ];
sel=items[0]; sync();};
document.getElementById("exp").onclick=async()=>{
const btn=document.getElementById("exp");
try{ await navigator.clipboard.writeText(JSON.stringify(items,null,1));
btn.textContent="Скопировано"; }
catch{ btn.textContent="Не удалось"; }
setTimeout(()=>btn.textContent="Скопировать JSON",1400);};
document.getElementById("preset").click();
matchMedia("(prefers-color-scheme: dark)").addEventListener("change",draw);
new MutationObserver(draw).observe(document.documentElement,{attributes:true,attributeFilter:["data-theme"]});
</script>
</body>
</html>
+130
Просмотреть файл
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Прошивка RT-4D из WSL через powershell.exe (WSL2 не отдаёт COM напрямую).
python3 tools/flash_ps.py stock-fw/rt4d_ru_batt.bin [--port COM4]
Рация должна быть в режиме прошивки: выключить -> включить, удерживая тангенту PTT.
Протокол бутлоадера (тот же, что в flash_rt4d.py):
рукопожатие : слать 0xFF, пока не придёт 0xFF
стирание : [39 33 05 10]+CK, затем [39 33 05 55]+CK -> ACK 0x06
запись : [57 hi lo] + 1024 Б + CK -> ACK 0x06
CK = (0x48 + сумма) & 0xFF
"""
import argparse, os, shutil, subprocess, sys
WIN_TMP_WSL = "/mnt/c/Users/vikto/AppData/Local/Temp"
WIN_TMP_WIN = r"C:\Users\vikto\AppData\Local\Temp"
PS = r'''
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$fw = [System.IO.File]::ReadAllBytes("{fwpath}")
$p = New-Object System.IO.Ports.SerialPort "{port}",115200,"None",8,"one"
$p.ReadTimeout = 250
$p.WriteTimeout = 5000
try {{ $p.Open() }} catch {{ Write-Output "ERR: порт занят или недоступен: $($_.Exception.Message)"; exit 1 }}
Write-Output "INFO: прошивка $($fw.Length) байт, порт {port}"
# --- рукопожатие ---
$p.DiscardInBuffer()
$ready = $false
$sw = [Diagnostics.Stopwatch]::StartNew()
while ($sw.ElapsedMilliseconds -lt 8000) {{
try {{ if ($p.ReadByte() -eq 0xFF) {{ $ready = $true; break }} }}
catch {{ $p.Write([byte[]]@(0xFF), 0, 1) }}
}}
if (-not $ready) {{
Write-Output "ERR: бутлоадер не отвечает. Выключите рацию и включите с зажатой тангентой PTT."
$p.Close(); exit 1
}}
Write-Output "INFO: бутлоадер найден"
# --- стирание ---
foreach ($trig in 0x10, 0x55) {{
$pl = [byte[]]@(0x39, 0x33, 0x05, $trig, 0)
$s = 0x48; for ($i = 0; $i -lt 4; $i++) {{ $s = ($s + $pl[$i]) -band 0xFF }}
$pl[4] = [byte]$s
$p.DiscardInBuffer(); $p.Write($pl, 0, 5)
$ok = $false; $sw2 = [Diagnostics.Stopwatch]::StartNew()
while ($sw2.ElapsedMilliseconds -lt 6000) {{ try {{ if ($p.ReadByte() -eq 6) {{ $ok = $true; break }} }} catch {{}} }}
if (-not $ok) {{ Write-Output "ERR: ошибка стирания"; $p.Close(); exit 1 }}
}}
Write-Output "INFO: область приложения стёрта"
# --- дополнение до кратности 1024 ---
$pad = (1024 - ($fw.Length % 1024)) % 1024
if ($pad -gt 0) {{
$t = New-Object byte[] ($fw.Length + $pad)
[Array]::Copy($fw, $t, $fw.Length)
for ($i = $fw.Length; $i -lt $t.Length; $i++) {{ $t[$i] = 0xFF }}
$fw = $t
}}
$total = $fw.Length
$blocks = $total / 1024
# --- запись ---
$pl = New-Object byte[] 1028
for ($off = 0; $off -lt $total; $off += 1024) {{
$pl[0] = 0x57
$pl[1] = [byte](($off -shr 8) -band 0xFF)
$pl[2] = [byte]($off -band 0xFF)
[Array]::Copy($fw, $off, $pl, 3, 1024)
$s = 0x48
for ($i = 0; $i -lt 1027; $i++) {{ $s = ($s + $pl[$i]) -band 0xFF }}
$pl[1027] = [byte]$s
$p.DiscardInBuffer(); $p.Write($pl, 0, 1028)
$ok = $false; $sw3 = [Diagnostics.Stopwatch]::StartNew()
while ($sw3.ElapsedMilliseconds -lt 4000) {{ try {{ if ($p.ReadByte() -eq 6) {{ $ok = $true; break }} }} catch {{}} }}
if (-not $ok) {{ Write-Output ("ERR: нет ACK на блоке 0x{{0:X5}}" -f $off); $p.Close(); exit 1 }}
$n = ($off / 1024) + 1
if (($n % 16) -eq 0 -or $n -eq $blocks) {{ Write-Output ("PROG: {{0}}/{{1}} блоков" -f $n, $blocks) }}
}}
$p.Close()
Write-Output "OK: записано. Выключите и включите рацию обычным образом."
'''
def find_port() -> str:
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 main():
ap = argparse.ArgumentParser()
ap.add_argument("firmware")
ap.add_argument("--port")
a = ap.parse_args()
fw = os.path.abspath(a.firmware)
if not os.path.exists(fw):
sys.exit(f"нет файла: {fw}")
name = "rt4d_flash_tmp.bin"
shutil.copy(fw, os.path.join(WIN_TMP_WSL, name))
win_fw = os.path.join(WIN_TMP_WIN, name)
port = a.port or find_port()
script = PS.format(fwpath=win_fw.replace("\\", "\\\\"), port=port)
proc = subprocess.Popen(["powershell.exe", "-NoProfile", "-Command", script],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding='utf-8', errors='replace')
rc = 0
for line in proc.stdout:
line = line.rstrip("\r\n")
if line:
print(line, flush=True)
if line.startswith("ERR:"):
rc = 1
proc.wait()
sys.exit(rc or proc.returncode)
if __name__ == "__main__":
main()
+88
Просмотреть файл
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Подбор RXOffset хотспота под смещённую TX-частоту рации (без прибора).
Идея: TX рации смещена на постоянный X Гц (дефолтная калибровка). Двигаем приёмное
смещение хотспота (RXOffset) и при передаче рации (PTT) ищем в логе MMDVMHost
«received RF» — значит offset поймал передачу. По BER выбираем лучший.
Запуск на самом хотспоте (или по SSH). Владелец УДЕРЖИВАЕТ PTT на DMR-канале во время
каждого шага (окно ~6 с). Меняет RXOffset в MMDVM.ini, рестартит MMDVMHost, читает лог.
python3 hotspot_rxoffset_sweep.py --range 1000 --step 100 --dwell 7
Требует sudo (запись MMDVM.ini + restart). Значения offset в Гц.
"""
import argparse, subprocess, re, time, sys
INI = "/opt/MMDVMHost/MMDVM.ini"
SVC = "radiostar-mmdvmhost"
def set_rxoffset(val):
# заменить RXOffset= в секции [Modem], сохранив регистр
sub = subprocess.run(
["sudo","sed","-i",f"s/^RXOffset=.*/RXOffset={val}/",INI],
capture_output=True, text=True)
return sub.returncode == 0
def restart():
subprocess.run(["sudo","systemctl","restart",SVC],capture_output=True)
def watch_rf(dwell):
"""слушать журнал dwell секунд, вернуть (got_rf, ber)"""
t0 = time.time()
got = False; ber = None
proc = subprocess.Popen(
["sudo","journalctl","-u",SVC,"-f","--since","now","-o","cat"],
stdout=subprocess.PIPE, text=True)
try:
while time.time() - t0 < dwell:
line = proc.stdout.readline()
if not line: continue
if "received RF" in line or "RF voice" in line or "RF late entry" in line:
got = True
m = re.search(r"BER:\s*([\d.]+)", line)
if m: ber = float(m.group(1))
if "BER:" in line and "RF" in line:
m = re.search(r"BER:\s*([\d.]+)", line)
if m: ber = float(m.group(1))
finally:
proc.terminate()
return got, ber
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--range", type=int, default=1000, help="± Гц")
ap.add_argument("--step", type=int, default=100)
ap.add_argument("--dwell", type=int, default=7)
a = ap.parse_args()
offsets = list(range(-a.range, a.range+1, a.step))
# порядок: от центра наружу
offsets.sort(key=lambda x: abs(x))
print(f"перебор RXOffset {offsets[0]}..{offsets[-1]} Гц, шаг {a.step}, окно {a.dwell}с")
print("УДЕРЖИВАЙ PTT на DMR-канале рации во время КАЖДОГО шага!\n")
best = None
for off in offsets:
set_rxoffset(off); restart(); time.sleep(2)
print(f" RXOffset={off:+5d} Гц … жми PTT", flush=True)
got, ber = watch_rf(a.dwell)
if got:
print(f" ✅ ПОЙМАНО RF! BER={ber}")
if best is None or (ber is not None and ber < best[1]):
best = (off, ber if ber is not None else 99)
if ber is not None and ber < 3:
print(f" отличный BER — offset {off} рабочий, стоп")
break
if best:
set_rxoffset(best[0]); restart()
print(f"\nЛУЧШИЙ RXOffset={best[0]} Гц (BER {best[1]}) — записан и применён")
else:
print("\nне поймано — TX смещена сильнее диапазона или проблема в девиации, не частоте")
if __name__ == "__main__":
main()
+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()
+90
Просмотреть файл
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Чтение SPI-флеша рации через CPS-протокол (USART6) из WSL по powershell.exe.
Рация — в ОБЫЧНОМ режиме (не мост, не прошивка), кабель в COM. CPS-протокол:
notify : 34 00 00 10 44 -> ACK 06
read : 52 <off_hi> <off_lo> <cksum> -> 1028 Б (3 hdr + 1024 data + 1 cksum), offset в КБ
cksum : (sum) & 0xFF, seed 0
python3 tools/spi_read.py 0 16 --out cur_cal.bin # блоки 0..15 (первые 16 КБ)
python3 tools/spi_read.py 0 4096 --out full.bin # весь 4 МБ (долго)
"""
import argparse, subprocess, sys, os
WIN_TMP_WSL = "/mnt/c/Users/vikto/AppData/Local/Temp"
WIN_TMP_WIN = r"C:\Users\vikto\AppData\Local\Temp"
PS = r'''
$ErrorActionPreference='Stop'
$p = New-Object System.IO.Ports.SerialPort "{port}",115200,"None",8,"one"
$p.ReadTimeout=1500; $p.WriteTimeout=3000
try {{ $p.Open() }} catch {{ Write-Output "ERR:openfail"; exit 1 }}
function ck([byte[]]$a,$n){{ $s=0; for($i=0;$i -lt $n;$i++){{ $s=($s+$a[$i]) -band 0xFF }}; return [byte]$s }}
# notify
$p.DiscardInBuffer()
$nf=[byte[]](0x34,0x00,0x00,0x10,0x44)
$p.Write($nf,0,5)
$ok=$false; $sw=[Diagnostics.Stopwatch]::StartNew()
while($sw.ElapsedMilliseconds -lt 3000){{ try {{ if($p.ReadByte() -eq 6){{ $ok=$true; break }} }} catch {{}} }}
if(-not $ok){{ Write-Output "ERR:notify"; $p.Close(); exit 1 }}
$fs=[System.IO.File]::Open("{outwin}",[System.IO.FileMode]::Create)
for($off={first}; $off -lt {last}; $off++){{
$cmd=[byte[]](0x52,(($off -shr 8) -band 0xFF),($off -band 0xFF),0)
$cmd[3]=ck $cmd 3
$p.DiscardInBuffer(); $p.Write($cmd,0,4)
$buf=New-Object byte[] 1028; $got=0; $sw2=[Diagnostics.Stopwatch]::StartNew()
while($got -lt 1028 -and $sw2.ElapsedMilliseconds -lt 2500){{
try {{ $b=$p.ReadByte(); $buf[$got]=[byte]$b; $got++ }} catch {{}}
}}
if($got -lt 1028){{ Write-Output ("ERR:read@"+$off); $fs.Close(); $p.Close(); exit 1 }}
if($buf[0] -eq 0xFF){{ Write-Output ("ERR:noblk@"+$off); $fs.Close(); $p.Close(); exit 1 }}
$fs.Write($buf,3,1024)
if(($off % 64) -eq 0){{ Write-Output ("PROG:"+$off) }}
}}
$fs.Close()
$cl=[byte[]](0x34,0x52,0x05,0xEE,0x79); $p.Write($cl,0,5)
$p.Close()
Write-Output "OK"
'''
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 main():
ap=argparse.ArgumentParser()
ap.add_argument("first", type=lambda x:int(x,0))
ap.add_argument("last", type=lambda x:int(x,0))
ap.add_argument("--out", default="spi_read.bin")
ap.add_argument("--port")
a=ap.parse_args()
port=a.port or find_port()
name="rt4d_spiread.bin"
outwin=os.path.join(WIN_TMP_WIN,name)
script=PS.format(port=port,outwin=outwin.replace("\\","\\\\"),first=a.first,last=a.last)
print(f"[чтение SPI блоков {a.first}..{a.last-1} ({(a.last-a.first)} КБ), порт {port}]")
proc=subprocess.Popen(["powershell.exe","-NoProfile","-Command",script],
stdout=subprocess.PIPE,stderr=subprocess.STDOUT,text=True,encoding="utf-8",errors="replace")
for line in proc.stdout:
line=line.strip()
if line.startswith("PROG:"): print(f"\r блок {line[5:]}",end="",flush=True)
elif line.startswith("ERR:"): print("\n"+line);
elif line=="OK": print("\n чтение завершено")
proc.wait()
# забрать файл из Windows temp
src=os.path.join(WIN_TMP_WSL,name)
if os.path.exists(src):
import shutil; shutil.copy(src,a.out)
print(f"сохранено: {a.out} ({os.path.getsize(a.out)} Б)")
else:
print("файл не создан — чтение не удалось")
if __name__=="__main__":
main()
+117
Просмотреть файл
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Полное восстановление SPI рации из заводского эталона (radio-spi-dump.bin, до экспериментов).
Пишет все регионы codeplug/калибровки CPS-протоколом (USART6) из WSL через powershell.exe.
Рация — в ОБЫЧНОМ режиме. Возвращает рацию к состоянию на момент дампа.
Регионы (region_id, address, size) — из rt4d-cps constants.
"""
import argparse, subprocess, sys, os, json
WIN_TMP_WSL = "/mnt/c/Users/vikto/AppData/Local/Temp"
WIN_TMP_WIN = r"C:\Users\vikto\AppData\Local\Temp"
REF = "radio-spi-dump.bin"
REGIONS = [
("calibration", 0x40, 0x000000, 0x001000),
("main_settings", 0x90, 0x002000, 0x001000),
("channels", 0x91, 0x004000, 0x00C000),
("zones", 0x92, 0x01C000, 0x020000),
("contacts", 0x93, 0x05C000, 0x010000),
("groups", 0x94, 0x07C000, 0x003000),
("dmr_keys", 0x95, 0x082000, 0x003000),
("call_log", 0x96, 0x088000, 0x00C000),
("default_sms", 0x97, 0x094000, 0x001000),
("schedules", 0x98, 0x0C6000, 0x008000),
("fm_settings", 0x99, 0x0D6000, 0x001000),
]
PS = r'''
$ErrorActionPreference='Stop'
$data=[System.IO.File]::ReadAllBytes("{datawin}")
$plan=ConvertFrom-Json '{planjson}'
$p=New-Object System.IO.Ports.SerialPort "{port}",115200,"None",8,"one"
$p.ReadTimeout=1500; $p.WriteTimeout=3000
try {{ $p.Open() }} catch {{ Write-Output "ERR:openfail"; exit 1 }}
function ck([byte[]]$a,$n){{ $s=0; for($i=0;$i -lt $n;$i++){{ $s=($s+$a[$i]) -band 0xFF }}; return [byte]$s }}
$p.DiscardInBuffer()
$nf=[byte[]](0x34,0x00,0x00,0x10,0x44); $p.Write($nf,0,5)
$ok=$false; $sw=[Diagnostics.Stopwatch]::StartNew()
while($sw.ElapsedMilliseconds -lt 3000){{ try {{ if($p.ReadByte() -eq 6){{ $ok=$true; break }} }} catch {{}} }}
if(-not $ok){{ Write-Output "ERR:notify"; $p.Close(); exit 1 }}
Write-Output "INFO:notify ok"
$doff=0
foreach($r in $plan){{
$region=[int]$r.region; $size=[int]$r.size; $name=$r.name
Write-Output ("REGION:"+$name)
for($i=0; $i -lt $size; $i+=1024){{
$cmd=New-Object byte[] 1028
$cmd[0]=$region
$blk=[int]($i/1024)
$cmd[1]=(($blk -shr 8) -band 0xFF); $cmd[2]=($blk -band 0xFF)
[Array]::Copy($data,$doff+$i,$cmd,3,1024)
$cmd[1027]=ck $cmd 1027
$p.DiscardInBuffer(); $p.Write($cmd,0,1028)
$ok=$false; $sw2=[Diagnostics.Stopwatch]::StartNew()
while($sw2.ElapsedMilliseconds -lt 3000){{ try {{ if($p.ReadByte() -eq 6){{ $ok=$true; break }} }} catch {{}} }}
if(-not $ok){{ Write-Output ("ERR:"+$name+"@"+$blk); $p.Close(); exit 1 }}
}}
$doff += $size
}}
$cl=[byte[]](0x34,0x52,0x05,0xEE,0x79); $p.Write($cl,0,5)
$p.Close()
Write-Output "OK"
'''
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 main():
ap=argparse.ArgumentParser()
ap.add_argument("--only", help="только регионы через запятую (напр. calibration,main_settings)")
ap.add_argument("--port")
a=ap.parse_args()
ref=open(REF,"rb").read()
sel=REGIONS
if a.only:
want=set(a.only.split(","))
sel=[r for r in REGIONS if r[0] in want]
total=sum(r[3] for r in sel)
print("восстановление SPI из эталона (до экспериментов):")
for n,rid,addr,sz in sel:
print(f" {n:14s} region 0x{rid:02X} 0x{addr:06X} {sz//1024:4d} КБ")
print(f" ИТОГО {total//1024} КБ, ~{total//1024} блоков")
# собрать данные подряд в порядке регионов
blob=b"".join(ref[addr:addr+sz] for _,_,addr,sz in sel)
name="rt4d_restore.bin"
open(os.path.join(WIN_TMP_WSL,name),"wb").write(blob)
datawin=os.path.join(WIN_TMP_WIN,name)
plan=[{"name":n,"region":rid,"size":sz} for n,rid,addr,sz in sel]
port=a.port or find_port()
script=PS.format(datawin=datawin.replace("\\","\\\\"),port=port,
planjson=json.dumps(plan).replace('"','\\"'))
print()
proc=subprocess.Popen(["powershell.exe","-NoProfile","-Command",script],
stdout=subprocess.PIPE,stderr=subprocess.STDOUT,text=True,encoding="utf-8",errors="replace")
rc=0
for line in proc.stdout:
line=line.strip()
if line.startswith("REGION:"): print(f"{line[7:]}")
elif line.startswith("INFO:"): print(f" {line[5:]}")
elif line.startswith("ERR:"): print(f" ОШИБКА: {line[4:]}"); rc=1
elif line=="OK": print(" ✅ SPI восстановлен из эталона")
proc.wait()
sys.exit(rc)
if __name__=="__main__":
main()
+38
Просмотреть файл
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""spi_wr.py REGION SRCADDR SIZE — пишет SIZE байт из radio-spi-dump.bin[SRCADDR]
в SPI рации регионом REGION (блоки 0..). Рация в обычном режиме. CPS через powershell."""
import subprocess, sys, os
WIN_TMP_WSL="/mnt/c/Users/vikto/AppData/Local/Temp"; WIN_TMP_WIN=r"C:\Users\vikto\AppData\Local\Temp"
region=int(sys.argv[1],0); srcaddr=int(sys.argv[2],0); size=int(sys.argv[3],0)
ref=open("radio-spi-dump.bin","rb").read()
data=ref[srcaddr:srcaddr+size]
open(os.path.join(WIN_TMP_WSL,"rt4d_wr.bin"),"wb").write(data)
dw=os.path.join(WIN_TMP_WIN,"rt4d_wr.bin").replace("\\","\\\\")
out=subprocess.run(["powershell.exe","-NoProfile","-Command","[System.IO.Ports.SerialPort]::GetPortNames() -join ','"],capture_output=True,text=True).stdout
port=[x for x in out.strip().replace("\r","").split(",") if x][0]
PS=f'''
$ErrorActionPreference='Stop'
$data=[System.IO.File]::ReadAllBytes("{dw}")
$p=New-Object System.IO.Ports.SerialPort "{port}",115200,"None",8,"one"
$p.ReadTimeout=1500;$p.WriteTimeout=3000
$p.Open()
function ck([byte[]]$a,$n){{$s=0;for($i=0;$i -lt $n;$i++){{$s=($s+$a[$i]) -band 0xFF}};return [byte]$s}}
$p.DiscardInBuffer();$nf=[byte[]](0x34,0x00,0x00,0x10,0x44);$p.Write($nf,0,5)
$ok=$false;$sw=[Diagnostics.Stopwatch]::StartNew()
while($sw.ElapsedMilliseconds -lt 3000){{try{{if($p.ReadByte() -eq 6){{$ok=$true;break}}}}catch{{}}}}
if(-not $ok){{Write-Output "ERR:notify";exit 1}}
Write-Output "notify ok"
for($i=0;$i -lt {size};$i+=1024){{
$cmd=New-Object byte[] 1028;$cmd[0]={region}
$blk=[int]($i/1024);$cmd[1]=(($blk -shr 8) -band 0xFF);$cmd[2]=($blk -band 0xFF)
[Array]::Copy($data,$i,$cmd,3,1024);$cmd[1027]=ck $cmd 1027
$p.DiscardInBuffer();$p.Write($cmd,0,1028)
$ok=$false;$sw2=[Diagnostics.Stopwatch]::StartNew()
while($sw2.ElapsedMilliseconds -lt 3000){{try{{if($p.ReadByte() -eq 6){{$ok=$true;break}}}}catch{{}}}}
if(-not $ok){{Write-Output ("ERR@"+$blk);exit 1}}
Write-Output ("blk "+$blk)
}}
$cl=[byte[]](0x34,0x52,0x05,0xEE,0x79);$p.Write($cl,0,5);$p.Close();Write-Output "OK"
'''
r=subprocess.run(["powershell.exe","-NoProfile","-Command",PS],capture_output=True,text=True,encoding="utf-8",errors="replace")
print(r.stdout.strip()[-400:])
+99
Просмотреть файл
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Восстановление калибровки рации (SPI region 0x40, 4 КБ) из заводского эталона.
Пишет 4 КБ калибровки CPS-протоколом (USART6) из WSL через powershell.exe.
Источник — radio-spi-dump.bin[0:0x1000] (снят ДО экспериментов).
Протокол записи (CPS):
notify : 34 00 00 10 44 -> ACK 06
write : <region> <blkHi> <blkLo> <1024 Б> <cksum> -> ACK 06 (region=0x40 калибровка)
cksum : sum & 0xFF, seed 0
python3 tools/spi_write_cal.py # запись region 0x40 из эталона (с подтверждением)
python3 tools/spi_write_cal.py --yes # без подтверждения
"""
import argparse, subprocess, sys, os, shutil
WIN_TMP_WSL = "/mnt/c/Users/vikto/AppData/Local/Temp"
WIN_TMP_WIN = r"C:\Users\vikto\AppData\Local\Temp"
REF = "radio-spi-dump.bin"
REGION = 0x40 # calibration
SIZE = 0x1000 # 4 КБ
PS = r'''
$ErrorActionPreference='Stop'
$data=[System.IO.File]::ReadAllBytes("{datawin}")
$p=New-Object System.IO.Ports.SerialPort "{port}",115200,"None",8,"one"
$p.ReadTimeout=1500; $p.WriteTimeout=3000
try {{ $p.Open() }} catch {{ Write-Output "ERR:openfail"; exit 1 }}
function ck([byte[]]$a,$n){{ $s=0; for($i=0;$i -lt $n;$i++){{ $s=($s+$a[$i]) -band 0xFF }}; return [byte]$s }}
$p.DiscardInBuffer()
$nf=[byte[]](0x34,0x00,0x00,0x10,0x44); $p.Write($nf,0,5)
$ok=$false; $sw=[Diagnostics.Stopwatch]::StartNew()
while($sw.ElapsedMilliseconds -lt 3000){{ try {{ if($p.ReadByte() -eq 6){{ $ok=$true; break }} }} catch {{}} }}
if(-not $ok){{ Write-Output "ERR:notify"; $p.Close(); exit 1 }}
Write-Output "INFO:notify ok"
$region={region}; $size={size}
for($i=0; $i -lt $size; $i+=1024){{
$cmd=New-Object byte[] 1028
$cmd[0]=$region
$blk=[int]($i/1024)
$cmd[1]=(($blk -shr 8) -band 0xFF); $cmd[2]=($blk -band 0xFF)
[Array]::Copy($data,$i,$cmd,3,1024)
$cmd[1027]=ck $cmd 1027
$p.DiscardInBuffer(); $p.Write($cmd,0,1028)
$ok=$false; $sw2=[Diagnostics.Stopwatch]::StartNew()
while($sw2.ElapsedMilliseconds -lt 3000){{ try {{ if($p.ReadByte() -eq 6){{ $ok=$true; break }} }} catch {{}} }}
if(-not $ok){{ Write-Output ("ERR:write@"+$blk); $p.Close(); exit 1 }}
Write-Output ("PROG:"+$blk)
}}
$cl=[byte[]](0x34,0x52,0x05,0xEE,0x79); $p.Write($cl,0,5)
$p.Close()
Write-Output "OK"
'''
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 main():
ap=argparse.ArgumentParser()
ap.add_argument("--yes", action="store_true")
ap.add_argument("--port")
a=ap.parse_args()
ref=open(REF,"rb").read()
cal=ref[:SIZE]
print(f"калибровка из эталона: {SIZE} Б, region 0x{REGION:02X}")
print(f" начало: {cal[:16].hex(' ')}")
if not a.yes:
print("\nЗАПИСЬ в SPI-калибровку рации. Рация должна быть в ОБЫЧНОМ режиме.")
if input("продолжить? [y/N] ").strip().lower() != "y":
print("отменено"); return
name="rt4d_cal.bin"
open(os.path.join(WIN_TMP_WSL,name),"wb").write(cal)
datawin=os.path.join(WIN_TMP_WIN,name)
port=a.port or find_port()
script=PS.format(datawin=datawin.replace("\\","\\\\"),port=port,region=REGION,size=SIZE)
proc=subprocess.Popen(["powershell.exe","-NoProfile","-Command",script],
stdout=subprocess.PIPE,stderr=subprocess.STDOUT,text=True,encoding="utf-8",errors="replace")
rc=0
for line in proc.stdout:
line=line.strip()
if line.startswith("PROG:"): print(f" записан блок {line[5:]}")
elif line.startswith("INFO:"): print(f" {line[5:]}")
elif line.startswith("ERR:"): print(f" ОШИБКА: {line[4:]}"); rc=1
elif line=="OK": print(" ✅ калибровка восстановлена")
proc.wait()
sys.exit(rc)
if __name__=="__main__":
main()