Files
rt-4d/flasher/build_exe.py
T
viktor 5f4d207aa5 Мост к DMR-модулю, разбор спектра REFV DualTachyon, карта запчастей прошивки
Баузбенд FM100B:
- найден прозрачный мост ПК<->модуль (включение с зажатой МЕНЮ), проверен живьём
- расшифрована контрольная сумма кадров (one's complement, BE), сверена с ответом рации
- инструмент tools/fm100b.py (ping/send/raw/scan)
- cmd 0x25 = запрос версии, модуль отвечает V1.2.0.32 (совпало с офиц. образом)
- разобран диспетчер входящих кадров: 193 записи, реальный код у 10 команд
- cmd 0x59 = индикация приёма, она же управляет гейтом звука PA14
- исправлено: boot-handshake это cmd 0x84, а не 0x64 (research/re/dmr.md)

Спектроанализатор REFV DualTachyon (docs/refw-spectrum.md):
- вызывается как функция горячей клавиши №22 Analog Spectrum
- вход 0x08009CAA -> обычный 0x080139E4 / по зоне 0x08015318
- спектр это экран №11; тик 0x08013BA4 (автомат на 4 состояния), клавиши 0x08013BFC

Декомпозиция (docs/firmware-parts.md):
- два процессора + внешний SPI = три канала внедрения
- карта ресурсов SPI: шрифты, пиньинь, голос, таблица Unicode
- найден штатный загрузчик ресурсов FontVoicePicture (шрифты/голос/картинки)
- дерево меню целиком: MIC/SPK Gain, RX/TX Limit, SMS Format уже в стоке
- аудио двухступенчатое: PA2 питание УНЧ, PA14 гейт от DMR-модуля

Прочее:
- везде исправлен режим прошивки: тангента PTT вместо клавиши "*"
- устаревший Ru-4D_Flasher.exe удалён из репозитория
- добавлены инструменты реверса: xref, refs, gpiomap, gpioscan, schem
2026-07-22 21:17:20 +09:00

166 строки
7.2 KiB
Python

#!/usr/bin/env python3
"""Generate a self-contained tkinter GUI flasher (firmware embedded) and build
it into a single Windows .exe with PyInstaller."""
import base64, os, subprocess, sys
# project root = parent of this script's directory (portable)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FW = f"{ROOT}/stock-fw/rt4d_ru_full.bin"
DIST = f"{ROOT}/dist"
fw = open(FW, "rb").read()
b64 = base64.b64encode(fw).decode()
info = f"Ru-4D V3.25 (русифицированная) - {len(fw)} байт"
GUI = r'''# -*- coding: utf-8 -*-
import base64, time, threading, queue
import tkinter as tk
from tkinter import ttk
import serial
from serial.tools import list_ports
FW = base64.b64decode("__B64__")
INFO = "__INFO__"
SEED = 0x48
def cksum(b):
return bytes([(SEED + sum(b)) & 0xFF])
def find_ports():
return list(list_ports.comports())
def auto_port(ports):
for p in ports: # CH343 = VID 0x1A86
if (p.vid or 0) == 0x1A86:
return p.device
return ports[0].device if ports else None
def do_flash(port_name, log, prog):
p = serial.Serial(port_name, 115200, timeout=0.3, write_timeout=3)
try:
log("Порт " + port_name + " открыт. Синхронизация...")
p.reset_input_buffer()
ready = False; t0 = time.time()
while time.time() - t0 < 7:
b = p.read(1)
if not b: p.write(b"\xff")
elif b == b"\xff": ready = True; break
if not ready:
raise RuntimeError("Рация не отвечает. Выключите и включите её\nс зажатой тангентой PTT, затем повторите.")
log("Бутлоадер найден. Стирание...")
for trig in (0x10, 0x55):
pl = bytes([0x39, 0x33, 0x05, trig]); pl += cksum(pl)
p.reset_input_buffer(); p.write(pl)
ok = False; t = time.time()
while time.time() - t < 4:
if p.read(1) == b"\x06": ok = True; break
if not ok: raise RuntimeError("Ошибка стирания.")
log("Запись прошивки...")
pad = (-len(FW)) % 1024
data = FW + b"\xff" * pad; total = len(data)
for off in range(0, total, 1024):
chunk = data[off:off + 1024]
pl = bytes([0x57, (off >> 8) & 0xFF, off & 0xFF]) + chunk; pl += cksum(pl)
p.reset_input_buffer(); p.write(pl)
ok = False; t = time.time()
while time.time() - t < 3:
if p.read(1) == b"\x06": ok = True; break
if not ok: raise RuntimeError("Ошибка записи на 0x%X." % off)
prog((off + 1024) / total)
prog(1.0)
log("[OK] Готово! Выключите и включите рацию обычно.")
finally:
p.close()
class App:
def __init__(self, root):
self.root = root; self.q = queue.Queue(); self.busy = False
root.title("Ru-4D - Прошивка рации"); root.geometry("460x520"); root.resizable(False, False)
root.configure(bg="#131a24")
F = dict(bg="#131a24", fg="#dfe7ef")
tk.Label(root, text="Ru-4D - Прошивка рации", font=("Segoe UI", 15, "bold"), **F).pack(pady=(16, 0))
tk.Label(root, text=INFO, font=("Segoe UI", 9), bg="#131a24", fg="#7f8ea3").pack()
steps = ("1. Выключите рацию.\n"
"2. Включите её, удерживая тангенту PTT (режим прошивки).\n"
"3. Подключите рацию к ПК кабелем USB-C.\n"
"4. Выберите COM-порт и нажмите Прошить .\n"
"5. Не отключайте кабель до конца записи.")
tk.Label(root, text=steps, justify="left", font=("Segoe UI", 10), bg="#131a24", fg="#c3cdd9").pack(padx=18, pady=12, anchor="w")
row = tk.Frame(root, bg="#131a24"); row.pack(fill="x", padx=18)
tk.Label(row, text="Порт:", **F).pack(side="left")
self.port_var = tk.StringVar()
self.port_cb = ttk.Combobox(row, textvariable=self.port_var, state="readonly", width=28)
self.port_cb.pack(side="left", padx=6)
tk.Button(row, text="↻", command=self.refresh, width=3).pack(side="left")
self.btn = tk.Button(root, text="Прошить", font=("Segoe UI", 13, "bold"),
bg="#22bb77", fg="#04120b", activebackground="#2ad", relief="flat",
command=self.start)
self.btn.pack(fill="x", padx=18, pady=(14, 6))
self.pb = ttk.Progressbar(root, maximum=1000); self.pb.pack(fill="x", padx=18, pady=4)
self.log = tk.Text(root, height=9, font=("Consolas", 9), bg="#0c1219", fg="#9fb0c2",
relief="flat", wrap="word"); self.log.pack(fill="both", expand=True, padx=18, pady=(6, 14))
self.refresh(); self.poll()
def refresh(self):
ports = find_ports()
self.port_cb["values"] = [f"{p.device} - {p.description}" for p in ports]
ap = auto_port(ports)
if ap:
for i, p in enumerate(ports):
if p.device == ap: self.port_cb.current(i); break
def logline(self, m, tag=None):
self.log.insert("end", m + "\n"); self.log.see("end")
def prog(self, f): self.pb["value"] = int(f * 1000)
def start(self):
if self.busy: return
sel = self.port_var.get()
if not sel:
self.logline("Выберите COM-порт (подключите рацию в режиме прошивки)."); return
port = sel.split(" - ")[0]
self.busy = True; self.btn.config(state="disabled"); self.log.delete("1.0", "end"); self.pb["value"] = 0
threading.Thread(target=self.worker, args=(port,), daemon=True).start()
def worker(self, port):
try:
do_flash(port, lambda m: self.q.put(("log", m)), lambda f: self.q.put(("prog", f)))
except Exception as e:
self.q.put(("log", "[ОШИБКА] " + str(e)))
self.q.put(("done", None))
def poll(self):
try:
while True:
k, v = self.q.get_nowait()
if k == "log": self.logline(v)
elif k == "prog": self.prog(v)
elif k == "done": self.busy = False; self.btn.config(state="normal")
except queue.Empty:
pass
self.root.after(60, self.poll)
if __name__ == "__main__":
root = tk.Tk()
try:
App(root); root.mainloop()
except Exception as e:
import traceback; traceback.print_exc()
'''
gui = GUI.replace("__B64__", b64).replace("__INFO__", info)
os.makedirs(DIST, exist_ok=True)
gui_path = f"{DIST}/flasher_gui.py"
open(gui_path, "w", encoding="utf-8").write(gui)
print("wrote", gui_path, len(gui), "bytes")
cmd = [sys.executable, "-m", "PyInstaller", "--onefile", "--windowed",
"--name", "Ru-4D_Flasher", "--distpath", DIST,
"--workpath", f"{DIST}/build", "--specpath", f"{DIST}/build",
"--clean", "--noconfirm", gui_path]
print("running:", " ".join(cmd[2:]))
r = subprocess.run(cmd, capture_output=True, text=True)
print(r.stdout[-1500:]); print(r.stderr[-1500:])
print("EXIT", r.returncode)