Extract reusable device protocols, ports and logic analyzers from SETGUI

This commit is contained in:
2026-09-23 20:11:37 +03:00
parent 80ba17d77d
commit 795a1279b1
63 changed files with 10181 additions and 6 deletions

View File

@@ -0,0 +1,358 @@
# -*- coding: utf-8 -*-
"""Посылка ПЧ -> терминал прошивки ТМС (``TMS_TO_TERMINAL``).
Это третий формат в проекте: не протокол SETGUI (``A5 5A``) из ``protocol.py``
и не транспорт моста (``AA 55``) из ``can_transport.py``, а ответ контроллера
преобразователя частоты на TMS320F281x, который он шлёт по RS485 в терминал.
Раскладка структуры (tempFold/message2.h, tempFold/RS_Functions.h)::
head Address, Number 2 байта
digit_data ANS_DIGIT_DATA_TO_TERMINAL N байт
analog_data TMS_ANALOG_DATA, каждое lo, hi 2*M байт
crc_lo, crc_hi 2 байта
add_byte 1 байт
CRC считается по всей посылке кроме последних трёх байт (``sizeof - 3``),
начальное значение 0xFFFF, кладётся младшим байтом вперёд.
Числа N и M у разных сборок прошивки разные: в ``RS_Functions.h`` это 28 и 30,
в ``message2.h`` того же прибора — уже 38 и 34 (в исходнике видно ``//add``
перед byte29). Поэтому раскладка здесь — параметр, а не константа.
Байтов синхронизации в формате нет — прошивка полагается на паузу в линии.
Поэтому разборщик ищет границу посылки перебором смещений по CRC, а не по
сигнатуре; заодно это гасит лишний байт, который ``RS_Send`` шлёт сверх
структуры (``sizeof(TMS_TO_TERMINAL)+1``).
"""
from __future__ import annotations
import random
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional
#: Head (2) + CRC (2) + add_byte (1): всё, что не данные.
FRAME_OVERHEAD = 5
#: CRC не покрывает crc_lo, crc_hi и add_byte.
CRC_TAIL = 3
#: Границы настройки в GUI: меньше одного поля смысла нет, больше не бывает.
MIN_DIGIT_BYTES = 1
MAX_DIGIT_BYTES = 64
MIN_ANALOG_COUNT = 1
MAX_ANALOG_COUNT = 64
#: Номера команд, enum в конце RS_Functions.h.
COMMAND_NAMES: Dict[int, str] = {
51: "CMD_LOAD",
52: "CMD_UPLOAD",
53: "CMD_RUN",
54: "CMD_XFLASH",
55: "CMD_TFLASH",
56: "CMD_PEEK",
57: "CMD_POKE",
58: "CMD_INITLOAD",
59: "CMD_INIT",
60: "CMD_EXTEND",
61: "CMD_VECTOR",
62: "CMD_IMPULSE",
65: "CMD_STD",
66: "CMD_TEST_ALL",
67: "CMD_STD_ANS",
}
#: Назначение аналоговых величин по комментариям в message2.c (ReceiveCommand).
ANALOG_NAMES = [
"FREQ_OUT (Гц*100)",
"FREQ_PWM",
"K_MOD_INV_1",
"K_MOD_INV_2",
"SENS_ROT0",
"SENS_ROT1",
"U_ZPT_1+",
"U_ZPT_1-",
"I_OUT_INV1",
"I_OUT_INV2",
"F_ROT",
"DIR_ROT",
"U_ZPT_2+",
"U_ZPT_2-",
"SENS_ROT_2",
"SENS_ROT_3",
"УКСС8 рег. 30",
"УКСС8 рег. 31",
"TORQ",
"log13",
"log17",
"УКСС3 рег. 40",
"УКСС3 рег. 42",
"УКСС2 рег. 40 (BTR)",
"УКСС2 рег. 42 (BTR)",
"TEMP_GED (Uin УМП1)",
"TEMP_BEARING (Uin УМП2)",
"POWER",
"Uenter_1",
"Uenter_2",
"УМП1 задание 4-20",
"УМП1 обратная связь 4-20",
"УМП2 задание 4-20",
"УМП2 обратная связь 4-20",
]
def analog_name(index: int) -> str:
"""Имя аналоговой величины по индексу с нуля; за таблицей — просто номер."""
if index < len(ANALOG_NAMES):
return ANALOG_NAMES[index]
return "аналог %d" % (index + 1)
def crc16_modbus(data: bytes, crc: int = 0xFFFF) -> int:
"""CRC-16/MODBUS: poly 0xA001 (рефлексия 0x8005), init 0xFFFF."""
for byte in data:
crc ^= byte
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return crc & 0xFFFF
def crc16_ccitt_false(data: bytes, crc: int = 0xFFFF) -> int:
"""CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, без рефлексии."""
for byte in data:
crc ^= byte << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
return crc
#: Самого ``crc16.h`` прошивки под рукой нет, поэтому вариант выбирает оператор.
CRC_VARIANTS: Dict[str, Callable[[bytes], int]] = {
"MODBUS": crc16_modbus,
"CCITT-FALSE": crc16_ccitt_false,
}
DEFAULT_CRC = "MODBUS"
@dataclass(frozen=True)
class Layout:
"""@brief Раскладка посылки: сколько байт дискретных величин и аналогов."""
digit_bytes: int
analog_count: int
crc_name: str = DEFAULT_CRC
def __post_init__(self) -> None:
if not MIN_DIGIT_BYTES <= self.digit_bytes <= MAX_DIGIT_BYTES:
raise ValueError("байт дискретных величин вне диапазона %d..%d"
% (MIN_DIGIT_BYTES, MAX_DIGIT_BYTES))
if not MIN_ANALOG_COUNT <= self.analog_count <= MAX_ANALOG_COUNT:
raise ValueError("аналоговых величин вне диапазона %d..%d"
% (MIN_ANALOG_COUNT, MAX_ANALOG_COUNT))
if self.crc_name not in CRC_VARIANTS:
raise ValueError("неизвестный вариант CRC: %s" % self.crc_name)
@property
def frame_size(self) -> int:
"""Полный размер ``TMS_TO_TERMINAL`` в байтах."""
return FRAME_OVERHEAD + self.digit_bytes + 2 * self.analog_count
@property
def analog_offset(self) -> int:
return 2 + self.digit_bytes
def crc(self, data: bytes) -> int:
return CRC_VARIANTS[self.crc_name](data)
#: Готовые раскладки из исходников: оператору не нужно считать байты руками.
PRESETS: Dict[str, Layout] = {
"BALZAM_7 (28/34)": Layout(28, 34),
"message2.h (38/34)": Layout(38, 34),
"RS_Functions.h (28/30)": Layout(28, 30),
"TEST_ALL (24/24)": Layout(24, 24),
}
DEFAULT_PRESET = "message2.h (38/34)"
def build_std_request(address: int, analogs: List[int], digits: bytes) -> bytes:
"""BALZAM_7 CMD_TO_TMS without add_byte (RS485.c: size_cmd15 - 1).
CMD_STD applies all four setpoints and six control bytes, even when the
caller only wants a telemetry reply. Never substitute an empty poll.
"""
if not 0 <= address <= 255:
raise ValueError("Адрес должен быть 0…255")
if len(analogs) != 4 or len(digits) != 6:
raise ValueError("CMD_STD: требуется 4 аналоговых значения и 6 байт дискретов")
if any(not -32768 <= value <= 32767 for value in analogs):
raise ValueError("Аналоговые значения должны быть -32768…32767")
body = bytes((address, 65))
body += b"".join(value.to_bytes(2, "little", signed=True) for value in analogs)
body += digits
return body + crc16_modbus(body).to_bytes(2, "little")
def default_layout() -> Layout:
"""Раскладка по умолчанию — самая свежая из исходников."""
return PRESETS[DEFAULT_PRESET]
@dataclass
class TmsRecord:
"""@brief Одна разобранная посылка ПЧ -> терминал."""
address: int
number: int
digits: bytes
analogs: List[int] # знаковые int16
crc_frame: int
crc_calc: int
add_byte: int
raw: bytes
layout: Layout = field(default_factory=default_layout)
@property
def crc_ok(self) -> bool:
return self.crc_frame == self.crc_calc
@property
def command(self) -> str:
"""Номер команды словом; неизвестные остаются числом."""
name = COMMAND_NAMES.get(self.number)
return "%s (%d)" % (name, self.number) if name else str(self.number)
def bits(self, byte_index: int) -> List[int]:
"""Биты одного байта дискретных величин от bit0 к bit7."""
value = self.digits[byte_index]
return [(value >> bit) & 1 for bit in range(8)]
def set_bits(self) -> List[str]:
"""Имена взведённых битов вида ``byte05.bit3`` — для сводки в журнале."""
return ["byte%02d.bit%d" % (index + 1, bit)
for index, value in enumerate(self.digits)
for bit in range(8) if value >> bit & 1]
@property
def summary(self) -> str:
head = "адрес %d, %s" % (self.address, self.command)
if not self.crc_ok:
return "%s, CRC не сошлась" % head
return "%s, битов взведено: %d" % (head, len(self.set_bits()))
def decode(raw: bytes, layout: Optional[Layout] = None) -> TmsRecord:
"""@brief Раскладывает готовые байты посылки по полям.
@param raw Ровно ``layout.frame_size`` байт.
@param layout Раскладка; по умолчанию из ``message2.h``.
@throws ValueError если длина не совпадает с раскладкой.
"""
layout = layout or default_layout()
if len(raw) != layout.frame_size:
raise ValueError("ожидалось %d байт, получено %d"
% (layout.frame_size, len(raw)))
analogs = []
for index in range(layout.analog_count):
offset = layout.analog_offset + 2 * index
analogs.append(int.from_bytes(raw[offset:offset + 2], "little", signed=True))
return TmsRecord(
address=raw[0],
number=raw[1],
digits=bytes(raw[2:layout.analog_offset]),
analogs=analogs,
crc_frame=int.from_bytes(raw[-3:-1], "little"),
crc_calc=layout.crc(raw[:-CRC_TAIL]),
add_byte=raw[-1],
raw=bytes(raw),
layout=layout,
)
def build(address: int, number: int, digits: bytes, analogs: List[int],
layout: Optional[Layout] = None, add_byte: int = 0) -> bytes:
"""@brief Собирает посылку — нужна демо-потоку и тестам разбора."""
layout = layout or default_layout()
if len(digits) != layout.digit_bytes:
raise ValueError("ожидалось %d байт дискретных величин" % layout.digit_bytes)
if len(analogs) != layout.analog_count:
raise ValueError("ожидалось %d аналоговых величин" % layout.analog_count)
body = bytearray([address & 0xFF, number & 0xFF])
body += bytes(digits)
for value in analogs:
body += int(value).to_bytes(2, "little", signed=True)
body += layout.crc(bytes(body)).to_bytes(2, "little")
body.append(add_byte & 0xFF)
return bytes(body)
class TmsParser:
"""@brief Потоковый разборщик посылок ТМС.
Сигнатуры у формата нет, поэтому границу ищем перебором смещений: посылка
считается найденной там, где сходится CRC. Каждый пропущенный при поиске
байт идёт в ``resync_bytes``.
Раскладка меняется на лету (``set_layout``) — оператор подбирает число
полей прямо во время приёма.
"""
def __init__(self, layout: Optional[Layout] = None) -> None:
self._buf = bytearray()
self.layout = layout or default_layout()
self.stats = {"frames": 0, "resync_bytes": 0}
def set_layout(self, layout: Layout) -> None:
"""Меняет раскладку и сбрасывает недособранный хвост буфера."""
self.layout = layout
self._buf.clear()
def reset(self) -> None:
self._buf.clear()
self.stats.update(frames=0, resync_bytes=0)
def feed(self, chunk: bytes) -> List[TmsRecord]:
"""@brief Принимает кусок потока и отдаёт все распознанные посылки."""
self._buf += chunk
size = self.layout.frame_size
records: List[TmsRecord] = []
while len(self._buf) >= size:
candidate = bytes(self._buf[:size])
if self.layout.crc(candidate[:-CRC_TAIL]) == int.from_bytes(
candidate[-3:-1], "little"):
records.append(decode(candidate, self.layout))
self.stats["frames"] += 1
del self._buf[:size]
continue
del self._buf[:1]
self.stats["resync_bytes"] += 1
return records
class TmsDemoSource:
"""@brief Источник демонстрационных посылок ТМС: разбор без железа.
Повторяет то, что кладёт в линию ``RS_Send``, включая лишний байт сверх
структуры, — на нём и проверяется ресинхронизация разборщика.
@param layout Раскладка выдаваемых посылок.
@param rng Источник случайности; передаётся в тестах для повторяемости.
"""
def __init__(self, layout: Optional[Layout] = None,
rng: Optional[random.Random] = None) -> None:
self.layout = layout or default_layout()
self._rng = rng or random.Random()
self._tick = 0
def next_frame(self) -> bytes:
"""@brief Очередная посылка плюс хвостовой байт от ``RS_Send``."""
self._tick += 1
digits = bytes(self._rng.getrandbits(8)
for _ in range(self.layout.digit_bytes))
analogs = [self._rng.randint(-5000, 5000)
for _ in range(self.layout.analog_count)]
analogs[0] = 5000 + self._tick % 100 # FREQ_OUT ползёт заметно
frame = build(1, 65, digits, analogs, self.layout,
add_byte=self._tick & 0xFF)
return frame + bytes([0x00])