diff --git a/python/set_devices/can485_board.py b/python/set_devices/can485_board.py index c06d3ad..27f829e 100644 --- a/python/set_devices/can485_board.py +++ b/python/set_devices/can485_board.py @@ -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( diff --git a/python/set_devices/can_log_trends.py b/python/set_devices/can_log_trends.py index 8c33407..6ba5cbc 100644 --- a/python/set_devices/can_log_trends.py +++ b/python/set_devices/can_log_trends.py @@ -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 diff --git a/python/set_devices/can_monitor.py b/python/set_devices/can_monitor.py index 43c839f..c76cda1 100644 --- a/python/set_devices/can_monitor.py +++ b/python/set_devices/can_monitor.py @@ -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() diff --git a/python/set_devices/can_transport.py b/python/set_devices/can_transport.py index de082e0..c67cdbe 100644 --- a/python/set_devices/can_transport.py +++ b/python/set_devices/can_transport.py @@ -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 diff --git a/python/set_devices/deif_xdi.py b/python/set_devices/deif_xdi.py new file mode 100644 index 0000000..97733cd --- /dev/null +++ b/python/set_devices/deif_xdi.py @@ -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 diff --git a/python/set_devices/qt_ports/can_monitor.py b/python/set_devices/qt_ports/can_monitor.py index 4d98452..311c350 100644 --- a/python/set_devices/qt_ports/can_monitor.py +++ b/python/set_devices/qt_ports/can_monitor.py @@ -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) diff --git a/python/set_devices/qt_ports/candle_adapter.py b/python/set_devices/qt_ports/candle_adapter.py index 8ede2ee..76e81b0 100644 --- a/python/set_devices/qt_ports/candle_adapter.py +++ b/python/set_devices/qt_ports/candle_adapter.py @@ -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() diff --git a/python/set_devices/qt_ports/slcan_adapter.py b/python/set_devices/qt_ports/slcan_adapter.py index e047202..1a6748a 100644 --- a/python/set_devices/qt_ports/slcan_adapter.py +++ b/python/set_devices/qt_ports/slcan_adapter.py @@ -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()}")