Files
templates/python/set_devices/tms_terminal.py

359 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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])