feat: 新增 SerialMate 串口调试助手 v0.1.0

- 协议帧构成令牌序列可定制(包头/板ID/LEN口径/5种CRC x 3种覆盖), 默认兼容 DLPilot 光机协议
This commit is contained in:
2026-09-27 18:52:54 +08:00
parent 372cb6b7a0
commit c26d2cdbaa
21 changed files with 4258 additions and 9 deletions
+405
View File
@@ -0,0 +1,405 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
轻量滚动曲线控件(QPainter 绘制, 不引入 matplotlib/pyqtgraph)
================================================================
N 通道时间序列: add(ch, value) 追加点(内部取相对时间), deque 限长滚动丢弃;
y 轴自动缩放(全平数据回落 ±1)或 set_yrange 固定量程; 颜色/文字取自当前
palette, 明暗主题自动跟随。每通道 >1000 点时抽稀绘制, 3000 点 × 4 通道
重绘开销可控。
"""
import bisect
import time
from collections import deque
from PySide6.QtCore import QPointF, QRect, Qt
from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen, QPalette
from PySide6.QtWidgets import QSizePolicy, QWidget
def fmt_val(v):
"""数值可读显示: 普通小数带千分位(最多 4 位小数, 去尾零), 达 10 亿量级
才退回科学计数 —— 调试现场 2.821e+07 这类写法不直观"""
if v != v or v in (float('inf'), float('-inf')):
return str(v)
if abs(v) >= 1e9:
return f'{v:.4g}'
s = f'{v:,.4f}' if abs(v) < 1 else f'{v:,.3f}'
return s.rstrip('0').rstrip('.') or '0'
class PlotWidget(QWidget):
COLORS = ('#0f6cbd', '#d83b01', '#107c10', '#8764b8')
MAXLEN = 3000 # 每通道缓冲点数(超限滚动丢弃)
M_LEFT, M_TOP, M_RIGHT, M_BOTTOM = 52, 10, 12, 22 # 绘图区内边距
def __init__(self, channels=4, parent=None):
super().__init__(parent)
n = channels
self._times = [deque(maxlen=self.MAXLEN) for _ in range(n)]
self._vals = [deque(maxlen=self.MAXLEN) for _ in range(n)]
self._names = [''] * n
self._latest = [None] * n
self._paused = False
self._yrange = None # None=自动 或 (min, max)
self._span = None # 可见时间窗宽(秒); None=跟随全部
self._offset = 0.0 # 右端距最新数据的秒数(0=实时)
self._frozen = False # 暂停渲染: 继续收数但不重绘
self._drag_y = None # Y 轴拖动基准(仅手动量程)
self.on_yrange_changed = None # 拖动改 Y 量程回调(主界面同步控件)
self.on_span_changed = None # 滚轮改时间窗回调(主界面同步下拉)
self._hover_pos = None # 悬停光标位置(None=不在绘图区)
self._hover_lines = [] # 悬停读值(顶部小框内容)
self._t0 = time.perf_counter()
self.setMinimumHeight(150)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setMouseTracking(True) # 无按键也接收 mouseMove(悬停读值)
# --- 数据接口(主线程调用) ---
def set_channel_name(self, ch, name):
self._names[ch] = name
self._touch()
def set_yrange(self, yr):
self._yrange = yr
self._touch()
def set_view(self, span):
"""设置可见时间窗宽(秒); None=跟随全部数据"""
self._span = None if span is None else max(float(span), 1.0)
self._touch()
def set_offset(self, offset):
"""可见窗右端距最新数据的秒数(0=实时跟随最新)"""
self._offset = max(float(offset), 0.0)
self._touch()
def view_offset(self):
return self._offset
def view_span(self):
return self._span
def view_range(self):
"""(全部数据 tmin, tmax, 可回看的最大滞后秒数) —— 供时间滑条换算"""
t_all = [t for ts in self._times if ts for t in (ts[0], ts[-1])]
tmin = min(t_all) if t_all else 0.0
tmax = max(t_all) if t_all else tmin + 5.0
span = self._span or max(tmax - tmin, 5.0)
return tmin, tmax, max(tmax - tmin - span, 0.0)
def visible_domain(self, tmin, tmax):
"""按时间窗/滞后换算可见横轴域(纯计算, 供单测)。
不做数据边界收敛: 选 5min 窗而数据只有 23s 时, 显示完整的 5min 轴,
数据贴右端随采集逐渐填满(示波器式); 滞后超出全部历史则为空窗"""
if self._span is None:
return tmin, tmax
right = tmax - self._offset
left = right - self._span
if right <= left: # 滞后超出全部历史 -> 空窗保宽
right = left + self._span
return left, right
def auto_yrange(self):
"""自动 Y 量程: 可见数据的 min/max 加 8% 边距(全平数据回落 ±1)"""
visible = [v for ch in range(len(self._vals)) for v in self._vals[ch]]
if not visible:
return -1.0, 1.0
ylo, yhi = min(visible), max(visible)
if ylo == yhi:
return ylo - 1.0, yhi + 1.0
pad = (yhi - ylo) * 0.08
return ylo - pad, yhi + pad
def set_paused(self, paused):
self._paused = bool(paused)
def is_paused(self):
return self._paused
def set_frozen(self, frozen):
"""暂停渲染(弹窗用): 继续接收入库但不重绘; 解冻后一次性渲染全部积累。
冻结是绝对状态: 覆写 update() 吞掉一切重绘请求, 任何代码路径都无法
在冻结期打破(悬停/滑条等), 只有 set_frozen(False) 恢复"""
self._frozen = bool(frozen)
if not self._frozen:
super().update()
def is_frozen(self):
return self._frozen
def update(self):
if self._frozen:
return
super().update()
def _touch(self):
self.update()
def add(self, ch, value):
if self._paused or value is None:
return
self._times[ch].append(time.perf_counter() - self._t0)
self._vals[ch].append(float(value))
self._latest[ch] = float(value)
self._touch()
def clear(self):
for t in self._times:
t.clear()
for v in self._vals:
v.clear()
self._latest = [None] * len(self._latest)
self._touch()
# --- 悬停读值 ---
def _nearest(self, ch, t, tol):
"""通道 ch 在时刻 t 附近(±tol 秒)最近的采样点 -> (名称, 值) 或 None"""
ts, vs = self._times[ch], self._vals[ch]
if not ts:
return None
lst = list(ts) # deque 无随机 bisect, 拷贝代价可接受
i = bisect.bisect_left(lst, t)
best = None
for j in (i - 1, i):
if 0 <= j < len(lst) and abs(lst[j] - t) <= tol:
if best is None or abs(lst[j] - t) < abs(lst[best] - t):
best = j
if best is None:
return None
return self._names[ch] or f'CH{ch + 1}', vs[best]
def _plot_geometry(self):
"""绘图区几何与可见横轴域: (left, top, pw, ph, tmin, tmax)"""
left, top = self.M_LEFT, self.M_TOP
pw = self.width() - left - self.M_RIGHT
ph = self.height() - top - self.M_BOTTOM
t_all = [t for ts in self._times if ts for t in (ts[0], ts[-1])]
if t_all:
tmin, tmax = min(t_all), max(t_all)
else:
tmin, tmax = 0.0, 5.0
tmin, tmax = self.visible_domain(tmin, tmax)
return left, top, pw, ph, tmin, tmax
def wheelEvent(self, ev):
"""滚轮缩放时间窗: 上滚放大(窗宽变短), 下滚缩小; 滞后按比例缩放"""
delta = ev.angleDelta().y()
if delta:
factor = 0.8 if delta > 0 else 1.25
old = self._span
if old is None: # 跟随模式 -> 以当前数据跨度为基准
_tmin, tmax, _mp = self.view_range()
old = max(tmax - _tmin, 5.0)
new = max(min(old * factor, 900.0), 2.0)
if self._offset and old:
self._offset = self._offset * new / old
self.set_view(new)
if self.on_span_changed is not None:
self.on_span_changed()
super().wheelEvent(ev)
def mousePressEvent(self, ev):
if ev.button() == Qt.LeftButton and self._yrange is not None:
self._drag_y = ev.position().toPoint().y()
super().mousePressEvent(ev)
def mouseReleaseEvent(self, ev):
if ev.button() == Qt.LeftButton:
self._drag_y = None
super().mouseReleaseEvent(ev)
def mouseMoveEvent(self, ev):
pos = ev.position().toPoint()
if self._drag_y is not None and self._yrange is not None:
_left, _top, _pw, ph, *_rest = self._plot_geometry()
if ph > 40:
ylo, yhi = self._yrange
shift = (yhi - ylo) * (pos.y() - self._drag_y) / ph
yr = (ylo - shift, yhi - shift) # 下拖数据下移, 上拖上移
self.set_yrange(yr)
if self.on_yrange_changed is not None:
self.on_yrange_changed(yr)
self._drag_y = pos.y()
self._hover_pos = pos
self.update()
super().mouseMoveEvent(ev)
def leaveEvent(self, ev):
self._hover_pos = None
self.update()
super().leaveEvent(ev)
def _hover_lines_at(self, pos):
"""光标位置 -> 读值框内容; 不在绘图区/无命中返回空列表。
由 paintEvent 每帧调用(而非仅在鼠标移动时), 鼠标停住不动,
实时数据到来时读值依然持续刷新(用户实测反馈)"""
left, top, pw, ph, tmin, tmax = self._plot_geometry()
span = max(tmax - tmin, 0.001)
if pw < 40 or ph < 40 or not (left <= pos.x() <= left + pw) \
or not (top <= pos.y() <= top + ph):
return []
t = tmin + (pos.x() - left) / pw * span
tol = span / max(pw, 1) * 10 # 光标 ±10px 内的采样算命中
lines = [f'T = {t:.2f}s']
for ch in range(len(self._vals)):
got = self._nearest(ch, t, tol)
if got:
lines.append(f'{got[0]} = {fmt_val(got[1])}')
return lines if len(lines) > 1 else []
# --- 绘制 ---
def paintEvent(self, _ev):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
rect = self.rect()
pal = self.palette()
p.fillRect(rect, pal.color(QPalette.Base))
text = pal.color(QPalette.WindowText)
dim = QColor(text)
dim.setAlpha(110)
grid = QColor(text)
grid.setAlpha(38)
left, top = self.M_LEFT, self.M_TOP
right, bottom = self.M_RIGHT, self.M_BOTTOM
pw, ph = rect.width() - left - right, rect.height() - top - bottom
if pw < 40 or ph < 40:
return
p.setFont(QFont(self.font().family(), 8))
p.setPen(dim)
# y 量程: 固定 或 自动(auto_yrange: 8% 边距, 全平数据回落 ±1)
if self._yrange is not None:
ylo, yhi = self._yrange
if ylo >= yhi:
yhi = ylo + 1.0
else:
ylo, yhi = self.auto_yrange()
def my(y): # 值 -> 像素 y
return top + (yhi - y) / (yhi - ylo) * ph
# 横向参考线(4 等分)与每条线上的纵轴数值刻度
p.setPen(QPen(grid, 1, Qt.DashLine))
for i in range(1, 4):
y = top + ph * i / 4
p.drawLine(left, int(y), left + pw, int(y))
for i in range(1, 6):
x = left + pw * i / 6
p.drawLine(int(x), top, int(x), top + ph)
p.setPen(dim)
for i in range(5):
v = yhi - (yhi - ylo) * i / 4
y = top + ph * i / 4
p.drawText(QRect(2, int(y) - 9, left - 8, 18),
Qt.AlignRight | Qt.AlignVCenter, fmt_val(v))
# x 范围: 时间窗(span)内、按滞后(offset)取可见域; 无窗宽则跟随全部数据
t_all = [t for ch in range(len(self._times)) if self._times[ch]
for t in (self._times[ch][0], self._times[ch][-1])]
if t_all:
tmin, tmax = min(t_all), max(t_all)
else:
tmin, tmax = 0.0, 5.0
data_span = tmax - tmin
tmin, tmax = self.visible_domain(tmin, tmax)
span = max(tmax - tmin, 0.001)
def mx(t): # 时间 -> 像素 x
return left + (t - tmin) / span * pw
empty = True
for ch in range(len(self._vals)):
ts, vs = self._times[ch], self._vals[ch]
n = len(vs)
if n == 0:
continue
empty = False
color = QColor(self.COLORS[ch % len(self.COLORS)])
p.setPen(QPen(color, 1.6))
if n == 1:
p.drawEllipse(QPointF(mx(ts[0]), my(vs[0])), 2.2, 2.2)
continue
step = max(1, n // 1000)
path = QPainterPath(QPointF(mx(ts[0]), my(vs[0])))
for i in range(step, n, step):
path.lineTo(mx(ts[i]), my(vs[i]))
path.lineTo(mx(ts[-1]), my(vs[-1]))
p.drawPath(path)
p.setPen(dim)
tag = '窗口' if self._span is not None else '最近'
if self._span is not None and data_span < self._span - 0.5:
label = f'已采集 {data_span:.0f}s / 窗口 {self._span:.0f}s'
else:
label = f'{tag} {span:.0f}s' if span >= 10 else f'{tag} {span:.1f}s'
p.drawText(rect.adjusted(0, rect.height() - 18, -4, 0),
Qt.AlignRight | Qt.AlignVCenter, label)
if self._hover_pos is not None:
self._hover_lines = self._hover_lines_at(self._hover_pos)
if self._hover_lines:
self._hover_box(p)
if empty:
p.drawText(rect, Qt.AlignCenter, '等待数据…(勾选通道并使能接收)')
elif self._paused:
p.drawText(rect.adjusted(0, top, 0, 0), Qt.AlignHCenter, '已暂停')
elif self._frozen:
p.drawText(rect.adjusted(0, top, 0, 0), Qt.AlignHCenter,
'已冻结渲染(采集继续)')
self._legend(p, rect, text)
def _hover_box(self, p):
"""顶部居中的悬停读值小框(反色底), 内容在 mouseMove 里持续刷新"""
p.setFont(QFont(self.font().family(), 9))
fm = p.fontMetrics()
w = max(fm.horizontalAdvance(s) for s in self._hover_lines) + 16
h = fm.height() * len(self._hover_lines) + 10
x = self.M_LEFT + (self.width() - self.M_LEFT - self.M_RIGHT - w) // 2
y = self.M_TOP + 6
bg = QColor(self.palette().color(QPalette.WindowText))
bg.setAlpha(210)
p.setPen(Qt.NoPen)
p.setBrush(bg)
p.drawRoundedRect(x, y, w, h, 4, 4)
p.setPen(self.palette().color(QPalette.Base))
for i, s in enumerate(self._hover_lines):
p.drawText(x + 8, y + 5 + i * fm.height(), w - 16, fm.height(),
Qt.AlignLeft | Qt.AlignVCenter, s)
p.setBrush(Qt.NoBrush)
def _legend(self, p, rect, text):
"""右上角图例: 色块 + 通道名 + 最新值"""
lines = []
for ch in range(len(self._vals)):
if self._latest[ch] is None:
continue
name = self._names[ch] or f'CH{ch + 1}'
lines.append((ch, f'{name} = {fmt_val(self._latest[ch])}'))
if not lines:
return
p.setFont(QFont(self.font().family(), 8))
fm = p.fontMetrics()
w = max(fm.horizontalAdvance(s) for _c, s in lines) + 26
h = fm.height() * len(lines) + 8
x1, y1 = rect.width() - 16 - w, 8
bg = QColor(text)
bg.setAlpha(18)
p.setPen(Qt.NoPen)
p.setBrush(bg)
p.drawRoundedRect(x1, y1, w, h, 4, 4)
p.setBrush(Qt.NoBrush)
for i, (ch, s) in enumerate(lines):
y = y1 + 4 + i * fm.height()
p.setPen(Qt.NoPen)
p.setBrush(QColor(self.COLORS[ch % len(self.COLORS)]))
p.drawRect(x1 + 6, y + fm.ascent() - 8, 8, 8)
p.setPen(self.palette().color(QPalette.WindowText))
p.drawText(x1 + 20, y, w - 22, fm.height(),
Qt.AlignLeft | Qt.AlignVCenter, s)