fix: SerialMate v0.1.1 接收路径修复——时间戳改 link 线程到达时刻打戳、接收日志批量插入+5000行上限+积压折叠、通道配置缓存;自测扩至 selftest_gui 69 项,新增 ISSUE 报告与 _sim_periodic 复现脚本;重打包

This commit is contained in:
2026-10-13 11:44:29 +08:00
parent 296e220f20
commit e5b2e45f57
9 changed files with 411 additions and 66 deletions
+110 -39
View File
@@ -49,7 +49,7 @@ from protocol import (BIN_FORMATS, CRC_ALGOS, CRC_ALGO_LABELS, CRC_SCOPE_LABELS,
from theme import ThemeManager
APP_NAME = 'SerialMate'
APP_VERSION = '0.1.0'
APP_VERSION = '0.1.1'
REPO_URL = 'https://gitea.hulk.wang/hulk/HW-Debug-Tools'
BAUDS = (1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600)
@@ -69,10 +69,23 @@ FMT_LABELS = (('u8', 'u8'), ('s8', 's8'),
('u32be', 'u32 大端'), ('u32le', 'u32 小端'),
('s32be', 's32 大端'), ('s32le', 's32 小端'),
('f32be', 'f32 大端'), ('f32le', 'f32 小端'))
# 接收日志治理: 行数上限(超量丢最旧); 事件队列积压超阈值时, 本轮帧日志折叠为
# 计数摘要(曲线照常), 防长跑时排版成本拖垮 30ms 轮询导致显示延迟越积越大
RX_MAX_BLOCKS = 5000
FOLD_QSIZE = 200
def _timestamp():
t = time.time()
def _esc_log(text):
"""日志行入 HTML 缓冲前的转义: html.escape + 空格改  ——
富文本会折叠连续空格, 日志对齐(如 TX 行 '命令 [hex]')依赖原空格"""
return html.escape(text).replace(' ', ' ')
def _timestamp(t=None):
"""epoch 秒 -> 'HH:MM:SS.mmm' 纯格式化; t=None 取当前时刻。
日志时间必须传入 link 后台线程打的到达时刻, 不能消费时现取(会随 UI 卡顿挤压失真)"""
if t is None:
t = time.time()
return time.strftime('%H:%M:%S', time.localtime(t)) + f'.{int(t * 1000) % 1000:03d}'
@@ -232,14 +245,17 @@ class Window(QMainWindow):
self._plot_win = None # 曲线独立窗口(弹出后非 None)
self.tx_history, self._hist_pos = [], -1
self._tx_n = self._rx_n = self._tx_b = self._rx_b = 0
self._rx_buf = [] # 待写入接收日志的 HTML 行(批量 flush)
self._ch_cfg = [] # 通道配置缓存(_build_ui 后重建)
self.link = SerialLink(cfg=self._proto_from_store(),
on_status=lambda s: self.events.put(('status', s)))
self.link.on_frame = lambda fr: self.events.put(('frame', fr))
self.link.on_raw = lambda b: self.events.put(('raw', b))
self.link.on_tx = lambda lb, d: self.events.put(('tx', lb, d))
on_status=lambda s, ts: self.events.put(('status', s, ts)))
self.link.on_frame = lambda fr, ts: self.events.put(('frame', fr, ts))
self.link.on_raw = lambda b, ts: self.events.put(('raw', b, ts))
self.link.on_tx = lambda lb, d, ts: self.events.put(('tx', lb, d, ts))
self._build_ui()
self.plot.on_yrange_changed = self._on_plot_yrange
self.plot.on_span_changed = self._sync_time_slider
self._ch_cfg = self._collect_channels() # 通道配置缓存(_on_channel 时重建)
self._apply_store_to_ui()
self.theme_manager.set_window(self)
self.ed_tx.installEventFilter(self)
@@ -634,7 +650,8 @@ class Window(QMainWindow):
return out
def _on_channel(self, *_):
for i, c in enumerate(self._collect_channels()):
self._ch_cfg = self._collect_channels() # 用户改配置才重建, 帧路径只读缓存
for i, c in enumerate(self._ch_cfg):
self.plot.set_channel_name(i, c['name'] if c['enabled'] else '')
self._update_preview()
@@ -823,7 +840,7 @@ class Window(QMainWindow):
self.text_rx.setLineWrapMode(QPlainTextEdit.NoWrap)
self.text_rx.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
self.text_rx.setMinimumHeight(120)
self.text_rx.document().setMaximumBlockCount(5000)
self.text_rx.document().setMaximumBlockCount(RX_MAX_BLOCKS)
lay.addWidget(self.text_rx, 1)
return card
@@ -1245,61 +1262,114 @@ class Window(QMainWindow):
# ------------------------------------------------------- 事件与回显
def _drain_events(self):
for _ in range(400): # 单次上限, 防极端速率卡 UI
"""30ms 轮询消费 link 事件。事件尾部携带 link 后台线程打的发生时刻 ts
(字节到达/写入串口), 日志时间因此是到达时间而非显示时间。
接收日志行先进 _rx_buf 攒批, 本轮结束 _flush_rx 一次写入——把 N 次
UI 重排压成 1 次; 队列积压超 FOLD_QSIZE 时帧日志折叠为计数摘要
(显示本批首尾到达时刻, 曲线照常入库), 防消费不逮时越积越卡。"""
n = 0
folded = None # None=未折叠; {'n':帧数,'t0/t1':首尾到达,'cnt':{描述:计数}}
for _ in range(400): # 单次上限, 防极端速率卡 UI
try:
ev = self.events.get_nowait()
except queue.Empty:
return
break
kind = ev[0]
ts = ev[-1]
if kind == 'status':
self._on_status(ev[1])
self._on_status(ev[1], ts)
elif kind == 'frame':
self._on_frame(ev[1])
if folded is None and self.events.qsize() > FOLD_QSIZE:
folded = {'n': 0, 't0': ts, 't1': ts, 'cnt': {}}
if folded is not None:
self._absorb_frame(ev[1], ts, folded)
else:
self._on_frame(ev[1], ts)
elif kind == 'raw':
self._on_raw(ev[1])
self._on_raw(ev[1], ts)
elif kind == 'tx':
self._on_tx(ev[1], ev[2])
self._on_tx(ev[1], ev[2], ts)
n += 1
if folded:
detail = ' '.join(f'{desc} ×{c}' for desc, c in folded['cnt'].items())
self._append_rx(f'← {_timestamp(folded["t0"])}~{_timestamp(folded["t1"])}'
f' ⏳ 显示滞后, 折叠 {folded["n"]} 帧: {detail}')
if n:
self._flush_rx()
self._update_counters()
def _hint(self, text):
def _hint(self, text, ts=None):
self.lb_state.setText(text)
self.statusBar().showMessage(text, 8000)
self._append_rx(f'· {_timestamp()} {text}')
self._append_rx(f'· {_timestamp(ts)} {text}')
def _on_status(self, text):
def _on_status(self, text, ts=None):
"""连接状态: 同步连接按钮文案 + 提示"""
if text.startswith('已连接'):
self.btn_conn.setText('断开')
elif text.startswith(('已断开', '连接失败', '串口丢失')):
self.btn_conn.setText('连接')
self._hint(text)
self._hint(text, ts)
def _append_rx(self, line):
self.text_rx.appendPlainText(line)
"""接收日志行: 只入批量缓冲, 由 _flush_rx 统一写入"""
self._rx_buf.append(_esc_log(line))
def _append_rx_frame(self, prefix, core, suffix=''):
"""帧日志行: payload 核心内容垫一层浅背景色, 目光一眼锁定。
用半透明 rgba —— 主题无关, 明暗切换后历史行不会残留异色"""
self.text_rx.appendHtml(
f'{html.escape(prefix)}'
self._rx_buf.append(
f'{_esc_log(prefix)}'
f'<span style="background-color:rgba(96,125,180,0.16);">'
f'{html.escape(core)}</span>'
f'{html.escape(suffix)}')
f'{_esc_log(core)}</span>'
f'{_esc_log(suffix)}')
def _on_frame(self, fr):
def _flush_rx(self):
"""批量写入接收日志: 每行一个 <p>(一个 block), maximumBlockCount
裁旧行照常生效; 一次 appendHtml 替代逐行 N 次, 长跑不再因排版
成本拖垮事件轮询"""
if not self._rx_buf:
return
self.text_rx.appendHtml(
''.join(f'<p style="margin:0">{line}</p>' for line in self._rx_buf))
self._rx_buf.clear()
def _on_frame(self, fr, ts=None):
self._rx_n += 1
self._rx_b += len(fr.raw)
ts = f'{_timestamp()} ' if self.chk_ts.isChecked() else ''
t = f'{_timestamp(ts)} ' if self.chk_ts.isChecked() else ''
disp = RX_DISPLAYS[self.combo_display.currentIndex()]
payload = hexs(fr.payload) if disp == 'hex' else \
(clean_text(fr.payload.decode('ascii', 'replace')) or '(空)')
if fr.code is None: # 布局无 ID 字段
head = f'← {ts}'
head = f'← {t}'
else:
name = TYPE_NAMES.get(fr.code)
head = f'← {ts}B=0x{fr.code:02X}' + (f' {name}' if name else '')
head = f'← {t}B=0x{fr.code:02X}' + (f' {name}' if name else '')
suffix = f' [{hexs(fr.raw)}]' if self.chk_raw.isChecked() else ''
self._append_rx_frame(f'{head} LEN={len(fr.payload)} ', payload, suffix)
for i, c in enumerate(self._collect_channels()):
self._extract_channels(fr)
def _absorb_frame(self, fr, ts, folded):
"""积压期帧处理: 只计数 + 曲线照常入库, 日志行省略(_drain_events 汇总)"""
self._rx_n += 1
self._rx_b += len(fr.raw)
if not folded['n']:
folded['t0'] = ts
folded['n'] += 1
folded['t1'] = ts
if fr.code is None: # 布局无 ID 字段
desc = '帧'
else:
name = TYPE_NAMES.get(fr.code)
desc = f'B=0x{fr.code:02X}' + (f' {name}' if name else '')
folded['cnt'][desc] = folded['cnt'].get(desc, 0) + 1
self._extract_channels(fr)
def _extract_channels(self, fr):
"""按通道配置缓存(self._ch_cfg, _on_channel 时重建)提取数值:
曲线入库 + 最新值标签。帧路径不得遍历 UI 控件——高频时放大 UI 占用"""
for i, c in enumerate(self._ch_cfg):
if not c['enabled']:
continue
v = extract_value(fr.payload, c['mode'], offset=c['offset'],
@@ -1308,29 +1378,30 @@ class Window(QMainWindow):
if v is not None:
self._ch_ui[i]['val'].setText(fmt_val(v))
self.plot.add(i, v)
self._update_counters()
def _on_raw(self, data):
def _on_raw(self, data, ts=None):
self._rx_n += 1
self._rx_b += len(data)
ts = f'{_timestamp()} ' if self.chk_ts.isChecked() else ''
t = f'{_timestamp(ts)} ' if self.chk_ts.isChecked() else ''
disp = RX_DISPLAYS[self.combo_display.currentIndex()]
body = hexs(data) if disp == 'hex' else clean_text(data.decode('ascii', 'replace'))
self._append_rx(f'← {ts}{body}')
self._update_counters()
self._append_rx(f'← {t}{body}')
def _on_tx(self, label, data):
def _on_tx(self, label, data, ts=None):
self._tx_n += 1
self._tx_b += len(data)
if label is None:
self._append_rx(f'→ {_timestamp()} [HEX] {hexs(data)}')
self._append_rx(f'→ {_timestamp(ts)} [HEX] {hexs(data)}')
else:
self._append_rx(f'→ {_timestamp()} {label} [{hexs(data)}]')
self._update_counters()
self._append_rx(f'→ {_timestamp(ts)} {label} [{hexs(data)}]')
def _update_counters(self):
"""状态栏: 收发计数 + 事件队列积压量。正常恒为 0; 数字增长=事件
产出快于消费(GUI 卡或数据量异常), 便于用户把"不显示"现场分层"""
self.statusBar().showMessage(
f'发送 {self._tx_n} 帧 / {self._tx_b} 字节 接收 {self._rx_n} 帧 / {self._rx_b} 字节')
f'发送 {self._tx_n} 帧 / {self._tx_b} 字节 '
f'接收 {self._rx_n} 帧 / {self._rx_b} 字节 '
f'积压 {self.events.qsize()}')
def _on_theme(self, idx):
self.theme_manager.set_mode(('system', 'light', 'dark')[idx])