元驭机器人元驭机器人Hall 传感器教学下载原文件
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()