1"""霍尔编码器上位机。23在工程目录执行:45 python -m host67或:89 python host/app.py10"""1112from __future__ import annotations1314import csv15import math16import queue17import sys18import time19from collections import deque2021import numpy as np22import pyqtgraph as pg23from PySide6.QtCore import QPointF, Qt, QTimer24from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygonF25from PySide6.QtWidgets import (26 QApplication,27 QCheckBox,28 QComboBox,29 QDoubleSpinBox,30 QFileDialog,31 QFrame,32 QGridLayout,33 QGroupBox,34 QHBoxLayout,35 QLabel,36 QMainWindow,37 QMessageBox,38 QPushButton,39 QPlainTextEdit,40 QScrollArea,41 QSizePolicy,42 QSpinBox,43 QSplitter,44 QVBoxLayout,45 QWidget,46)4748if __package__:49 from . import bus as busmod50else:51 import bus as busmod5253pg.setConfigOptions(antialias=False, background="#14171c", foreground="#d5d8de")5455CAP = 3276856STYLE = """57QMainWindow { background: #1b1e24; }58#panel, #scroll, QScrollArea { background: #1b1e24; border: none; }59QGroupBox {60 background: #22262e;61 border: 1px solid #343a46;62 border-radius: 8px;63 margin-top: 8px;64 padding: 6px 8px 6px 8px;65 font-weight: 600;66}67QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; color: #9aa3b2; }68QLabel { background: transparent; color: #e6e8eb; }69QLabel#muted { color: #8b93a1; }70QLabel#angle { font-size: 34px; font-weight: 650; color: #f4f7fb; }71QLabel#rpm { font-size: 16px; color: #b6f25c; }72QPushButton {73 background: #2c313b;74 border: 1px solid #434b5a;75 border-radius: 6px;76 padding: 6px 10px;77 color: #e6e8eb;78}79QPushButton:hover { background: #38404c; }80QPushButton:pressed { background: #252a33; }81QPushButton:disabled { color: #6c7380; background: #262b33; }82QPushButton#primary { background: #1f8f62; border: none; font-weight: 600; }83QPushButton#primary:hover { background: #25a571; }84QPushButton#danger { background: #8d3a3a; border: none; }85QComboBox, QSpinBox {86 background: #12151a;87 border: 1px solid #3c4350;88 border-radius: 4px;89 padding: 4px 6px;90 color: #e6e8eb;91 min-height: 22px;92}93QComboBox QAbstractItemView { background: #12151a; color: #e6e8eb; selection-background-color: #1f8f62; }94QCheckBox { color: #d5d8de; spacing: 6px; }95QPlainTextEdit {96 background: #12151a;97 color: #c5cad3;98 border: 1px solid #343a46;99 border-radius: 6px;100 font-family: Consolas, "Cascadia Mono", monospace;101 font-size: 12px;102}103QSplitter::handle { background: #2a303a; }104"""105106107class Dial(QWidget):108 """0° 在右侧、逆时针增加的角度盘。"""109110 def __init__(self) -> None:111 super().__init__()112 self.angle = 0.0113 self.linear = False114 self.setMinimumSize(150, 150)115 self.setMaximumHeight(168)116 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)117118 def set_angle(self, degrees: float) -> None:119 self.angle = degrees % 360.0120 self.update()121122 def set_linear(self, linear: bool) -> None:123 self.linear = linear124 self.update()125126 def paintEvent(self, _event) -> None:127 painter = QPainter(self)128 painter.setRenderHint(QPainter.RenderHint.Antialiasing)129 side = min(self.width(), self.height()) - 8130 painter.translate(self.width() / 2, self.height() / 2)131 radius = side / 2132 painter.setPen(QPen(QColor("#3c4452"), 8))133 painter.setBrush(QColor("#171b21"))134 painter.drawEllipse(int(-radius), int(-radius), int(radius * 2), int(radius * 2))135 painter.setPen(QPen(QColor("#8b93a1"), 1))136 for mark in range(0, 360, 10):137 painter.save()138 painter.rotate(-mark)139 inner = radius - (14 if mark % 30 == 0 else 8)140 painter.drawLine(int(inner), 0, int(radius - 4), 0)141 painter.restore()142 painter.setPen(QColor("#9aa3b2"))143 font = QFont("Microsoft YaHei UI", 8)144 painter.setFont(font)145 if self.linear:146 labels = ((0, "+20"), (90, "0"), (180, "-20"))147 else:148 labels = tuple((mark, str(mark)) for mark in range(0, 360, 30))149 for mark, text in labels:150 rad = math.radians(mark)151 x = math.cos(rad) * (radius - 28)152 y = -math.sin(rad) * (radius - 28)153 painter.drawText(int(x - 18), int(y - 8), 36, 16, int(Qt.AlignmentFlag.AlignCenter), text)154 painter.setPen(Qt.PenStyle.NoPen)155 painter.setBrush(QColor("#b6f25c"))156 painter.rotate(-self.angle)157 painter.drawPolygon(QPolygonF([158 QPointF(radius - 18, 0),159 QPointF(-18, 7),160 QPointF(-18, -7),161 ]))162 painter.setBrush(QColor("#1b1e24"))163 painter.drawEllipse(-8, -8, 16, 16)164165166class TraceStore:167 """环形采样缓冲。角度同时保留 0–360 和连续展开。"""168169 def __init__(self) -> None:170 self.t = np.zeros(CAP, np.float64)171 self.raw = np.zeros((3, CAP), np.float32)172 self.wrapped = np.zeros(CAP, np.float32)173 self.unwrapped = np.zeros(CAP, np.float64)174 self.i = 0175 self.n = 0176 self.primed = False177 self.last_deg = 0.0178 self.last_t = 0.0179 self.unwrap = 0.0180 self.rpm = 0.0181 self.linear = False182 self.latest: busmod.Sample | None = None183 self.rate_times: deque[float] = deque(maxlen=240)184185 def clear(self) -> None:186 self.i = 0187 self.n = 0188 self.primed = False189 self.rpm = 0.0190 self.latest = None191 self.rate_times.clear()192193 def add(self, sample: busmod.Sample) -> None:194 if self.linear:195 deg = sample.angle_cdeg / 100.0196 if self.primed:197 dt = sample.t - self.last_t198 if 0.0004 < dt < 0.5:199 inst = (deg - self.last_deg) / dt200 self.rpm += 0.2 * (inst - self.rpm)201 self.unwrap = deg202 self.last_deg = deg203 self.last_t = sample.t204 self.primed = True205 else:206 deg = (sample.angle_cdeg % 36000) / 100.0207 if not self.primed:208 self.unwrap = deg209 self.last_deg = deg210 self.last_t = sample.t211 self.primed = True212 else:213 delta = deg - self.last_deg214 if delta > 180.0:215 delta -= 360.0216 elif delta < -180.0:217 delta += 360.0218 dt = sample.t - self.last_t219 self.unwrap += delta220 if 0.0004 < dt < 0.5:221 inst = (delta / 360.0) / dt * 60.0222 self.rpm += 0.2 * (inst - self.rpm)223 self.last_deg = deg224 self.last_t = sample.t225 slot = self.i226 self.t[slot] = sample.t227 self.raw[0, slot] = sample.raw[0]228 self.raw[1, slot] = sample.raw[1]229 self.raw[2, slot] = sample.raw[2]230 self.wrapped[slot] = deg231 self.unwrapped[slot] = self.unwrap232 self.i = (slot + 1) % CAP233 if self.n < CAP:234 self.n += 1235 self.latest = sample236 self.rate_times.append(sample.t)237238 def rate_hz(self) -> float:239 if len(self.rate_times) < 2:240 return 0.0241 span = self.rate_times[-1] - self.rate_times[0]242 if span <= 0.05:243 return 0.0244 return (len(self.rate_times) - 1) / span245246 def visible(self, span: float, wrap_angle: bool):247 if self.n == 0:248 return None249 if self.n < CAP:250 t = self.t[:self.n]251 raw = self.raw[:, :self.n]252 wrapped = self.wrapped[:self.n]253 unwrapped = self.unwrapped[:self.n]254 else:255 cut = self.i256 t = np.concatenate((self.t[cut:], self.t[:cut]))257 raw = np.concatenate((self.raw[:, cut:], self.raw[:, :cut]), axis=1)258 wrapped = np.concatenate((self.wrapped[cut:], self.wrapped[:cut]))259 unwrapped = np.concatenate((self.unwrapped[cut:], self.unwrapped[:cut]))260 start = int(np.searchsorted(t, t[-1] - span, side="left"))261 t = t[start:]262 raw = raw[:, start:]263 if wrap_angle:264 angle = wrapped[start:].astype(np.float64, copy=True)265 if angle.size > 1:266 jumps = np.flatnonzero(np.abs(np.diff(angle)) > 180.0)267 angle[jumps] = np.nan268 else:269 angle = unwrapped[start:]270 return t, raw, angle271272273def style_plot(plot: pg.PlotWidget, ylabel: str) -> None:274 plot.setBackground("#14171c")275 plot.showGrid(x=True, y=True, alpha=0.22)276 label_style = {"color": "#c5c8ce", "font-family": "Microsoft YaHei UI", "font-size": "12px"}277 plot.setLabel("bottom", "时间", units="s", **label_style)278 plot.setLabel("left", ylabel, **label_style)279 tick_font = QFont("Microsoft YaHei UI", 9)280 plot.showAxis("right", False)281 plot.setMouseEnabled(x=True, y=True)282 for name in ("left", "bottom"):283 axis = plot.getAxis(name)284 axis.setPen(pg.mkPen("#5c6370"))285 axis.setTextPen(pg.mkPen("#c5c8ce"))286 axis.setTickFont(tick_font)287 plot.getViewBox().setDefaultPadding(0.0)288289290class MainWindow(QMainWindow):291 def __init__(self) -> None:292 super().__init__()293 self.setWindowTitle("霍尔传感器上位机")294 self.resize(1280, 780)295 self.bus = busmod.Bus()296 self.bus.start()297 self.store = TraceStore()298 self.connected = False299 self.kind = busmod.TYPE_ENCODER300 self.streaming = False301 self.demo_on = False302 self.demo_theta = 0.0303 self.demo_next = 0.0304 self.csv_file = None305 self.csv_writer = None306 self._fps_n = 0307 self._fps_t = time.perf_counter()308 self._fps = 0.0309 self._dirty = False310 self._programmatic_range = False311 self._wrap_applied = False312 self._build()313 self.timer = QTimer(self)314 self.timer.setTimerType(Qt.TimerType.PreciseTimer)315 self.timer.timeout.connect(self._on_tick)316 self.timer.start(16)317 self._refresh_ports()318 self._sync_buttons()319320 def _build(self) -> None:321 central = QWidget()322 self.setCentralWidget(central)323 root = QHBoxLayout(central)324 root.setContentsMargins(10, 10, 10, 10)325 root.setSpacing(10)326 root.addWidget(self._build_panel())327 root.addWidget(self._build_plots(), stretch=1)328329 def _build_panel(self) -> QWidget:330 panel = QWidget()331 panel.setObjectName("panel")332 panel.setFixedWidth(320)333 layout = QVBoxLayout(panel)334 layout.setContentsMargins(0, 0, 0, 0)335 layout.setSpacing(8)336337 link = QGroupBox("连接")338 grid = QGridLayout(link)339 self.port = QComboBox()340 self.btn_refresh = QPushButton("刷新")341 self.btn_refresh.clicked.connect(self._refresh_ports)342 self.baud = QComboBox()343 for rate in busmod.BAUD_TABLE:344 self.baud.addItem(str(rate), rate)345 self.baud.setCurrentIndex(4)346 self.dev_id = QSpinBox()347 self.dev_id.setRange(1, 254)348 self.dev_id.setValue(1)349 self.dev_type = QComboBox()350 self.dev_type.addItem("不限", busmod.TYPE_ANY)351 self.dev_type.addItem("角度编码器", busmod.TYPE_ENCODER)352 self.dev_type.addItem("直线位置", busmod.TYPE_POSITION)353 self.btn_link = QPushButton("连接")354 self.btn_link.setObjectName("primary")355 self.btn_link.clicked.connect(self._toggle_link)356 grid.addWidget(QLabel("串口"), 0, 0)357 grid.addWidget(self.port, 0, 1)358 grid.addWidget(self.btn_refresh, 0, 2)359 grid.addWidget(QLabel("波特率"), 1, 0)360 grid.addWidget(self.baud, 1, 1, 1, 2)361 grid.addWidget(QLabel("设备号"), 2, 0)362 grid.addWidget(self.dev_id, 2, 1, 1, 2)363 grid.addWidget(QLabel("类型"), 3, 0)364 grid.addWidget(self.dev_type, 3, 1, 1, 2)365 grid.addWidget(self.btn_link, 4, 0, 1, 3)366 hint = QLabel("USB 转 485 使用自动收发即可")367 hint.setObjectName("muted")368 grid.addWidget(hint, 5, 0, 1, 3)369 layout.addWidget(link)370371 live = QGroupBox("实时")372 live_layout = QVBoxLayout(live)373 self.dial = Dial()374 self.angle_label = QLabel("0.00°")375 self.angle_label.setObjectName("angle")376 self.angle_label.setAlignment(Qt.AlignmentFlag.AlignCenter)377 self.rpm_label = QLabel("0.0 RPM")378 self.rpm_label.setObjectName("rpm")379 self.rpm_label.setAlignment(Qt.AlignmentFlag.AlignCenter)380 self.detail = QLabel("幅度 — U1 — U2 — U4 —")381 self.detail.setWordWrap(True)382 self.detail.setObjectName("muted")383 self.flags = QLabel("未连接")384 self.flags.setObjectName("muted")385 live_layout.addWidget(self.dial)386 live_layout.addWidget(self.angle_label)387 live_layout.addWidget(self.rpm_label)388 live_layout.addWidget(self.detail)389 live_layout.addWidget(self.flags)390 layout.addWidget(live)391392 cap = QGroupBox("采集")393 cap_grid = QGridLayout(cap)394 self.stream_mode = QComboBox()395 self.stream_mode.addItem("主从查询", "poll")396 self.stream_mode.addItem("主动上报", "report")397 self.period = QSpinBox()398 self.period.setRange(0, 1000)399 self.period.setValue(10)400 self.period.setSuffix(" ms")401 self.btn_stream = QPushButton("开始采集")402 self.btn_stream.setObjectName("primary")403 self.btn_stream.clicked.connect(self._toggle_stream)404 self.follow = QCheckBox("跟随最新")405 self.follow.setChecked(True)406 self.span = QComboBox()407 for seconds in (2, 5, 10, 30):408 self.span.addItem(f"{seconds} 秒", seconds)409 self.span.setCurrentIndex(2)410 self.wrap = QCheckBox("角度按 0–360 显示")411 self.demo = QCheckBox("无设备演示")412 self.demo_rpm = QSpinBox()413 self.demo_rpm.setRange(0, 3000)414 self.demo_rpm.setValue(60)415 self.demo_rpm.setSuffix(" RPM")416 self.btn_clear = QPushButton("清空波形")417 self.btn_clear.clicked.connect(self._clear_plot)418 self.btn_csv = QPushButton("开始录制")419 self.btn_csv.clicked.connect(self._toggle_csv)420 cap_grid.addWidget(self.stream_mode, 0, 0, 1, 2)421 cap_grid.addWidget(QLabel("周期"), 1, 0)422 cap_grid.addWidget(self.period, 1, 1)423 cap_grid.addWidget(self.btn_stream, 2, 0, 1, 2)424 cap_grid.addWidget(self.follow, 3, 0)425 cap_grid.addWidget(self.span, 3, 1)426 cap_grid.addWidget(self.wrap, 4, 0, 1, 2)427 cap_grid.addWidget(self.demo, 5, 0)428 cap_grid.addWidget(self.demo_rpm, 5, 1)429 cap_grid.addWidget(self.btn_clear, 6, 0)430 cap_grid.addWidget(self.btn_csv, 6, 1)431 note = QLabel("主从周期 0 表示尽快查询。主动上报最小 5 ms。")432 note.setWordWrap(True)433 note.setObjectName("muted")434 cap_grid.addWidget(note, 7, 0, 1, 2)435 layout.addWidget(cap)436437 cal = QGroupBox("模式、零点与校准")438 cal_layout = QGridLayout(cal)439 self.btn_profile = QPushButton("切换为直线位置")440 self.btn_profile.clicked.connect(self._toggle_profile)441 self.sigma = QDoubleSpinBox()442 self.sigma.setRange(2.0, 30.0)443 self.sigma.setSingleStep(0.5)444 self.sigma.setDecimals(1)445 self.sigma.setValue(8.0)446 self.sigma.setSuffix(" mm")447 self.btn_sigma = QPushButton("写宽度")448 self.btn_sigma.clicked.connect(self._write_sigma)449 self.btn_zero = QPushButton("当前位置设为 0°")450 self.btn_zero.clicked.connect(lambda: self.bus.submit("zero"))451 self.btn_clear_zero = QPushButton("清除零点")452 self.btn_clear_zero.clicked.connect(lambda: self.bus.submit("clear_zero"))453 self.btn_cal_begin = QPushButton("开始校准")454 self.btn_cal_begin.clicked.connect(self._cal_begin)455 self.btn_cal_end = QPushButton("结束校准")456 self.btn_cal_end.clicked.connect(lambda: self.bus.submit("cal_end"))457 self.btn_cal_clear = QPushButton("清除校准")458 self.btn_cal_clear.clicked.connect(lambda: self.bus.submit("cal_clear"))459 self.cal_label = QLabel("角度模式先转满一圈。直线模式先把磁铁拿开,再采基线。")460 self.cal_label.setWordWrap(True)461 self.cal_label.setObjectName("muted")462 cal_layout.addWidget(self.btn_profile, 0, 0, 1, 2)463 cal_layout.addWidget(self.sigma, 1, 0)464 cal_layout.addWidget(self.btn_sigma, 1, 1)465 cal_layout.addWidget(self.btn_zero, 2, 0, 1, 2)466 cal_layout.addWidget(self.btn_clear_zero, 3, 0)467 cal_layout.addWidget(self.btn_cal_clear, 3, 1)468 cal_layout.addWidget(self.btn_cal_begin, 4, 0)469 cal_layout.addWidget(self.btn_cal_end, 4, 1)470 cal_layout.addWidget(self.cal_label, 5, 0, 1, 2)471 layout.addWidget(cal)472473 device = QGroupBox("设备参数")474 dev_grid = QGridLayout(device)475 self.new_id = QSpinBox()476 self.new_id.setRange(1, 254)477 self.new_id.setValue(1)478 self.btn_id = QPushButton("写设备号")479 self.btn_id.clicked.connect(lambda: self.bus.submit("set_id", self.new_id.value()))480 self.new_baud = QComboBox()481 for rate in busmod.BAUD_TABLE:482 self.new_baud.addItem(str(rate), rate)483 self.new_baud.setCurrentIndex(4)484 self.btn_baud = QPushButton("写波特率")485 self.btn_baud.clicked.connect(lambda: self.bus.submit("set_baud", self.new_baud.currentIndex()))486 self.btn_info = QPushButton("读信息")487 self.btn_info.clicked.connect(lambda: self.bus.submit("info"))488 self.btn_factory = QPushButton("恢复出厂")489 self.btn_factory.setObjectName("danger")490 self.btn_factory.clicked.connect(self._factory)491 dev_grid.addWidget(self.new_id, 0, 0)492 dev_grid.addWidget(self.btn_id, 0, 1)493 dev_grid.addWidget(self.new_baud, 1, 0)494 dev_grid.addWidget(self.btn_baud, 1, 1)495 dev_grid.addWidget(self.btn_info, 2, 0)496 dev_grid.addWidget(self.btn_factory, 2, 1)497 layout.addWidget(device)498 layout.addStretch(1)499500 scroll = QScrollArea()501 scroll.setObjectName("scroll")502 scroll.setWidgetResizable(True)503 scroll.setWidget(panel)504 scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)505 scroll.setFixedWidth(338)506 return scroll507508 def _build_plots(self) -> QWidget:509 wrap = QWidget()510 layout = QVBoxLayout(wrap)511 layout.setContentsMargins(0, 0, 0, 0)512 splitter = QSplitter(Qt.Orientation.Vertical)513 self.plot_adc = pg.PlotWidget()514 self.plot_angle = pg.PlotWidget()515 style_plot(self.plot_adc, "霍尔原码")516 style_plot(self.plot_angle, "角度")517 self.plot_angle.setXLink(self.plot_adc)518 legend = self.plot_adc.addLegend(offset=(8, 8))519 legend.setBrush(pg.mkBrush(20, 24, 30, 190))520 legend.setPen(pg.mkPen("#3c424d"))521 pens = (("#3ec6ff", "U1"), ("#ffb020", "U2"), ("#ff5d73", "U4"))522 self.curves = []523 for color, name in pens:524 curve = self.plot_adc.plot(pen=pg.mkPen(color, width=1.6), name=name)525 curve.setClipToView(True)526 curve.setDownsampling(auto=True, method="peak")527 self.curves.append(curve)528 self.angle_curve = self.plot_angle.plot(pen=pg.mkPen("#b6f25c", width=1.8), name="角度")529 self.angle_curve.setClipToView(True)530 self.angle_curve.setDownsampling(auto=True, method="peak")531 self.plot_adc.getViewBox().enableAutoRange(x=False, y=False)532 self.plot_adc.setYRange(0, 4095)533 self.plot_angle.getViewBox().enableAutoRange(x=False, y=True)534 self.plot_adc.getViewBox().sigRangeChangedManually.connect(self._user_panned)535 self.plot_angle.getViewBox().sigRangeChangedManually.connect(self._user_panned)536 splitter.addWidget(self.plot_adc)537 splitter.addWidget(self.plot_angle)538 splitter.setStretchFactor(0, 3)539 splitter.setStretchFactor(1, 2)540 layout.addWidget(splitter, stretch=1)541 self.log = QPlainTextEdit()542 self.log.setReadOnly(True)543 self.log.setMaximumHeight(120)544 self.log.setPlaceholderText("通信记录")545 layout.addWidget(self.log)546 self.status = QLabel("就绪")547 self.status.setObjectName("muted")548 layout.addWidget(self.status)549 return wrap550551 def _refresh_ports(self) -> None:552 current = self.port.currentData()553 self.port.clear()554 ports = busmod.list_serial_ports()555 if not ports:556 self.port.addItem("没有串口", "")557 return558 for device, label in ports:559 self.port.addItem(label, device)560 if device == current:561 self.port.setCurrentIndex(self.port.count() - 1)562563 def _toggle_link(self) -> None:564 if self.connected:565 self.bus.submit("disconnect")566 return567 port = self.port.currentData()568 if not port:569 self._log("没有可用串口")570 return571 self.bus.submit("connect", port, int(self.baud.currentData()), self.dev_id.value(), int(self.dev_type.currentData()))572573 def _toggle_stream(self) -> None:574 if self.streaming or self.demo_on:575 if self.demo_on:576 self.demo_on = False577 self.store.linear = self.kind == busmod.TYPE_POSITION578 self.store.clear()579 self._dirty = True580 self._sync_buttons()581 self._log("停止演示")582 if self.streaming:583 self.bus.submit("stream", "off", 0)584 return585 self.store.clear()586 self._dirty = True587 if self.demo.isChecked():588 self.demo_on = True589 self.store.linear = False590 self.demo_theta = 0.0591 self.demo_next = time.perf_counter()592 self._sync_buttons()593 self._log("演示波形")594 return595 if not self.connected:596 self._log("先连接串口,或勾选无设备演示")597 return598 mode = self.stream_mode.currentData()599 self.bus.submit("stream", mode, self.period.value())600601 def _toggle_profile(self) -> None:602 target = busmod.TYPE_ENCODER if self.kind == busmod.TYPE_POSITION else busmod.TYPE_POSITION603 if target == busmod.TYPE_POSITION:604 self._log("切换后把磁铁拿开,点开始校准再结束,采集零磁场基线。")605 self.bus.submit("set_profile", target)606607 def _write_sigma(self) -> None:608 self.bus.submit("set_sigma", int(round(self.sigma.value() * 1000)))609610 def _cal_begin(self) -> None:611 if self.kind == busmod.TYPE_POSITION:612 self.cal_label.setText("正在采集零磁场基线。确认磁铁已经拿开,再点结束校准。")613 else:614 self.cal_label.setText("正在记录。请把磁铁匀速转满至少一圈,再点结束校准。")615 self.bus.submit("cal_begin")616617 def _factory(self) -> None:618 answer = QMessageBox.question(self, "恢复出厂", "设备号、波特率、零点和校准都会恢复成出厂值。")619 if answer == QMessageBox.StandardButton.Yes:620 self.bus.submit("factory")621622 def _clear_plot(self) -> None:623 self.store.clear()624 self._dirty = True625 self._draw()626627 def _toggle_csv(self) -> None:628 if self.csv_file is not None:629 self._close_csv()630 return631 path, _ = QFileDialog.getSaveFileName(self, "保存波形", "hall.csv", "CSV (*.csv)")632 if not path:633 return634 self.csv_file = open(path, "w", newline="", encoding="utf-8")635 self.csv_writer = csv.writer(self.csv_file)636 if self.store.linear:637 header = ["t_s", "position_mm", "amplitude", "u1", "u2", "u4", "flags", "mm_s"]638 else:639 header = ["t_s", "angle_deg", "amplitude", "u1", "u2", "u4", "flags", "rpm"]640 self.csv_writer.writerow(header)641 self.btn_csv.setText("停止录制")642 self._log(f"正在写入 {path}")643644 def _close_csv(self) -> None:645 if self.csv_file is not None:646 self.csv_file.close()647 self.csv_file = None648 self.csv_writer = None649 self.btn_csv.setText("开始录制")650 self._log("录制已停止")651652 def _user_panned(self) -> None:653 if not self._programmatic_range:654 self.follow.setChecked(False)655656 def _on_tick(self) -> None:657 self._drain_events()658 got = self._drain_samples()659 if self.demo_on:660 got = self._produce_demo() or got661 if self.csv_file is not None and got:662 self.csv_file.flush()663 if got or self._dirty:664 self._draw()665 self._dirty = False666 self._fps_n += 1667 now = time.perf_counter()668 if now - self._fps_t >= 0.5:669 self._fps = self._fps_n / (now - self._fps_t)670 self._fps_n = 0671 self._fps_t = now672 self._update_status()673674 def _drain_events(self) -> None:675 while True:676 try:677 event = self.bus.events.get_nowait()678 except queue.Empty:679 return680 kind = event[0]681 if kind == "log":682 self._log(str(event[1]))683 elif kind == "connected":684 self.connected = bool(event[1])685 self.btn_link.setText("断开" if self.connected else "连接")686 if not self.connected:687 self.streaming = False688 self.flags.setText("未连接")689 self._sync_buttons()690 elif kind == "streaming":691 self.streaming = bool(event[1])692 self._sync_buttons()693 elif kind == "info":694 self._show_info(event[1])695 elif kind == "id":696 self.dev_id.setValue(int(event[1]))697 self.new_id.setValue(int(event[1]))698 elif kind == "baud":699 self._select_baud(int(event[1]))700 elif kind == "mode":701 self.stream_mode.setCurrentIndex(1 if int(event[1]) == busmod.MODE_REPORT else 0)702 self.period.setValue(int(event[2]))703 elif kind == "zero":704 zero = int(event[1]) / 100.0705 if self.kind == busmod.TYPE_POSITION:706 self.cal_label.setText(f"机械零点偏移 {zero:.2f} mm")707 else:708 self.cal_label.setText(f"机械零点偏移 {zero:.2f}°")709 elif kind == "sigma":710 self.sigma.setValue(int(event[1]) / 1000.0)711 elif kind == "cal":712 offsets, gains = event[1], event[2]713 flags = int(event[3]) if len(event) > 3 else 0714 if self.kind == busmod.TYPE_POSITION:715 self.sigma.setValue(gains[0] / 1000.0)716 if flags & 2:717 off_text = " / ".join(str(v) for v in offsets)718 self.cal_label.setText(f"基线 {off_text}\n敏感宽度 {gains[0] / 1000:.1f} mm")719 else:720 self.cal_label.setText(f"尚未采集基线,当前按 2048 估算\n敏感宽度 {gains[0] / 1000:.1f} mm")721 else:722 gain_text = " / ".join(f"{g / 4096:.3f}" for g in gains)723 off_text = " / ".join(str(v) for v in offsets)724 self.cal_label.setText(f"偏置 {off_text}\n增益 {gain_text}")725 elif kind == "factory":726 self.dev_id.setValue(int(event[1]))727 self.new_id.setValue(int(event[1]))728 self._select_baud(int(event[2]))729 self.stream_mode.setCurrentIndex(0)730 self.period.setValue(20)731732 def _apply_kind(self, dev_type: int) -> None:733 linear = dev_type == busmod.TYPE_POSITION734 if linear != self.store.linear and not self.demo_on:735 self.store.linear = linear736 self.store.clear()737 self._dirty = True738 self._wrap_applied = not self.wrap.isChecked()739 self.kind = dev_type if dev_type in (busmod.TYPE_ENCODER, busmod.TYPE_POSITION) else busmod.TYPE_ENCODER740 self.dial.set_linear(linear and not self.demo_on)741 self.btn_profile.setText("切换为角度编码器" if linear else "切换为直线位置")742 self.btn_zero.setText("当前位置设为 0 mm" if linear else "当前位置设为 0°")743 self.wrap.setEnabled(not linear)744 style = {"color": "#c5c8ce", "font-family": "Microsoft YaHei UI", "font-size": "12px"}745 if linear:746 self.plot_angle.setLabel("left", "位置", units="mm", **style)747 else:748 self.plot_angle.setLabel("left", "角度", units="°", **style)749750 def _show_info(self, info: busmod.DeviceInfo) -> None:751 self.dev_id.setValue(info.dev_id)752 self.new_id.setValue(info.dev_id)753 if 0 <= info.baud_index < len(busmod.BAUD_TABLE):754 self._select_baud(busmod.BAUD_TABLE[info.baud_index])755 self.stream_mode.setCurrentIndex(1 if info.mode == busmod.MODE_REPORT else 0)756 self.period.setValue(info.report_ms)757 self._apply_kind(info.dev_type)758 mode = "主动上报" if info.mode == busmod.MODE_REPORT else "主从"759 cal = "已校准" if info.cal_valid else "未校准"760 if info.dev_type == busmod.TYPE_POSITION:761 name = "直线位置"762 zero = f"{info.zero_cdeg / 100:.2f} mm"763 else:764 name = "角度编码器"765 zero = f"{info.zero_cdeg / 100:.2f}°"766 self.flags.setText(f"{name} 固件 {info.fw >> 8}.{info.fw & 0xFF} {mode} {cal} 零点 {zero}")767 self._sync_buttons()768769 def _select_baud(self, baud: int) -> None:770 index = busmod.BAUD_TABLE.index(baud) if baud in busmod.BAUD_TABLE else 4771 self.baud.setCurrentIndex(index)772 self.new_baud.setCurrentIndex(index)773774 def _drain_samples(self) -> bool:775 got = False776 while True:777 try:778 sample = self.bus.samples.get_nowait()779 except queue.Empty:780 break781 self._take_sample(sample)782 got = True783 return got784785 def _take_sample(self, sample: busmod.Sample) -> None:786 self.store.add(sample)787 if self.csv_writer is not None:788 if self.store.linear:789 reading = sample.angle_cdeg / 100.0790 else:791 reading = (sample.angle_cdeg % 36000) / 100.0792 self.csv_writer.writerow([793 f"{sample.t:.6f}",794 f"{reading:.2f}",795 sample.amplitude,796 sample.raw[0],797 sample.raw[1],798 sample.raw[2],799 sample.flags,800 f"{self.store.rpm:.2f}",801 ])802803 def _produce_demo(self) -> bool:804 now = time.perf_counter()805 if self.demo_next <= 0:806 self.demo_next = now807 produced = False808 rpm = self.demo_rpm.value()809 step = 1.0 / 200.0810 omega = rpm / 60.0 * 2.0 * math.pi811 while self.demo_next <= now:812 theta = self.demo_theta813 amp = 1500.0814 raw = []815 phase = (0.0, -2.0 * math.pi / 3.0, 2.0 * math.pi / 3.0)816 for index, shift in enumerate(phase):817 noise = 1.5 * math.sin(theta * 17 + index)818 raw.append(int(2048 + amp * math.cos(theta + shift) + noise))819 deg = math.degrees(theta) % 360.0820 sample = busmod.Sample(self.demo_next, int(round(deg * 100)), int(amp), (raw[0], raw[1], raw[2]), 0)821 self._take_sample(sample)822 self.demo_theta += omega * step823 self.demo_next += step824 produced = True825 if self.demo_next < now - 0.5:826 self.demo_next = now827 break828 return produced829830 def _draw(self) -> None:831 latest = self.store.latest832 linear = self.store.linear and not self.demo_on833 if latest is not None:834 if linear:835 mm = latest.angle_cdeg / 100.0836 clamped = max(-20.0, min(20.0, mm))837 self.dial.set_angle(90.0 - clamped / 20.0 * 90.0)838 self.angle_label.setText(f"{mm:.2f} mm")839 self.rpm_label.setText(f"{self.store.rpm:+.1f} mm/s")840 else:841 deg = (latest.angle_cdeg % 36000) / 100.0842 self.dial.set_angle(deg)843 self.angle_label.setText(f"{deg:.2f}°")844 self.rpm_label.setText(f"{self.store.rpm:+.1f} RPM")845 weak = bool(latest.flags & 1)846 self.angle_label.setStyleSheet("color: #ff6b6b;" if weak else "color: #f4f7fb;")847 self.detail.setText(848 f"幅度 {latest.amplitude} U1 {latest.raw[0]} U2 {latest.raw[1]} U4 {latest.raw[2]}"849 )850 parts = []851 if latest.flags & 1:852 parts.append("磁场弱")853 if latest.flags & 2:854 parts.append("已校准")855 if latest.flags & 4:856 parts.append("校准中")857 if latest.flags & 8:858 parts.append("超出阵列")859 if parts:860 self.flags.setText(" ".join(parts))861 view = self.store.visible(float(self.span.currentData()), self.wrap.isChecked() and not linear)862 if view is None:863 for curve in self.curves:864 curve.setData([], [])865 self.angle_curve.setData([], [])866 return867 t, raw, angle = view868 for index, curve in enumerate(self.curves):869 curve.setData(t, raw[index], connect="all", skipFiniteCheck=True)870 wrap = self.wrap.isChecked()871 if wrap != self._wrap_applied:872 self._wrap_applied = wrap873 if wrap:874 self.plot_angle.getViewBox().enableAutoRange(y=False)875 self.plot_angle.setYRange(-10, 370, padding=0)876 else:877 self.plot_angle.getViewBox().enableAutoRange(y=True)878 connect = "finite" if wrap else "all"879 self.angle_curve.setData(t, angle, connect=connect, skipFiniteCheck=not wrap)880 if self.follow.isChecked() and t.size:881 self._programmatic_range = True882 end = float(t[-1])883 span = float(self.span.currentData())884 self.plot_adc.setXRange(end - span, end + span * 0.02, padding=0)885 self._programmatic_range = False886 self._update_status()887888 def _update_status(self) -> None:889 link = "已连接" if self.connected else "未连接"890 run = "演示" if self.demo_on else ("采集中" if self.streaming else "停止")891 self.status.setText(892 f"{link} {run} 采样 {self.store.rate_hz():.0f} Hz 画面 {self._fps:.0f} fps 丢弃 {self.bus.dropped}"893 )894895 def _sync_buttons(self) -> None:896 running = self.streaming or self.demo_on897 self.btn_stream.setText("停止采集" if running else "开始采集")898 online = self.connected and not self.demo_on899 for button in (900 self.btn_profile, self.btn_sigma, self.btn_zero, self.btn_clear_zero,901 self.btn_cal_begin, self.btn_cal_end, self.btn_cal_clear,902 self.btn_id, self.btn_baud, self.btn_info, self.btn_factory,903 ):904 button.setEnabled(online)905 self.sigma.setEnabled(online and self.kind == busmod.TYPE_POSITION)906 self.btn_sigma.setEnabled(online and self.kind == busmod.TYPE_POSITION)907 self.port.setEnabled(not self.connected)908 self.baud.setEnabled(not self.connected)909 self.btn_refresh.setEnabled(not self.connected)910911 def _log(self, text: str) -> None:912 stamp = time.strftime("%H:%M:%S")913 bar = self.log.verticalScrollBar()914 follow = bar.value() >= bar.maximum() - 4915 self.log.appendPlainText(f"{stamp} {text}")916 if self.log.document().blockCount() > 200:917 cursor = self.log.textCursor()918 cursor.movePosition(cursor.MoveOperation.Start)919 cursor.select(cursor.SelectionType.BlockUnderCursor)920 cursor.removeSelectedText()921 cursor.deleteChar()922 if follow:923 bar.setValue(bar.maximum())924925 def closeEvent(self, event) -> None:926 self._close_csv()927 self.bus.stop()928 event.accept()929930931def main() -> None:932 app = QApplication(sys.argv)933 app.setStyle("Fusion")934 app.setFont(QFont("Microsoft YaHei UI", 10))935 app.setStyleSheet(STYLE)936 window = MainWindow()937 window.show()938 sys.exit(app.exec())939940941if __name__ == "__main__":942 main()