359 lines
14 KiB
Python
359 lines
14 KiB
Python
# -*- 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])
|