diff --git a/Src/tms_debugvar_term.py b/Src/tms_debugvar_term.py index 256cc8b..7ae90b3 100644 --- a/Src/tms_debugvar_term.py +++ b/Src/tms_debugvar_term.py @@ -1,72 +1,96 @@ -""" -PySide2 Serial Debug Terminal Widget -===================================== - -A small, embeddable terminal-style widget for talking to your MCU debug monitor -via the simple protocol you described: - -Request (host -> target): - 0x0A 0x44 - * In , the MSB (bit15 of the 16‑bit dbg number) selects read type: - 0 = read value, 1 = read name. - * Remaining 15 bits = variable index (0..0x7FFF). - -Response (target -> host): built per your WatchVar() C function. Layout: - [0] addr_recive (target address / echo) - [1] CMD_RS232_WATCH (command echo, e.g., 0x44) - [2] status (0x00 = OK, 0xFF = error; else vendor‑defined) - [3] IQType (enum: 0=int, 1=IQ1, 2=IQ2, ...) - if ReadName == 1: - [4..N] DebugVarName_t raw bytes (fixed length, default 32, configurable) - else (ReadName == 0): - [4] dataLo (LSB of 16‑bit value) - [5] dataHi (MSB of 16‑bit value) - [...next] crcLo crcHi 0x00 0x00 (CRC16‑IBM over the bytes preceding CRC) - -NOTE: The C code shows a 32‑bit signed Data but only transmits the low 16 bits. -This widget decodes the 16‑bit word and sign‑extends to 32 when converting IQ, -keeping behavior close to the firmware sample. Adjust in subclass if needed. - -Features --------- -- COM port selection (auto‑populate) & open/close. -- Variable index entry (0..0x7FFF) via spin box (hex display optional). -- Buttons: "Read Name", "Read Value". -- Raw vs Formatted output checkbox: when unchecked, IQ scaling -> float. -- Displays: Name, Value, IQ type. -- UART log window (timestamped hex + ASCII, TX/RX tagged). -- CRC16‑IBM check; bad CRC flagged in log. -- Signals for integration: nameRead(index, status, iq, nameStr), valueRead(index, status, iq, raw16, floatVal). -- Non‑blocking I/O via QSerialPort (QtSerialPort) — *preferred* since you are on Windows and already in Qt/PySide2. No extra threads needed. - (If you strongly prefer pyserial, see commented alt implementation at bottom.) - -Tested Python target: 3.7 w/ PySide2. - -Integration ------------ -Instantiate `DebugTerminalWidget(parent=None)` and embed into your main window or layout. -Call `set_available_ports()` periodically or on refresh to re‑enumerate COM ports. - -Customization knobs (constructor args): -- cmd_byte: defaults to 0x44 (as in your request). -- start_byte: defaults to 0x0A. -- name_field_len: bytes to read when name requested (default 32; set to len(DebugVarName_t)). -- iq_scaling: dict mapping iq enum -> divisor (float). default: {0:1.0,1:2.0,2:4.0,3:8.0,... up to 15:2**n}. -- signed: treat 16‑bit value as signed (default True). If False, unsigned. - -Limitations / TODO hooks ------------------------- -- If firmware later sends full 32‑bit Data, override `_parse_value_payload()`. -- Multi‑frame or streaming modes not supported (only single WatchVar replies). -- Timeout & retry basic; expand as needed. - -""" import sys import struct import datetime from PySide2 import QtCore, QtWidgets, QtSerialPort -from collections import deque +from PySide2.QtCore import QTimer + +# ---------------------------------------------------------------- CRC util --- +def crc16_ibm(data: bytes, *, init=0xFFFF) -> int: + """CRC16-IBM (aka CRC-16/ANSI, polynomial 0xA001 reflected).""" + crc = init + for b in data: + crc ^= b + for _ in range(8): + if crc & 1: + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + return crc & 0xFFFF + +class Spoiler(QtWidgets.QWidget): + def __init__(self, title="", animationDuration=300, parent=None): + super().__init__(parent) + self._animationDuration = animationDuration + + # --- Toggle button --- + self.toggleButton = QtWidgets.QToolButton(self) + self.toggleButton.setStyleSheet("QToolButton { border: none; }") + self.toggleButton.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon) + self.toggleButton.setArrowType(QtCore.Qt.RightArrow) + self.toggleButton.setText(title) + self.toggleButton.setCheckable(True) + + # --- Header line --- + self.headerLine = QtWidgets.QFrame(self) + self.headerLine.setFrameShape(QtWidgets.QFrame.HLine) + self.headerLine.setFrameShadow(QtWidgets.QFrame.Sunken) + + # --- Content area --- + self.contentArea = QtWidgets.QScrollArea(self) + self.contentArea.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + self.contentArea.setFrameShape(QtWidgets.QFrame.NoFrame) + self.contentArea.setWidgetResizable(True) + self._contentWidget = QtWidgets.QWidget() + self.contentArea.setWidget(self._contentWidget) + self.contentArea.setMaximumHeight(0) + + # --- Анимация только по контенту --- + self._ani_content = QtCore.QPropertyAnimation(self.contentArea, b"maximumHeight") + self._ani_content.setDuration(animationDuration) + self._ani_content.setEasingCurve(QtCore.QEasingCurve.InOutCubic) + + # Следим за шагами анимации → обновляем родителя + self._ani_content.valueChanged.connect(self._adjust_parent_size) + + # --- Layout --- + self.mainLayout = QtWidgets.QGridLayout(self) + self.mainLayout.setVerticalSpacing(0) + self.mainLayout.setContentsMargins(0, 0, 0, 0) + self.mainLayout.addWidget(self.toggleButton, 0, 0, 1, 1) + self.mainLayout.addWidget(self.headerLine, 0, 1, 1, 1) + self.mainLayout.addWidget(self.contentArea, 1, 0, 1, 2) + + # --- Signals --- + self.toggleButton.clicked.connect(self._on_toggled) + + def setContentLayout(self, contentLayout): + old = self._contentWidget.layout() + if old: + QtWidgets.QWidget().setLayout(old) + self._contentWidget.setLayout(contentLayout) + + def _adjust_parent_size(self, *_): + top = self.window() + if top: + size = top.size() + size.setHeight(top.sizeHint().height()) # берём новую высоту + top.resize(size) # ширина остаётся прежней + + def _on_toggled(self, checked: bool): + self.toggleButton.setArrowType(QtCore.Qt.DownArrow if checked else QtCore.Qt.RightArrow) + + contentHeight = self._contentWidget.sizeHint().height() + self._ani_content.stop() + self._ani_content.setStartValue(self.contentArea.maximumHeight()) + self._ani_content.setEndValue(contentHeight if checked else 0) + + # --- Фиксируем ширину на время анимации --- + w = self.width() + self.setFixedWidth(w) + self._ani_content.finished.connect(lambda: self.setMaximumWidth(16777215)) # сброс фикса + + self._ani_content.start() class DebugTerminalWidget(QtWidgets.QWidget): nameRead = QtCore.Signal(int, int, int, str) # index, status, iq, name @@ -83,7 +107,9 @@ class DebugTerminalWidget(QtWidgets.QWidget): signed=True, iq_scaling=None, read_timeout_ms=200, - auto_crc_check=True): + auto_crc_check=True, + drop_if_busy=False, + replace_if_busy=True): super().__init__(parent) self.start_byte = start_byte self.cmd_byte = cmd_byte @@ -91,25 +117,43 @@ class DebugTerminalWidget(QtWidgets.QWidget): self.signed = signed self.read_timeout_ms = read_timeout_ms self.auto_crc_check = auto_crc_check - self._busy = False - self._queue = deque() + + # lockstep policy flags + self._drop_if_busy = drop_if_busy + self._replace_if_busy = replace_if_busy if iq_scaling is None: iq_scaling = {n: float(1 << n) for n in range(16)} iq_scaling[0] = 1.0 self.iq_scaling = iq_scaling - # Serial port + # Serial port --------------------------------------------------------- self.serial = QtSerialPort.QSerialPort(self) self.serial.setBaudRate(115200) self.serial.readyRead.connect(self._on_ready_read) self.serial.errorOccurred.connect(self._on_serial_error) + self._index_change_timer = QtCore.QTimer(self) + self._index_change_timer.setSingleShot(True) + self._index_change_timer.timeout.connect(self._on_index_change_timeout) + self._index_change_delay_ms = 200 # задержка перед отправкой запроса + # RX state ------------------------------------------------------------ self._rx_buf = bytearray() self._waiting_name = False self._expected_min_len = 0 + self._expected_exact_len = None # if known exactly - # Timer for polling + # Lockstep tx/rx state ------------------------------------------------ + self._busy = False # True => запрос отправлен, ждём ответ/таймаут + self._pending_cmd = None # (frame, is_name, index) ожидающий отправки + self._active_index = None # индекс текущего запроса (для сигналов) + + # Timer for per-transaction timeout ---------------------------------- + self._txn_timer = QtCore.QTimer(self) + self._txn_timer.setSingleShot(True) + self._txn_timer.timeout.connect(self._on_txn_timeout) + + # Polling timer ------------------------------------------------------- self._poll_timer = QtCore.QTimer(self) self._poll_timer.timeout.connect(self._on_poll_timeout) self._polling = False @@ -205,19 +249,27 @@ class DebugTerminalWidget(QtWidgets.QWidget): var_layout.addWidget(self.chk_raw, 5, 2) main_layout.addWidget(var_group) + + # --- Collapsible UART Log --- + self.log_spoiler = Spoiler("UART Log", animationDuration=300, parent=self) - # --- UART Log Group --- - log_group = QtWidgets.QGroupBox("UART Log") - log_layout = QtWidgets.QVBoxLayout(log_group) - + log_layout = QtWidgets.QVBoxLayout() self.txt_log = QtWidgets.QTextEdit() self.txt_log.setReadOnly(True) self.txt_log.setFontFamily("Courier") log_layout.addWidget(self.txt_log) - main_layout.addWidget(log_group, 1) + self.log_spoiler.setContentLayout(log_layout) + main_layout.addWidget(self.log_spoiler, 1) + def _toggle_log_panel(self, checked): + if checked: + self.toggle_log_btn.setArrowType(QtCore.Qt.DownArrow) + self.log_panel.show() + else: + self.toggle_log_btn.setArrowType(QtCore.Qt.RightArrow) + self.log_panel.hide() # ----------------------------------------------------------- Port mgmt --- def set_available_ports(self): """Enumerate COM ports and repopulate combo box.""" @@ -255,7 +307,7 @@ class DebugTerminalWidget(QtWidgets.QWidget): self.btn_open.setText("Close") self._log(f"[PORT] Opened {port_name} @ {baud}") self.portOpened.emit(port_name) - + # --------------------------------------------------------- Frame build --- def _build_request(self, index: int, read_name: bool) -> bytes: if read_name: @@ -265,37 +317,75 @@ class DebugTerminalWidget(QtWidgets.QWidget): hi = (dbg >> 8) & 0xFF lo = dbg & 0xFF return bytes([self.start_byte & 0xFF, self.cmd_byte & 0xFF, hi, lo]) - + + # ------------------------------- PUBLIC API (safe single outstanding) --- def request_name(self): - index = int(self.spin_index.value()) - frame = self._build_request(index, True) - self._enqueue_command(frame, is_name=True) + self._issue_command(is_name=True) def request_value(self): + self._issue_command(is_name=False) + + def _issue_command(self, *, is_name: bool): index = int(self.spin_index.value()) - frame = self._build_request(index, False) - self._enqueue_command(frame, is_name=False) - - def _enqueue_command(self, frame: bytes, is_name: bool): - self._queue.append((frame, is_name)) - if not self._busy: - self._process_next_command() - - def _process_next_command(self): - if not self._queue: + frame = self._build_request(index, is_name) + + if self._busy: + if self._drop_if_busy: + self._log("[LOCKSTEP] Busy -> drop new request") + return + if self._replace_if_busy: + self._pending_cmd = (frame, is_name, index) + self._log("[LOCKSTEP] Busy -> replaced pending request") + else: + # queue disabled; ignore + self._log("[LOCKSTEP] Busy -> ignore (no replace)") return - frame, is_name = self._queue.popleft() + + # idle -> send immediately + self._start_transaction(frame, is_name, index) + + # ------------------------------------------------------ TXN lifecycle --- + def _start_transaction(self, frame: bytes, is_name: bool, index: int): + """Mark busy, compute expected length, send frame, start timeout.""" self._busy = True + self._active_index = index self._waiting_name = is_name + # Expected minimal len: hdr[4] + payload(name/val) + crc/trailer[4] if is_name: self._expected_min_len = 4 + self.name_field_len + 4 else: self._expected_min_len = 4 + 2 + 4 + self._expected_exact_len = self._expected_min_len # protocol fixed-size now + self._rx_buf.clear() + self._set_ui_busy(True) self._send(frame) + self._txn_timer.start(self.read_timeout_ms) - def _finish_command(self): + def _end_transaction(self): + """Common exit path after parse or timeout.""" + self._txn_timer.stop() self._busy = False - self._process_next_command() + self._active_index = None + self._expected_min_len = 0 + self._expected_exact_len = None + self._rx_buf.clear() + self._set_ui_busy(False) + # if we have pending -> fire it now + if self._pending_cmd is not None: + frame, is_name, index = self._pending_cmd + self._pending_cmd = None + # start immediately (no recursion issues; single-shot via singleShot) + QtCore.QTimer.singleShot(0, lambda f=frame, n=is_name, i=index: self._start_transaction(f, n, i)) + + def _on_txn_timeout(self): + if not self._busy: + return + self._log("[TIMEOUT] Response not received in time; aborting transaction") + # log any garbage that came in + if self._rx_buf: + self._log_frame(bytes(self._rx_buf), tx=False) + self._end_transaction() + # --------------------------------------------------------------- TX/RX --- def _send(self, data: bytes): n = self.serial.write(data) @@ -303,41 +393,49 @@ class DebugTerminalWidget(QtWidgets.QWidget): self._log(f"[ERR] Write incomplete: {n}/{len(data)}") self.txBytes.emit(data) self._log_frame(data, tx=True) - # start a timeout timer to clear buffer if no response - QtCore.QTimer.singleShot(self.read_timeout_ms, self._check_timeout) - - def _check_timeout(self): - self._busy = False - data = self.serial.readAll() - chunk = bytes(data) - if chunk: - self._log_frame(chunk, tx=False) # <-- логируем каждую порцию - self._rx_buf.extend(chunk) - if self._expected_min_len and len(self._rx_buf) >= self._expected_min_len: - self._log("[TIMEOUT] No complete response") - self._rx_buf.clear() - self._expected_min_len = 0 - self._finish_command() def _on_ready_read(self): + if not self._busy: + # unexpected data while idle -> just log & drop + chunk = self.serial.readAll().data() + if chunk: + self._log("[WARN] RX while idle -> ignored") + self._log_frame(chunk, tx=False) + return + self._rx_buf.extend(self.serial.readAll().data()) - # if we know the minimum expected length, test - if self._expected_min_len and len(self._rx_buf) >= self._expected_min_len: - # parse frame - frame = bytes(self._rx_buf) - self._rx_buf.clear() - self._expected_min_len = 0 + + # If exact length known and reached -> parse immediately (no wait for timeout) + if self._expected_exact_len is not None and len(self._rx_buf) >= self._expected_exact_len: + frame = bytes(self._rx_buf[:self._expected_exact_len]) + # log rx; if extra bytes remain we'll keep them for next txn (unlikely) self.rxBytes.emit(frame) self._log_frame(frame, tx=False) self._parse_response(frame) - - self._finish_command() + # discard everything consumed + del self._rx_buf[:self._expected_exact_len] + if self._rx_buf: + self._log("[WARN] Extra RX bytes after frame -> stash for next txn") + self._end_transaction() + return + + # If only min len known: check >= min -> try parse; else keep waiting (timer still running) + if self._expected_min_len and len(self._rx_buf) >= self._expected_min_len: + frame = bytes(self._rx_buf) + self.rxBytes.emit(frame) + self._log_frame(frame, tx=False) + self._parse_response(frame) + self._end_transaction() + return + # else: wait for more data or timeout def _on_serial_error(self, err): if err == QtSerialPort.QSerialPort.NoError: return self._log(f"[SERIAL ERR] {self.serial.errorString()} ({err})") - + # treat as txn failure if busy + if self._busy: + self._end_transaction() # ------------------------------------------------------------- Parsing --- def _parse_response(self, frame: bytes): # basic length check @@ -373,6 +471,8 @@ class DebugTerminalWidget(QtWidgets.QWidget): if cmd != self.cmd_byte: self._log(f"[WARN] Unexpected cmd 0x{cmd:02X}") + index = self._active_index if self._active_index is not None else self.spin_index.value() + if self._waiting_name: name_bytes = payload[4:4 + self.name_field_len] # stop at first NUL @@ -383,12 +483,22 @@ class DebugTerminalWidget(QtWidgets.QWidget): name_str = name_bytes.decode(errors="replace") self.edit_name.setText(name_str) self.lbl_iq.setText(str(iq)) - self.nameRead.emit(self.spin_index.value(), status, iq, name_str) + self.nameRead.emit(index, status, iq, name_str) else: + # Чтение значения + if status != 0: + # Ошибка чтения переменной — считаем её недействительной + self.edit_value.setText("INVALID") + self.edit_value.setStyleSheet("color: red; font-weight: bold;") + self.lbl_iq.setText("-") + self.valueRead.emit(index, status, iq, 0, float('nan')) + self._log(f"[ERR] Variable at index {index} invalid, status={status}") + return + raw_lo = payload[4] if len(payload) > 4 else 0 raw_hi = payload[5] if len(payload) > 5 else 0 raw16 = (raw_hi << 8) | raw_lo - if self.signed and raw16 & 0x8000: + if self.signed and (raw16 & 0x8000): raw_signed = raw16 - 0x10000 else: raw_signed = raw16 @@ -401,9 +511,10 @@ class DebugTerminalWidget(QtWidgets.QWidget): disp = f"{float_val:.6g}" # compact self.edit_value.setText(disp) self.lbl_iq.setText(str(iq)) - self.valueRead.emit(self.spin_index.value(), status, iq, raw_signed, float_val) + self.valueRead.emit(index, status, iq, raw_signed, float_val) # -------------------------------------------------------------- Helpers --- + def _toggle_index_base(self, state): val = self.spin_index.value() if state == QtCore.Qt.Checked: @@ -417,33 +528,60 @@ class DebugTerminalWidget(QtWidgets.QWidget): def _on_index_changed(self, new_index: int): if self._polling: - # В режиме polling при изменении индекса автоматически запрашиваем имя - self.request_name() + self._index_change_timer.start(self._index_change_delay_ms) + + def _on_index_change_timeout(self): + # Здесь запускаем запрос имени или значения по новому индексу + if self._polling: + # если включён polling — можно просто перезапустить опрос с новым индексом + # например: + self._restart_polling_cycle() + + def _restart_polling_cycle(self): + # Прервать текущую транзакцию (если есть) + if self._busy: + # Если занято — запустить таймер, который через немного проверит снова + # Можно, например, использовать QTimer.singleShot (если PyQt/PySide) + QTimer.singleShot(10, self._restart_polling_cycle) # через 100 мс повторить попытку + return + # можно отправить запрос имени, если нужно + self.request_name() + # Запустить следующий запрос + self._on_poll_timeout() + + def _set_polling_ui(self, polling: bool): + # Если polling == True -> блокируем кнопки Read/Write, иначе разблокируем + self.btn_read_name.setDisabled(polling) + self.btn_read_value.setDisabled(polling) def _toggle_polling(self): if self._polling: self._poll_timer.stop() self._polling = False self.btn_poll.setText("Start Polling") - self._log("[POLL] Polling stopped") + self._set_polling_ui(False) + self._log("[POLL] Stopped") else: - if not self.serial.isOpen(): - self._log("[WARN] Port not open. Cannot start polling.") - return interval = self.spin_interval.value() self._poll_timer.start(interval) self._polling = True self.btn_poll.setText("Stop Polling") - self._log(f"[POLL] Polling started with interval {interval} ms") - self.request_name() - self._poll_once() # immediate first poll + self._set_polling_ui(True) + self._log(f"[POLL] Started, interval {interval} ms") def _on_poll_timeout(self): self._poll_once() def _poll_once(self): - if self._polling and self.serial.isOpen(): + if self._polling and self.serial.isOpen() and not self._busy: self.request_value() + # если busy -> просто пропускаем тик; не ставим pending, чтобы не накапливать очередь + + def _set_ui_busy(self, busy: bool): + '''self.btn_read_name.setEnabled(not busy) + self.btn_read_value.setEnabled(not busy) + # Не запрещаем Stop Polling, иначе нельзя прервать зависший запрос + self.spin_index.setEnabled(not busy)''' def _log(self, msg: str): ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3] @@ -457,25 +595,11 @@ class DebugTerminalWidget(QtWidgets.QWidget): self._log(f"[{dir_tag}] {hex_bytes} |{ascii_bytes}|") -# ---------------------------------------------------------------- CRC util --- -def crc16_ibm(data: bytes, *, init=0xFFFF) -> int: - """CRC16-IBM (aka CRC-16/ANSI, polynomial 0xA001 reflected).""" - crc = init - for b in data: - crc ^= b - for _ in range(8): - if crc & 1: - crc = (crc >> 1) ^ 0xA001 - else: - crc >>= 1 - return crc & 0xFFFF - - # ---------------------------------------------------------- Demo harness --- class _DemoWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() - self.setWindowTitle("Debug Terminal Demo") + self.setWindowTitle("DebugVar Terminal") self.term = DebugTerminalWidget(self) self.setCentralWidget(self.term) # connect sample signals -> print @@ -493,7 +617,5 @@ class _DemoWindow(QtWidgets.QMainWindow): if __name__ == "__main__": app = QtWidgets.QApplication(sys.argv) win = _DemoWindow() - win.resize(600, 500) win.show() - sys.exit(app.exec_()) - + sys.exit(app.exec_()) \ No newline at end of file diff --git a/debug_tools.c b/debug_tools.c index edb7957..1251cf9 100644 --- a/debug_tools.c +++ b/debug_tools.c @@ -249,9 +249,9 @@ static int convertDebugVarToIQx(DebugVar_t *var, int32_t *ret_var) else iq_final64 = iq_united64 >> (-shift); - // int32_t - if (iq_final64 > 2147483647 || iq_final64 < -2147483648) - return 3; // +// // int32_t +// if (iq_final64 > 2147483647 || iq_final64 < -2147483648) +// return 3; // *ret_var = (int32_t)iq_final64; }