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).
Этот коммит содержится в:
@@ -18,6 +18,11 @@
|
||||
- ✅ **Прошивка разложена на запчасти:** два процессора + внешний SPI = три независимых канала внедрения. Карта — [`docs/firmware-parts.md`](docs/firmware-parts.md).
|
||||
- ✅ **Найден штатный загрузчик ресурсов** (`Unicode Download.exe`, внутри `FontVoicePicture`) — официальный путь замены **шрифтов, голосовых подсказок и картинок** (в т.ч. загрузочного логотипа) без патча MCU.
|
||||
- ✅ **Разобран спектроанализатор 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/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/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 в обе стороны).
|
||||
|
||||
## История извлечения прошивки
|
||||
|
||||
Двоичные данные
Двоичный файл не отображается.
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
@@ -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` — интерактивный агент (обычный режим).
|
||||
@@ -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`).
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
@@ -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,"<")}</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,""")+'"></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>
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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:])
|
||||
@@ -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()
|
||||
Ссылка в новой задаче
Block a user