Extract reusable device protocols, ports and logic analyzers from SETGUI
This commit is contained in:
358
python/set_devices/tms_terminal.py
Normal file
358
python/set_devices/tms_terminal.py
Normal 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])
|
||||
Reference in New Issue
Block a user