Восстановлено из транскрипта сессии после потери рабочего дерева на сервере сборки. Что внутри: - четыре новых модуля прошивки (приём, сборка исходящего, меню Messages); - правки существующих файлов в виде скриптов, с порядком применения; - инструменты стенда: снятие экрана рации по USB, дамп кадров, эталонная сборка пакета; - NOTES.md: что проверено в эфире, что не доделано, где грабли. Работало в эфире в августе 2026: данные ходят в обе стороны в режиме хотспота, рация принимает SMS и показывает на экране. Отправка собрана и сверена с эталоном сервера побайтово, но в эфире не проверялась. Claude-Session: https://claude.ai/code/session_01GJtvG5WwqYNe1b7s9LLtgy
116 строки
3.5 KiB
Python
116 строки
3.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Grab the radio's screen over USB and save it as a PNG.
|
|
|
|
Uses the CPS read command (area 6 = display buffer), which the firmware answers only
|
|
while the radio is NOT in modem mode - and, importantly, such requests do not switch it
|
|
into modem mode either. So the screen can be watched while the radio does its normal job.
|
|
|
|
Display is 160x128, RGB565, 2 bytes per pixel.
|
|
"""
|
|
import os, sys, termios, time, zlib, struct, argparse
|
|
|
|
WIDTH, HEIGHT = 160, 128
|
|
CPS_ACCESS_DISPLAY_BUFFER = 6
|
|
CHUNK = 1024
|
|
|
|
|
|
def open_port(path):
|
|
fd = os.open(path, os.O_RDWR | os.O_NOCTTY)
|
|
a = termios.tcgetattr(fd)
|
|
a[0] = 0
|
|
a[1] = 0
|
|
a[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
|
|
a[3] = 0
|
|
a[6][termios.VMIN] = 0
|
|
a[6][termios.VTIME] = 20
|
|
termios.tcsetattr(fd, termios.TCSANOW, a)
|
|
termios.tcflush(fd, termios.TCIOFLUSH)
|
|
return fd
|
|
|
|
|
|
def read_exact(fd, n, timeout=3.0):
|
|
buf = b""
|
|
deadline = time.time() + timeout
|
|
while len(buf) < n and time.time() < deadline:
|
|
try:
|
|
part = os.read(fd, n - len(buf))
|
|
except BlockingIOError:
|
|
part = b""
|
|
if part:
|
|
buf += part
|
|
else:
|
|
time.sleep(0.003)
|
|
return buf
|
|
|
|
|
|
def read_block(fd, area, addr, length):
|
|
req = bytes([ord('R'), area,
|
|
(addr >> 24) & 0xFF, (addr >> 16) & 0xFF, (addr >> 8) & 0xFF, addr & 0xFF,
|
|
(length >> 8) & 0xFF, length & 0xFF])
|
|
termios.tcflush(fd, termios.TCIFLUSH)
|
|
os.write(fd, req)
|
|
head = read_exact(fd, 3)
|
|
if len(head) < 3 or head[0:1] != b'R':
|
|
return None
|
|
got = (head[1] << 8) | head[2]
|
|
data = read_exact(fd, got)
|
|
return data if len(data) == got else None
|
|
|
|
|
|
def write_png(path, rgb_rows):
|
|
"""Minimal PNG writer (stdlib only)."""
|
|
raw = b"".join(b"\x00" + row for row in rgb_rows)
|
|
|
|
def chunk(tag, data):
|
|
c = struct.pack(">I", len(data)) + tag + data
|
|
return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
|
|
|
png = b"\x89PNG\r\n\x1a\n"
|
|
png += chunk(b"IHDR", struct.pack(">IIBBBBB", WIDTH, HEIGHT, 8, 2, 0, 0, 0))
|
|
png += chunk(b"IDAT", zlib.compress(raw, 6))
|
|
png += chunk(b"IEND", b"")
|
|
open(path, "wb").write(png)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--port", default="/dev/ttyACM0")
|
|
ap.add_argument("--out", default="/home/viktor/screen.png")
|
|
args = ap.parse_args()
|
|
|
|
fd = open_port(args.port)
|
|
try:
|
|
total = WIDTH * HEIGHT * 2
|
|
data = b""
|
|
while len(data) < total:
|
|
n = min(CHUNK, total - len(data))
|
|
part = read_block(fd, CPS_ACCESS_DISPLAY_BUFFER, len(data), n)
|
|
if part is None:
|
|
print("read failed at offset %d - is the radio in modem mode?" % len(data))
|
|
return 2
|
|
data += part
|
|
finally:
|
|
os.close(fd)
|
|
|
|
rows = []
|
|
for y in range(HEIGHT):
|
|
row = bytearray()
|
|
for x in range(WIDTH):
|
|
i = (y * WIDTH + x) * 2
|
|
# RGB565, little endian as stored by the display driver
|
|
v = data[i] | (data[i + 1] << 8)
|
|
r = ((v >> 11) & 0x1F) << 3
|
|
g = ((v >> 5) & 0x3F) << 2
|
|
b = (v & 0x1F) << 3
|
|
row += bytes((r, g, b))
|
|
rows.append(bytes(row))
|
|
|
|
write_png(args.out, rows)
|
|
nonblack = sum(1 for i in range(0, len(data), 2) if data[i] or data[i + 1])
|
|
print("saved %s (%d of %d pixels non-black)" % (args.out, nonblack, WIDTH * HEIGHT))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|