# -*- 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])