Добавить протокол DEIF, диагностику CAN и сохранение сессий
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
159
python/set_devices/deif_xdi.py
Normal file
159
python/set_devices/deif_xdi.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user