Files
templates/python/protocan/balsam.py
Andrey a5e46d4627 Расширить обмен по шинам, CAN-адаптеры и обработку графиков
Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
2026-10-17 14:36:14 +03:00

157 lines
12 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.
# Python-привязка исторического CAN-протокола Balsam к общему C99-ядру. Модуль представляет
# результат в объектах Python; двоичный контракт регистров остаётся в balsam_can.
"""Balsam 167 legacy CAN register decoder backed by the shared C99 core."""
from __future__ import annotations
from dataclasses import dataclass
from .native import NativeProtocolUnavailable, get_native_protocol
BASE_ID = 0x00BA0000
DATA_OFFSET = 0x10
NODE_COUNT = 13
PULT_COMMAND_ID = 0x00745009
PULT_DATA_ID = 0x00745019
HEARTBEAT_ADDRESS = 0x0100 # CAN_HEARTBEAT_ADDR в ecan.c периферии 167 начинает три служебных слова вне технологической карты R0..R127.
HEARTBEAT_NAMES = { # Назначения слов соответствуют hiword и loword ветки CAN_send для CAN_HEARTBEAT_ADDR.
HEARTBEAT_ADDRESS: "Heartbeat: счётчик, с", # Первое слово содержит секунды после запуска по модулю 65536, а не знаковое измерение.
HEARTBEAT_ADDRESS + 1: "Heartbeat: Mode", # Второе слово передаёт аппаратный номер Mode платы без преобразования CAN-индекса.
HEARTBEAT_ADDRESS + 2: "Heartbeat: тип платы (Desk)", # Третье слово содержит код Desk из прошивки, без подмены его именем CAN-узла.
} # Все три адреса общие для поддерживаемых узлов периферии 167.
COMMON_REGISTER_NAMES = { # Общие назначения подтверждены package.h; единицы времени зависят от версии планировщика CAN.
0x60: "Настройка быстрого потока CAN", # Cancount[m_FAST]: в новом планировщике ноль отключает поток, ненулевое значение включает его.
0x61: "Настройка медленного потока CAN", # Cancount[m_SLOW]: исторический период нельзя переводить в миллисекунды без версии прошивки.
0x62: "Яркость индикации", # Brightness в package.h содержит исходный уровень яркости, а не подтверждённый процент.
0x7E: "Последний режим", # LastMode хранится в предпоследнем слове технологической карты R0–R127.
0x7F: "Команды", # Commands — битовая маска, которую дополнительно расшифровывает command_description.
} # Таблица доступна с любой версией DLL и при использовании резервного Python-декодера.
@dataclass(frozen=True)
class BalsamRegister:
address: int
value: int
name: str
@property
def signed_value(self) -> int:
return self.value if self.value < 0x8000 else self.value - 0x10000
@dataclass(frozen=True)
class BalsamFrame:
can_id: int
device: int
device_name: str
from_device: bool
start_address: int
present_mask: int
registers: tuple[BalsamRegister, ...]
@property # Признак доступен адаптерам монитора и общей текстовой расшифровке без повторного разбора байтов.
def is_heartbeat(self) -> bool: # Штатный CAN_heartbeat_service передаёт ответ с адресом 0x0100 и всеми тремя словами.
return self.from_device and self.start_address == HEARTBEAT_ADDRESS and self.present_mask == 7 # Маска 111 из ecan.c исключает команды и неполные служебные пакеты.
@property # Имя отправителя определяется направлением кадра, тогда как device_name сохраняет имя периферийного узла.
def sender_name(self) -> str: # Все устройства 167 подписываются по источнику кадра, как в предоставленной таблице адресов.
return self.device_name if self.from_device else "TMS" # Команды базового диапазона передаёт TMS, а ответы со смещением 0x10 — соответствующий периферийный узел.
@property # Получатель дополняет отправителя, чтобы подробная расшифровка совпадала с подписью CAN ID.
def recipient_name(self) -> str: # Имя адресата нужно для обоих направлений каждой пары устройств.
return "TMS" if self.from_device else self.device_name # Периферия возвращает данные в TMS; получателем команды остаётся узел, выбранный CAN ID.
@property # Краткий маршрут используется также в сводке и истории CAN-монитора.
def route(self) -> str: # Собираем направление из тех же имён, которые показаны в подробностях кадра.
return "%s → %s" % (self.sender_name, self.recipient_name) # Единые подписи исключают различия между строкой ID и полями отправителя и получателя.
def command_description(self, value: int) -> str:
"""Command register 127; named UMP bits from BALZAM_167 automats.h."""
if value == 0:
return "нет активных команд"
names = ({0x0004: "сохранение уставок", 0x0010: "установка нулевого уровня"}
if 1 <= self.device <= 7 else {})
if self.device in (5, 6):
names.update({0x0040: "останов заряда", 0x0080: "пуск заряда",
0x0100: "инициализация ЦАП", 0x8000: "сброс УМП"})
return ", ".join(names.get(1 << bit, "бит %d" % bit)
for bit in range(16) if value & (1 << bit))
@property
def summary(self) -> str: # Сводка показывает служебный пакет компактно, а технологические регистры — с адресами и известными назначениями.
if self.is_heartbeat: # Только полный ответ с адреса 0x0100 содержит три служебных слова в гарантированном порядке.
seconds, mode, desk = (item.value for item in self.registers) # Значения heartbeat беззнаковые; счётчик циклически проходит 0–65535 секунд.
return "BALZAM · %s · Heartbeat · %d с · Mode=%d · Desk=%d" % (self.route, seconds, mode, desk) # Один heartbeat получает одну подпись; исходные HEX остаются в байтах и подробных полях.
direction = "данные" if self.from_device else "команда" # Направление технологического кадра определяется диапазоном CAN ID.
values = ", ".join( # Каждое отмеченное маской слово выводится ровно один раз вместе с его назначением.
"R%d=0x%04X (%d)" % (item.address, item.value, item.signed_value) # Десятичный адрес совпадает с таблицей R0–R127, HEX сохраняет точное исходное слово.
+ (" — " + item.name if item.name else "") # Для неизвестного адреса сохраняем только достоверные номер и значение, не придумывая назначение.
+ (" — " + self.command_description(item.value)
if item.address == 0x7F else "")
for item in self.registers
) or "нет отмеченных регистров"
return "BALZAM · %s · %s · %s" % (self.route, direction, values)
def is_balsam_id(can_id: int) -> bool:
if can_id & 0x1FFFFFFF in (PULT_COMMAND_ID, PULT_DATA_ID):
return True
relative = (can_id & 0x1FFFFFFF) - BASE_ID
return (0 <= relative < NODE_COUNT
or DATA_OFFSET <= relative < DATA_OFFSET + NODE_COUNT)
def decode(can_id: int, data: bytes, native=None) -> BalsamFrame | None:
"""Decode wire order: BE value2, value3, mask/address, value1."""
if native is None:
try:
native = get_native_protocol()
except NativeProtocolUnavailable:
return _decode_fallback(can_id, data)
status, decoded = native.balsam_decode(can_id, bytes(data))
if status == 0:
return None
if status == -2:
raise ValueError("BALZAM CAN frame must contain exactly 8 data bytes")
if status != 1 or decoded is None:
raise ValueError("invalid BALZAM CAN frame")
device, direction, mask, start, values = decoded
registers = tuple(
BalsamRegister(start + index, values[index],
HEARTBEAT_NAMES.get(start + index) or COMMON_REGISTER_NAMES.get(start + index) or native.balsam_register_name(device, start + index)) # Общие настройки получают одинаковые подписи со старой DLL; назначения каналов определяет C-ядро.
for index in range(3) if mask & (4 >> index)
)
return BalsamFrame(can_id & 0x1FFFFFFF, device,
native.balsam_device_name(device), direction == 1,
start, mask, registers)
def _decode_fallback(can_id: int, data: bytes) -> BalsamFrame | None:
if not is_balsam_id(can_id):
return None
if len(data) != 8:
raise ValueError("BALZAM CAN frame must contain exactly 8 data bytes")
relative = (can_id & 0x1FFFFFFF) - BASE_ID
device = (relative & 0x0F) + 1 # Прошивка ecan.c уменьшает DevNum перед сборкой ID; маска выделяет нулевой CAN-индекс, а +1 возвращает номер Mode.
from_device = relative >= DATA_OFFSET
if can_id & 0x1FFFFFFF in (PULT_COMMAND_ID, PULT_DATA_ID):
device = 10
from_device = (can_id & 0x1FFFFFFF) == PULT_DATA_ID
header = int.from_bytes(data[4:6], "big")
values = tuple(int.from_bytes(data[offset:offset + 2], "big")
for offset in (6, 0, 2))
names = {
1: "Трансформатор 1", 2: "Трансформатор 2",
3: "Силовой 1", 4: "Силовой 2", 5: "УМП 1", 6: "УМП 2", # Названия силовых узлов и УМП соответствуют строкам 3..6 предоставленной таблицы адресов 167.
7: "Двигатель", 8: "Задатчик", 9: "ВЭП", 10: "ПМУ", 13: "Терминал", # Таблица задаёт задатчик 8, ВЭП 9 и ПМУ для пары 0xBA0009/0xBA0019; служебный терминал сохраняет номер 13.
}
start, mask = header & 0x1FFF, (header >> 13) & 7
registers = tuple(BalsamRegister(start + i, values[i],
HEARTBEAT_NAMES.get(start + i) or COMMON_REGISTER_NAMES.get(start + i, "")) # Без DLL сохраняем подтверждённые назначения heartbeat и общих настроек, оставляя неизвестные каналы без подписи.
for i in range(3) if mask & (4 >> i))
return BalsamFrame(can_id & 0x1FFFFFFF, device,
names.get(device, "Узел %d" % device),
from_device, start, mask, registers)