Добавить протокол DEIF, диагностику CAN и сохранение сессий

This commit is contained in:
2026-10-04 23:34:26 +03:00
parent 6c08eece61
commit 0e3d89e1ee
8 changed files with 321 additions and 47 deletions

View File

@@ -197,6 +197,9 @@ def _column_index(header: list[str], *aliases: str) -> int | None:
def _parse_log_timestamp(value: str, reference: float, milliseconds: bool) -> float:
"""Читает epoch, millis() либо часы из CSV без требования даты в файле."""
text = value.strip()
if re.match(r"\d{4}-\d{2}-\d{2}[T ]", text):
from datetime import datetime
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
clock = re.fullmatch(r"(\d{1,2}):(\d{2}):(\d{2})(?:[.,](\d{1,6}))?", text)
if clock:
fraction = float("0." + (clock.group(4) or "0"))
@@ -229,14 +232,14 @@ def _parse_csv_frame(
rtr_index = None
time_key = "ms"
else:
time_index = _column_index(header, "Время", "time", "timestamp", "ms",
time_index = _column_index(header, "Время", "Последний приём", "time", "timestamp", "ms",
"millis", "time_ms", "timestamp_ms",
"uptime_ms", "elapsed_ms", "t_ms")
type_index = _column_index(header, "Тип", "type", "format", "fmt", "ide", "ext")
id_index = _column_index(header, "ID", "identifier", "can_id", "canid",
"ID (29 бит)")
dlc_index = _column_index(header, "DLC", "len", "length")
data_index = _column_index(header, "Данные (hex)", "data", "payload",
data_index = _column_index(header, "Данные (hex)", "Данные", "data", "payload",
"data_hex", "bytes", "DATA (8 байт)")
rtr_index = _column_index(header, "RTR", "remote")
if None in (time_index, id_index, dlc_index, data_index):
@@ -286,7 +289,9 @@ def _parse_csv_frame(
}
stamp = _parse_log_timestamp(row[time_index], reference, milliseconds)
relative = stamp < 1_000_000_000 and not ":" in row[time_index]
return BoardFrame(stamp, extended, identifier, rtr, dlc, data, source="SD"), relative
source_index = _column_index(header, "Канал", "source", "dir", "direction", "Направление") if header else None
source = row[source_index].strip() if source_index is not None and source_index < len(row) else "SD"
return BoardFrame(stamp, extended, identifier, rtr, dlc, data, source=source), relative
def parse_sd_log(

View File

@@ -17,12 +17,31 @@ class CanLogChannel:
name: str
address: int
points: deque
decoded: bool = False
def _values(frame: BoardFrame):
if frame.rtr:
return
prefix = f"{'EXT' if frame.extended else 'STD'} {frame.identifier:08X}"
if not frame.extended:
from .deif_xdi import decode as decode_deif
from .can_transport import build_frame
packet = decode_deif(build_frame(frame.identifier, frame.data, ide=False))
fields = dict(packet.fields)
if "RAW INT16 LE" in fields:
# Invalid measurements must not become numeric spikes or raw-word curves.
if "Статус" in fields or packet.warnings:
return
address = int(fields["Индекс объекта"], 16)
subindex = int(fields["Подиндекс"], 16)
label = f"{frame.source} · {prefix} · {packet.device_label} · 0x{address:04X}:{subindex:02X} · {fields['Параметр']}"
raw = int(fields["RAW INT16 LE"])
yield (label + " · RAW", address, raw, True)
if "Значение" in fields:
number, unit = fields["Значение"].split(" ", 1)
yield (label + " · " + unit, address, float(number), True)
return
if frame.extended:
for project in PROJECTS:
if project.format is not LegacyCanFormat.ROTATING_THREE_WORDS:
@@ -34,13 +53,13 @@ def _values(frame: BoardFrame):
if packet is not None:
for address, value in packet.present_values:
# Keep both CAN directions and nodes in separate curves.
yield (f"{prefix} · {node.name} · R{address:04X}", address, value)
yield (f"{prefix} · {node.name} · R{address:04X}", address, value, False)
return
# Unknown protocols stay explicitly raw; never guess a register layout.
for offset in range(0, len(frame.data), 2):
part = frame.data[offset:offset + 2]
label = f"BE16 [{offset}:{offset + 1}]" if len(part) == 2 else f"байт [{offset}]"
yield (f"{prefix} · {label}", offset, int.from_bytes(part, "big"))
yield (f"{prefix} · {label}", offset, int.from_bytes(part, "big"), False)
def read_can_log(lines, *, reference_timestamp: float, capacity: int,
@@ -49,16 +68,16 @@ def read_can_log(lines, *, reference_timestamp: float, capacity: int,
imported = parse_sd_log(lines, reference_timestamp, limit=frame_limit)
channels = {}
for frame in imported.frames:
for name, address, _value in _values(frame):
channels.setdefault(name, address)
for name, address, _value, decoded in _values(frame):
channels.setdefault(name, (address, decoded))
if len(channels) > 4096:
raise ValueError("CAN CSV: слишком много каналов (максимум 4096)")
if not channels:
raise ValueError("CAN CSV: нет данных для графиков")
capacity = max(1, min(capacity, max_total_points // len(channels)))
result = {name: CanLogChannel(name, address, deque(maxlen=capacity))
for name, address in channels.items()}
result = {name: CanLogChannel(name, address, deque(maxlen=capacity), decoded)
for name, (address, decoded) in channels.items()}
for frame in imported.frames:
for name, _address, value in _values(frame):
for name, _address, value, _decoded in _values(frame):
result[name].points.append((frame.timestamp * 1000, value))
return list(result.values()), imported

View File

@@ -1,5 +1,6 @@
"""Shared CAN protocol selection and disk-backed session buffer (no Qt)."""
import csv
import os
import tempfile
from collections import Counter, deque
from datetime import datetime
@@ -8,10 +9,14 @@ from protocan import protocan as pc
from .can_bridge import decode_frame
PROTOCOLS = (("auto", "Авто (ProtoCAN / Balsam)"),
("devboard", "DevBoard_V1 / SETCAN"))
("devboard", "DevBoard_V1 / SETCAN"),
("deif", "DEIF XDi-192 / CANopen"))
def decode_protocol(frame, protocol="auto"):
if protocol == "deif":
from .deif_xdi import decode
return decode(frame)
if protocol == "devboard":
return pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
return decode_frame(frame)
@@ -40,7 +45,7 @@ def parse_filter(text):
class CanSession:
"""All frames spool to disk; only the latest 5000 stay in display memory.
"""All frames spool to disk; retain recent frames and unique-frame totals.
Starting recording includes the current session buffer. Reset clears the
buffer/counters but never removes frames already written to a recording.
@@ -50,6 +55,7 @@ class CanSession:
def __init__(self):
self.recent = deque(maxlen=5000)
self.counts = Counter()
self.unique_frames = {}
self.output = None
self.path = None
self.buffer = tempfile.TemporaryFile(mode="w+", encoding="utf-8", newline="")
@@ -61,6 +67,11 @@ class CanSession:
csv.writer(self.buffer, delimiter=";").writerow(row)
self.recent.append((identifier, row))
self.counts[identifier] += 1
# Timestamp is not part of a frame's identity. Keep the latest arrival
# and a session-wide count, including repeats outside the recent buffer.
key = row[1:]
previous = self.unique_frames.get(key)
self.unique_frames[key] = (row, previous[1] + 1 if previous else 1)
if self.output is not None:
csv.writer(self.output, delimiter=";").writerow(row)
@@ -83,6 +94,28 @@ class CanSession:
self.output, self.path = output, path
return path
def save(self, path):
"""Save a full buffer snapshot without stopping an active recording."""
path = Path(path)
if self.output is not None and (path.resolve() == self.path.resolve()
or (path.exists() and path.samefile(self.path))):
raise ValueError("Выберите другой файл: в этот CSV сейчас идёт запись.")
temporary = None
try:
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8-sig",
newline="", dir=path.parent, delete=False) as output:
temporary = Path(output.name)
csv.writer(output, delimiter=";").writerow(self.HEADER)
self.buffer.seek(0)
while chunk := self.buffer.read(65536):
output.write(chunk)
os.replace(temporary, path)
finally:
self.buffer.seek(0, 2)
if temporary is not None and temporary.exists():
temporary.unlink()
return path
def flush(self):
if self.output is not None:
self.output.flush()
@@ -97,6 +130,7 @@ class CanSession:
self.buffer.truncate()
self.recent.clear()
self.counts.clear()
self.unique_frames.clear()
def close(self):
self.stop()

View File

@@ -262,5 +262,7 @@ def hex_str(data: bytes) -> str:
def raw_frame_text(data: bytes) -> str:
"""Show the same raw bytes in hexadecimal and decimal on separate lines."""
return hex_str(data) + "\n" + " ".join(str(byte) for byte in data)
"""Align each HEX/DEC byte in a three-character monospace column."""
hexadecimal = " ".join(f"{byte:02X}".ljust(3) for byte in data).rstrip()
decimal = " ".join(str(byte).ljust(3) for byte in data).rstrip()
return hexadecimal + "\n" + decimal

View File

@@ -0,0 +1,159 @@
"""DEIF XDi-192 observations from CAN analysis dated 2026-10-03.
Based on DEIF 4189350066D, sections 3.2, 11 and appendix 9.
Angle and AC2 percent use the verified working-library resolution of 0.1.
Absolute AC2 power remains RAW until its physical unit is confirmed.
"""
from protocan import protocan as pc
SELECTORS = {
bytes.fromhex("01 01 30 07"): "Азимут / руль — задание",
bytes.fromhex("01 81 30 02"): "RPM / обороты",
bytes.fromhex("01 91 30 02"): "Обороты винта, %",
bytes.fromhex("01 21 34 02"): "Электрическая мощность AC2",
bytes.fromhex("01 31 34 02"): "Электрическая мощность AC2, %",
}
MASTER_SELECTORS = tuple(SELECTORS)
SELECTORS[bytes.fromhex("01 01 30 02")] = "Азимут / руль — фактическое значение"
OBJECTS = {
0x3000: ("Азимут / руль", "°"),
0x3080: ("Обороты винта", "rpm"),
0x3090: ("Обороты винта, %", "%"),
0x3420: ("Электрическая мощность AC2", None),
0x3430: ("Электрическая мощность AC2, %", "%"),
}
def decode(frame):
result = pc.Decoded(pc.ProtoCanId(frame.can_id, 0, 0, 0, 0, 0, 0),
len(frame.data), bytes(frame.data), ide=frame.ide,
rtr=frame.rtr, protocol="DEIF XDi-192")
result.fields = [("Протокол", "DEIF XDi-net / CANopen SAM-MPDO"),
("CAN ID", "0x%03X" % frame.can_id)]
def reject(message):
result.summary = message
result.warnings.append(message)
return result
if frame.ide or frame.rtr:
return reject("DEIF: ожидается STD data frame; EXT/RTR не интерпретируется")
if 0x701 <= frame.can_id <= 0x77F:
node = frame.can_id - 0x700
result.device_label = "Node %d" % node
result.message_label = "Heartbeat / Boot-up"
result.fields.append(("Node-ID", str(node)))
if len(frame.data) != 1:
return reject("DEIF: Heartbeat Node %d — ожидается DLC=1" % node)
state = {0: "Boot-up", 5: "Operational"}.get(frame.data[0])
if state is None:
state = "состояние 0x%02X (не описано в журнале)" % frame.data[0]
result.fields.append(("Состояние", state))
result.summary = "Node %d: %s" % (node, state)
return result
if not 0x201 <= frame.can_id <= 0x57F:
result.summary = "DEIF: CAN ID не описан в журнале"
return result
node = frame.can_id - 0x200
result.message_label = "XDi-net broadcast / SAM-MPDO"
result.fields.append(("COB-ID", "0x%03X" % frame.can_id))
if len(frame.data) != 8:
return reject("DEIF RPDO1: ожидается DLC=8")
selector = frame.data[:4]
result.fields.append(("Селектор Byte0..3", selector.hex(" ").upper()))
source = frame.data[0] & 0x7F
index = int.from_bytes(frame.data[1:3], "little")
subindex = frame.data[3]
result.fields.extend([("Источник Node-ID", str(source)),
("Индекс объекта", "0x%04X" % index),
("Подиндекс", "0x%02X" % subindex)])
dam = bool(frame.data[0] & 0x80)
if dam:
result.fields = [(key, value) for key, value in result.fields if key != "Источник Node-ID"]
result.fields.append(("Получатель Node-ID", str(source)))
result.message_label = "CANopen DAM-MPDO"
if not source or frame.can_id - source not in (0x200, 0x300, 0x400, 0x500):
return reject("DEIF: получатель не соответствует RPDO COB-ID")
elif not source or not 0x201 <= frame.can_id <= 0x27F or (node != 127 and source != node):
return reject("DEIF: источник не соответствует COB-ID")
result.device_label = ("Получатель Node %d" if dam else "Источник Node %d") % source
result.fields[0] = ("Протокол", "CANopen DAM-MPDO" if dam else "DEIF XDi-net / CANopen SAM-MPDO")
family, instance = index & 0xFFF0, index & 0xF
if family not in OBJECTS or not instance or subindex not in (2, 7):
return reject("DEIF: неизвестный объект/подиндекс 0x%04X:%02X" % (index, subindex))
name, unit = OBJECTS[family]
kind = "фактическое значение" if subindex == 2 else "задание"
name = "%s №%d — %s" % (name, instance, kind)
raw = int.from_bytes(frame.data[4:6], "little", signed=True)
result.fields.extend([("Параметр", name),
("RAW INT16 LE", str(raw)),
("Byte6..7", frame.data[6:].hex(" ").upper())])
result.summary = "Node %d · 0x%04X:%02X · %s: RAW=%d" % (source, index, subindex, name, raw)
if raw in (-32768, 32766, 32767):
status = {-32768: "ниже диапазона", 32766: "выше диапазона", 32767: "ошибка данных"}[raw]
result.fields.append(("Статус", status))
result.summary += "; " + status
result.warnings.append(status)
elif unit and (subindex == 2 or family == 0x3000):
value = "%.1f %s" % (raw / 10, unit)
result.fields.append(("Значение", value))
result.fields.append(("Масштаб", "RAW ×0.1"))
result.summary += "; " + value
elif family == 0x3420 and subindex == 2:
result.fields.append(("Масштаб / единицы", "RAW ×1; физическая единица не подтверждена"))
result.summary += "; RAW ×1 (физическая единица не подтверждена)"
else:
result.fields.append(("Масштаб / единицы", "не установлены"))
result.summary += "; масштаб и единицы не установлены"
if frame.data[6:] != b"\x00\x00":
result.warnings.append("Byte6..7: для I16 ожидается нулевое заполнение")
return result
def master_frames(node_id, raw_values, can_id=None, mode="sam", angle_subindex=7):
"""Build a five-parameter STD RPDO1 cycle, using signed INT16 LE values."""
from .can_transport import build_frame
if not isinstance(node_id, int) or not 1 <= node_id <= 127:
raise ValueError("Node-ID должен быть от 1 до 127")
identifier = 0x200 + node_id if can_id is None else can_id
if mode not in ("sam", "dam"):
raise ValueError("Выберите SAM или DAM")
if angle_subindex not in (2, 7):
raise ValueError("Угол: 0x02 — фактический, 0x07 — задание")
if not isinstance(identifier, int) or not 0x201 <= identifier <= 0x57F:
raise ValueError("STD ID вне диапазона RPDO")
if mode == "sam" and identifier not in (0x200 + node_id, 0x27F):
raise ValueError("XDi-net STD ID: 0x200 + источник или 0x27F")
if mode == "dam" and identifier - node_id not in (0x200, 0x300, 0x400, 0x500):
raise ValueError("CANopen DAM: STD ID = база RPDO (200/300/400/500 hex) + получатель")
values = list(raw_values)
if len(values) != len(MASTER_SELECTORS):
raise ValueError("Нужны пять RAW-параметров")
frames = []
for selector, value in zip(MASTER_SELECTORS, values):
if selector[1:] == bytes.fromhex("01 30 07"):
selector = selector[:3] + bytes([angle_subindex])
if not isinstance(value, int) or not -32768 <= value <= 32767:
raise ValueError("RAW должен быть INT16: -32768…32767")
data = bytes([node_id | (0x80 if mode == "dam" else 0)]) + selector[1:] + value.to_bytes(2, "little", signed=True) + b"\x00\x00"
frames.append(build_frame(identifier, data, ide=False))
return frames
# Display binding catalogue. None means library-dependent resolution, not degrees/%.
DISPLAY_PRESETS = {
"power": ("POWER · AC2 (RAW)", 0x3420, 2, None),
"rpm": ("RPM · обороты (×0.1)", 0x3080, 2, 0.1),
"angle": ("Угол · задание (×0.1°)", 0x3000, 7, 0.1),
"angle_actual": ("Угол · фактический (×0.1°)", 0x3000, 2, 0.1),
"power_percent": ("POWER% · AC2 (×0.1%)", 0x3430, 2, 0.1),
"rpm_percent": ("RPM% (×0.1)", 0x3090, 2, 0.1),
}
def display_preset(key, node, instance=1, dam=False):
"""Return a catalogue selector and its known scale, or None for RAW."""
if not 1 <= node <= 127 or not 1 <= instance <= 15:
raise ValueError("Node-ID: 1…127; instance: 1…15")
_, base, subindex, scale = DISPLAY_PRESETS[key]
selector = bytes([node | (0x80 if dam else 0)]) + (base + instance).to_bytes(2, "little") + bytes([subindex])
return selector.hex(" ").upper(), scale

View File

@@ -1,9 +1,13 @@
"""Reusable CAN monitor controls for host applications."""
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import (QWidget, QHBoxLayout, QVBoxLayout, QPushButton,
QCheckBox, QComboBox, QLabel, QLineEdit, QTableWidget, QTableWidgetItem,
QMessageBox, QApplication, QAbstractItemView)
from ..can_monitor import CanSession, PROTOCOLS, parse_filter, format_data
QMessageBox, QApplication, QAbstractItemView, QFileDialog)
from ..can_transport import build_frame
from ..can_monitor import CanSession, PROTOCOLS, parse_filter, format_data, decode_protocol
class MonitorControls(QWidget):
@@ -35,6 +39,10 @@ class MonitorControls(QWidget):
self.record.clicked.connect(self.toggle_record)
self.record.setToolTip("Записать буфер сессии и последующие кадры в CSV")
layout.addWidget(self.record)
self.save_button = QPushButton("Сохранить…")
self.save_button.setToolTip("Сохранить весь буфер сессии в CSV (данные HEX), включая кадры вне таблицы")
self.save_button.clicked.connect(self.save_session)
layout.addWidget(self.save_button)
self.data_format = QPushButton("Data: HEX")
self.data_format.setCheckable(True)
self.data_format.setToolTip("Переключить отображение байтов Data: HEX / DEC")
@@ -49,6 +57,8 @@ class MonitorControls(QWidget):
self.timer.start()
QApplication.instance().aboutToQuit.connect(self.session.close)
self.destroyed.connect(lambda: self.session.close())
self.protocol.currentIndexChanged.connect(lambda *_: self.refilter())
self.raw.toggled.connect(lambda *_: self.refilter())
def format_data(self, data):
return format_data(data, self.data_format.isChecked())
@@ -66,17 +76,22 @@ class MonitorControls(QWidget):
row.addWidget(QLabel("Фильтр ID (hex)"))
self.filter = QLineEdit()
self.filter.setPlaceholderText("701 или 0x701, 201")
self.filter.setToolTip("Фильтр только нижней таблицы; сверху отображаются все ID")
self.filter.setClearButtonEnabled(True)
self.filter.textChanged.connect(self.refilter)
row.addWidget(self.filter)
self.count = QLabel("Кадров: 0")
row.addWidget(self.count)
layout.addLayout(row)
self.history = QTableWidget(0, len(CanSession.HEADER))
self.history.setHorizontalHeaderLabels(CanSession.HEADER)
headers = ("Последний приём",) + CanSession.HEADER[1:6] + ("Счётчик", "Данные", "Расшифровка")
self.history = QTableWidget(0, len(headers))
self.history.setHorizontalHeaderLabels(headers)
self.history.setToolTip("Уникальные кадры и число их повторений с момента сброса")
self.history.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.history.horizontalHeader().setStretchLastSection(True)
self.history.setColumnWidth(0, 190)
self.history.setColumnWidth(7, 220)
self.history.setColumnWidth(8, 500)
self.history.verticalHeader().setVisible(False)
self.history.setMinimumHeight(180)
self.history.setVisible(False)
@@ -98,6 +113,25 @@ class MonitorControls(QWidget):
self.refilter()
self.status.setText("Буфер: 0")
def save_session(self):
if not self.session.counts:
QMessageBox.information(self, "Сохранение CAN", "Буфер сессии пуст.")
return
try:
directory = Path(self.directory())
directory.mkdir(parents=True, exist_ok=True)
suggested = directory / datetime.now().strftime("session_%Y%m%d_%H%M%S.csv")
path, _ = QFileDialog.getSaveFileName(
self, "Сохранить буфер CAN", str(suggested), "CSV (*.csv)")
if not path:
return
saved = self.session.save(path)
except (OSError, ValueError) as exc:
QMessageBox.critical(self, "Сохранение CAN", str(exc))
return
self.save_button.setToolTip("Сохранено: " + str(saved))
QMessageBox.information(self, "Сохранение CAN", "Сохранено: " + str(saved))
def toggle_record(self):
try:
if self.session.output is None:
@@ -132,27 +166,33 @@ class MonitorControls(QWidget):
else:
count = sum(value for key, value in self.session.counts.items()
if ids is None or key in ids)
self.count.setText("Кадров: %d (показаны последние ≤5000)" % count)
self.history.horizontalHeaderItem(6).setText(
unique_count = sum(1 for values, _ in self.session.unique_frames.values()
if ids is None or int(values[3], 16) in ids)
self.count.setText("Кадров: %d; уникальных: %d" % (count, unique_count))
self.history.horizontalHeaderItem(7).setText(
"Данные (DEC)" if self.data_format.isChecked() else "Данные (HEX)")
self.history.setColumnHidden(8, self.raw.isChecked())
self.history.setVisible(ids is not None)
self.history.setRowCount(0)
table = getattr(self.owner, "online_table", None)
if table is None:
table = getattr(self.owner, "live_table", None)
if table is not None:
for row in range(table.rowCount()):
item = table.item(row, 0)
if item is not None:
table.setRowHidden(row, ids is not None and int(item.text(), 16) not in ids)
if ids is None:
self.history.setRowCount(0)
return
for identifier, values in self.session.recent:
if identifier not in ids:
continue
row = self.history.rowCount()
self.history.insertRow(row)
for column, value in enumerate(values):
if column == 6:
value = self.format_data(bytes.fromhex(value))
self.history.setItem(row, column, QTableWidgetItem(str(value)))
frames = [(values, count) for values, count in self.session.unique_frames.values()
if int(values[3], 16) in ids]
self.history.setRowCount(len(frames))
for row, (values, count) in enumerate(frames):
data = bytes.fromhex(values[6])
summary = ""
if not self.raw.isChecked():
frame = build_frame(int(values[3], 16), data,
ide=values[2] == "EXT", rtr=bool(values[4]),
to_can=values[1] == "TX")
summary = decode_protocol(frame, self.protocol.currentData()).summary
display = values[:6] + (count, self.format_data(data), summary)
for column, value in enumerate(display):
item = self.history.item(row, column)
if item is None:
self.history.setItem(row, column, QTableWidgetItem(str(value)))
elif item.text() != str(value):
item.setText(str(value))
if column == 8:
self.history.item(row, column).setToolTip(summary)

View File

@@ -23,6 +23,8 @@ from .qt_compat import QObject, Signal
_EXTENDED_ID = 0x80000000
_FRAME_RECEIVE = 1
_FRAME_ECHO = 2
_FRAME_ERROR = 3
_MODE_LISTEN_ONLY = 0x0001
@@ -160,6 +162,8 @@ class CandleAdapter(QObject):
frame_received = Signal(int, bytes)
capture_received = Signal(int, bytes, bool, bool)
transmit_echo = Signal(int, bytes, bool, bool)
bus_error = Signal(int, bytes, int)
connection_lost = Signal(str)
def __init__(self, parent: QObject | None = None) -> None:
@@ -223,7 +227,7 @@ class CandleAdapter(QObject):
self._reader = None
self._listen_only = False
def send(self, can_id: int, data: bytes) -> None:
def send(self, can_id: int, data: bytes, *, extended: bool = True) -> None:
device = self._device
if device is None:
raise CandleError("Candle-адаптер не подключён")
@@ -232,7 +236,9 @@ class CandleAdapter(QObject):
if len(data) > 8:
raise CandleError("Classic CAN поддерживает не более 8 байт")
frame = _Frame()
frame.can_id = (can_id & 0x1FFFFFFF) | _EXTENDED_ID
if not 0 <= can_id <= (0x1FFFFFFF if extended else 0x7FF):
raise CandleError("CAN ID вне диапазона")
frame.can_id = can_id | (_EXTENDED_ID if extended else 0)
frame.can_dlc = len(data)
frame.channel = self._channel
frame.data[:len(data)] = data
@@ -250,13 +256,22 @@ class CandleAdapter(QObject):
frame = _Frame()
if not api.dll.candle_frame_read(device, ct.byref(frame), 50):
continue
if (api.dll.candle_frame_type(ct.byref(frame)) == _FRAME_RECEIVE
and frame.channel == self._channel):
size = min(frame.can_dlc, 8)
if frame.channel != self._channel:
continue
kind = api.dll.candle_frame_type(ct.byref(frame))
size = min(frame.can_dlc, 8)
data = bytes(frame.data[:size])
if kind == _FRAME_ERROR or frame.flags & 0x01:
self.bus_error.emit(frame.can_id & 0x1FFFFFFF if kind == _FRAME_ERROR else 0,
data, frame.flags)
if kind == _FRAME_ECHO:
self.transmit_echo.emit(api.dll.candle_frame_id(ct.byref(frame)), data,
bool(frame.can_id & 0x80000000), bool(frame.can_id & 0x40000000))
elif kind == _FRAME_RECEIVE:
self.capture_received.emit(api.dll.candle_frame_id(ct.byref(frame)),
bytes(frame.data[:size]), bool(frame.can_id & 0x80000000), bool(frame.can_id & 0x40000000))
data, bool(frame.can_id & 0x80000000), bool(frame.can_id & 0x40000000))
self.frame_received.emit(api.dll.candle_frame_id(ct.byref(frame)),
bytes(frame.data[:size]))
data)
def close(self) -> None:
self.disconnect_channel()

View File

@@ -137,14 +137,14 @@ class SlcanAdapter(QObject):
self._buffer.clear()
self._listen_only = False
def send(self, can_id: int, data: bytes) -> None:
def send(self, can_id: int, data: bytes, *, extended: bool = True) -> None:
if not self.connected:
raise SlcanError("SLCAN-адаптер не подключён")
if self._listen_only:
raise SlcanError("SLCAN-адаптер открыт в режиме только приёма")
# The adapter contract is used by ProtoCAN, whose IDs are always EXT,
# including the numerically small values that would also fit in 11 bits.
packet = encode_frame(can_id, data, extended=True)
packet = encode_frame(can_id, data, extended=extended)
if self._serial.write(packet) != len(packet):
raise SlcanError(f"SLCAN не передал CAN-кадр: {self._serial.errorString()}")