сделана бета терминалка для опроса переменных

This commit is contained in:
Razvalyaev 2025-07-18 17:43:23 +03:00
parent 5be6343c33
commit c94a7e711c
2 changed files with 272 additions and 150 deletions

View File

@ -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 <DbgNumbHigh> <DbgNumbLow>
* In <DbgNumbHigh>, the MSB (bit15 of the 16bit 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 vendordefined)
[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 16bit value)
[5] dataHi (MSB of 16bit value)
[...next] crcLo crcHi 0x00 0x00 (CRC16IBM over the bytes preceding CRC)
NOTE: The C code shows a 32bit signed Data but only transmits the low 16 bits.
This widget decodes the 16bit word and signextends to 32 when converting IQ,
keeping behavior close to the firmware sample. Adjust in subclass if needed.
Features
--------
- COM port selection (autopopulate) & 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).
- CRC16IBM check; bad CRC flagged in log.
- Signals for integration: nameRead(index, status, iq, nameStr), valueRead(index, status, iq, raw16, floatVal).
- Nonblocking 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 reenumerate 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 16bit value as signed (default True). If False, unsigned.
Limitations / TODO hooks
------------------------
- If firmware later sends full 32bit Data, override `_parse_value_payload()`.
- Multiframe 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_())

View File

@ -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;
}