diff --git a/.github/workflows/espfoc_flow.yaml b/.github/workflows/espfoc_flow.yaml index 5e3d20f5..5bfc95df 100644 --- a/.github/workflows/espfoc_flow.yaml +++ b/.github/workflows/espfoc_flow.yaml @@ -75,10 +75,8 @@ jobs: idf.py build popd - # Temporarily disabled while tuner protocol / axis lifecycle catches up with Studio. python_tests: - if: false - name: TunerStudio host tests + name: espFoC Tool host tests runs-on: ubuntu-latest steps: - name: Checkout @@ -94,14 +92,15 @@ jobs: sudo apt-get update sudo apt-get install -y libegl1 libxkbcommon0 libdbus-1-3 python -m pip install --upgrade pip - pip install -r tools/espfoc_studio/requirements.txt + pip install -r tools/espfoc_tool/requirements.txt - name: Run host test suite env: QT_QPA_PLATFORM: offscreen PYTHONPATH: ${{ github.workspace }}/tools + ESPFOC_TOOL_NO_GL: "1" run: | - python -m pytest tools/espfoc_studio/tests/ -v --tb=short + python -m pytest tools/espfoc_tool/tests/ -v --tb=short unit_tests: name: Unit tests (build and run on QEMU) diff --git a/Kconfig b/Kconfig index 436eb324..311ca0fb 100644 --- a/Kconfig +++ b/Kconfig @@ -147,7 +147,7 @@ menu "espFoC Settings" depends on ESP_FOC_TUNER_ENABLE default ESP_FOC_BRIDGE_NONE help - Physical bus for Tuner Studio or custom host tools. + Physical bus for espFoC Tool / espfocctl or custom host tools. config ESP_FOC_BRIDGE_NONE bool "None (weak callbacks in application)" diff --git a/README.md b/README.md index d6150e02..175edf84 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ position loops live in the application's regulation callback; the library stays focused and the hot path runs without floating-point math. Gains can be synthesised at build time, retuned live from the firmware API, persisted to NVS, or dialled in interactively through -the bundled TunerStudio GUI. +the bundled **espFoC Tool** desktop GUI. Targets: ESP32, **ESP32-C6**, ESP32-S3, ESP32-P4 (ESP-IDF v5+). The reference bring-up target is **ESP32-C6** (fixed-point hot path sized for @@ -83,27 +83,30 @@ Inverter and rotor drivers are pluggable: --- -## Tuning +## Tuning (espFoC Tool) -![TunerStudio demo](doc/images/tuner_studio.gif) +![espFoC Tool](doc/images/espfoc_tool_logo.svg) -espFoC ships with **TunerStudio**, a PySide6 + pyqtgraph desktop app -that speaks the runtime tuner protocol over UART or USB-CDC. In a -single window you get: +**espFoC Tool** is a PySide6 + pyqtgraph control app over UART/USB-CDC. +It opens without a board connected (USB auto-scan) and exposes four views: -- live axis state and gain readout with in-place editing; -- one-click rotor alignment with auto-detected natural direction; -- align / run / stop lifecycle and store / erase calibration to NVS; -- predicted step response, Bode, pole-zero and root-locus plots; -- firmware scope stream with per-channel colour, toggle and cursor; -- SVPWM hexagon with the three phase projections and the resultant - voltage vector rotating as the motor is driven. +| View | Purpose | +|------|---------| +| **Config** | Live gains, editor vs device diff, write dirty fields, NVS RMW store/erase | +| **Current** | Motor R/L/bw + MPZ plots (step, Bode, pole-zero, root locus) | +| **Control** | id/iq targets, align, E-stop, SVPWM hexagon | +| **States** | Named scope channels ([axis_tuning](examples/axis_tuning) wire map) | -### Launch TunerStudio +OpenGL plot rendering is enabled by default; set `ESPFOC_TOOL_NO_GL=1` to disable. +Full workflow: [`doc/TUNING.md`](doc/TUNING.md). + +### Launch espFoC Tool ```bash -pip install -r tools/espfoc_studio/requirements.txt -PYTHONPATH=tools python3 -m espfoc_studio.gui --port /dev/ttyACM0 +pip install -r tools/espfoc_tool/requirements.txt +PYTHONPATH=tools python3 -m espfoc_tool.gui +# optional fixed port: +PYTHONPATH=tools python3 -m espfoc_tool.gui --port /dev/ttyACM0 ``` ### Talk to a real target @@ -130,14 +133,19 @@ For your own firmware, enable a transport bridge in `menuconfig` Then: ```bash -PYTHONPATH=tools python3 -m espfoc_studio.gui --port /dev/ttyACM0 +PYTHONPATH=tools python3 -m espfoc_tool.gui --port /dev/ttyACM0 ``` -### Scripted tuning +### Scripted control + +**espfocctl** drives align, run, stop, E-stop, gain writes, id/iq targets, +and NVS store/erase from scripts: -A companion CLI (`tunerctl`) drives align, run, stop, gain writes, -target id/iq, store, and erase from scripts. Details in -[`doc/TUNING.md`](doc/TUNING.md). +```bash +PYTHONPATH=tools python3 -m espfoc_tool.cli.espfocctl --port /dev/ttyACM0 -i +# one-shot E-stop: +PYTHONPATH=tools python3 -m espfoc_tool.cli.espfocctl --port /dev/ttyACM0 estop +``` --- @@ -219,12 +227,11 @@ loop contains no floating-point operations. ``` espFoC/ ├── doc/ -│ ├── images/ # architecture, TunerStudio screenshot, demo gif -│ └── TUNING.md # deep dive: autogen, runtime API, protocol, CLI +│ ├── images/ # architecture, espFoC Tool logo, demo gifs +│ └── TUNING.md # espFoC Tool + espfocctl workflow and scope map ├── examples/ # axis_tuning / unit_test_runner / test_drivers ├── include/espFoC/ # public API -├── scripts/ -│ └── motors/*.json # motor profiles consumed by the autotuner +├── scripts/ # gen_iq31_sin_lut.py, build_samples.sh ├── source/ │ ├── calibration/ # NVS calibration format and axis helpers │ ├── drivers/ # inverters, encoders, shunts, tuner bridges @@ -232,7 +239,7 @@ espFoC/ │ ├── motor_control/ # axis core (FOC ISR + slow loop), MPZ, Q16 helpers │ └── osal/ # OS abstraction (tasks, critical sections, esp_timer) ├── test/ # Unity unit tests (run via examples/unit_test_runner) -└── tools/espfoc_studio # PySide6 + pyqtgraph GUI, CLI, host protocol +└── tools/espfoc_tool # PySide6 GUI (espFoC Tool), espfocctl, host protocol ``` --- diff --git a/changelog.txt b/changelog.txt index 34acf031..b5ca5c73 100644 --- a/changelog.txt +++ b/changelog.txt @@ -6,6 +6,21 @@ This file is used to generate GitHub releases. All versions from 2.0.0 onward ar ## Unreleased +### Added + +- **espFoC Tool** (`tools/espfoc_tool`) — PySide6 control GUI replacing TunerStudio: Config / Current / Control / States views, USB auto-scan, offline navigation, OpenGL plots by default. +- **`espfocctl`** — CLI rename from `tunerctl`; adds `estop` (id/iq zero + stop). +- **`doc/TUNING.md`** — host tuning reference (views, scope map, CLI, troubleshooting). + +### Removed + +- **TunerStudio** host app (`tools/espfoc_studio`), generic Scope tab, in-GUI demo/loopback mode, Generate App / Hardware tabs (never shipped in 3.x tree). + +### Changed + +- Host package **`espfoc_studio` → `espfoc_tool`**; launch with `python -m espfoc_tool.gui`. +- CI **`python_tests`** job runs `tools/espfoc_tool/tests` (offscreen Qt, no GL). + ### Removed - **Tuner override mode** (`CMD_OVERRIDE_ON/OFF`, `TUNER_OVERRIDE` state bit, `ESP_FOC_TUNER_ALWAYS_OVERRIDE_VOLTAGE_MODE`). Host writes `target_i_d` / `target_i_q` directly while the axis is `RUNNING`. diff --git a/doc/TUNING.md b/doc/TUNING.md new file mode 100644 index 00000000..907b6538 --- /dev/null +++ b/doc/TUNING.md @@ -0,0 +1,179 @@ +# espFoC Tool — tuning and host protocol + +This document describes how to control an espFoC axis from the host using +**espFoC Tool** (GUI) or **espfocctl** (CLI). Both use the same binary link +layer and tuner protocol implemented in firmware (`esp_foc_link`, `esp_foc_tuner`). + +--- + +## Prerequisites + +```bash +pip install -r tools/espfoc_tool/requirements.txt +export PYTHONPATH=tools # or prefix every command with PYTHONPATH=tools +``` + +Reference firmware: [`examples/axis_tuning`](../examples/axis_tuning). It advertises +firmware type **`TSGX`** so auto-scan can recognise the board. + +Enable in your own project: + +- `CONFIG_ESP_FOC_TUNER_ENABLE=y` +- `CONFIG_ESP_FOC_BRIDGE_UART` or `CONFIG_ESP_FOC_BRIDGE_USBCDC` +- `CONFIG_ESP_FOC_SCOPE=y` (for Dashboard scope plots) + +--- + +## Quick start (GUI) + +```bash +python3 -m espfoc_tool.gui +# optional fixed port (skips USB scan): +python3 -m espfoc_tool.gui --port /dev/ttyACM0 --baud 921600 +``` + +1. Wait for **CONNECTED** in the status bar (or plug the board — scan runs every 2 s). +2. Open **Dashboard** → **Run alignment** (rotor direction + encoder zero). +3. Enable **Manual setpoints**, set **iq** / **id** (nudge buttons optional). +4. Open **Tune** → edit Kp/Ki/lim/filter → **Write** (RAM) → **Patch** (flash). +5. **E-STOP** (Dashboard → Actions) or disable manual setpoints → axis stops. + +The GUI works **offline**: both views are navigable without a board; device +actions stay disabled until connected. + +--- + +## Views + +### Tune + +| Area | Purpose | +|------|---------| +| Left | Live gains, manual editor, **Apply gains** / **Apply filter**, serial log | +| Center | Device vs pending diff; flash badge (stored / empty) | +| Right | Motor **R**, **L**, **bandwidth**; MPZ step/Bode/pole-zero/root locus | + +**Flash actions** (center column): + +| Button | Action | +|--------|--------| +| **Read** | Load Kp, Ki, lim, filter cutoff from device RAM into the editor | +| **Write** | Push only fields that differ from live RAM | +| **Patch** | Write dirty fields, then **store calibration** to NVS (firmware RMW) | + +**Apply gains** under the MPZ plots writes synthesized Kp/Ki/lim from the motor model. +Pole pairs can be changed in the plot toolbar; persist with **Patch**. + +NVS **store** only writes tuning fields that changed relative to the blob +(`esp_foc_calibration_axis_tuner_store` on device). Align data in NVS is not +edited from the tool in this release. + +### Dashboard + +| Area | Purpose | +|------|---------| +| Left — Motion | Manual setpoints, id/iq spinboxes + nudge | +| Left — Actions | **Run alignment**, **E-STOP**, **Autoset** (SVM/scope reset) | +| Right — top | SVPWM hexagon (pu) beside three-phase waveforms (scope ch 10–12) | +| Right — bottom | Rolling plots for all scope channels (`axis_tuning` map) | + +**E-STOP** sequence: id/iq → 0, `stop` axis (also calls `inverter.disable` via +`park_inverter_safe`). + +Scope streaming starts automatically on connect. + +--- + +## Scope channel map (`axis_tuning`) + +| Ch | Signal | +|----|--------| +| 0 | id target | +| 1 | id measured | +| 2 | iq target | +| 3 | iq measured | +| 4 | ud | +| 5 | uq | +| 6 | θ_meas mech | +| 7 | θ_est mech | +| 8 | ω_est mech | +| 9 | PLL error | +| 10 | iu | +| 11 | iv | +| 12 | iα | +| 13 | FOC hot-path µs | + +Other examples may use different maps; only `axis_tuning` is the contract for espFoC Tool. + +--- + +## espfocctl (CLI) + +Interactive: + +```bash +python3 -m espfoc_tool.cli.espfocctl --port /dev/ttyACM0 -i +``` + +One-shot: + +```bash +python3 -m espfoc_tool.cli.espfocctl --port /dev/ttyACM0 align +python3 -m espfoc_tool.cli.espfocctl --port /dev/ttyACM0 estop +``` + +| Command | Description | +|---------|-------------| +| `connect` / `disconnect` | Link session | +| `status` | Heartbeat + axis state flags | +| `read` | Kp, Ki, Kd, Kff, ILim, Vmax | +| `write --kp … --ki …` | Write gains | +| `align` | Rotor alignment | +| `run` / `stop` | Start / stop FOC loop | +| `set-target id\|iq VALUE` | Current references (A) | +| `store` / `erase` | NVS calibration | +| `cutoff [--set HZ]` | Current LPF | +| `scope-start` / `scope-stop` | Scope stream | +| `firmware-type` | FourCC (expect `TSGX` on axis_tuning) | +| `estop` | id/iq=0 + stop | + +--- + +## Environment variables + +| Variable | Effect | +|----------|--------| +| `ESPFOC_TOOL_NO_GL=1` | Disable OpenGL plot rendering | +| `ESPFOC_TOOL_SCOPE_CSV=1` | Decode legacy CSV scope (if firmware built with legacy CSV) | +| `QT_QPA_PLATFORM=offscreen` | Headless CI / smoke tests | + +--- + +## Host tests + +```bash +QT_QPA_PLATFORM=offscreen ESPFOC_TOOL_NO_GL=1 PYTHONPATH=tools \ + python3 -m pytest tools/espfoc_tool/tests/ -v +``` + +`FakeTunerLoopback` is for unit tests only — not exposed in the GUI. + +--- + +## Troubleshooting + +| Symptom | Check | +|---------|--------| +| Stuck on SCANNING | Cable, driver, correct port; firmware must expose bridge + `TSGX` | +| NO LINK after connect | Heartbeat / baud (default 921600); another app holding the port | +| Align fails | Motor wiring, pole pairs, sensor; see serial log on **Tune** | +| Scope flat | `CONFIG_ESP_FOC_SCOPE`, scope started, PWM loop running | +| Plots slow on VM | `ESPFOC_TOOL_NO_GL=1` or smaller window | + +--- + +## FITL builds + +Firmware built with `CONFIG_ESP_FOC_FITL` simulates plant + sensors in software. +espFoC Tool treats it like a normal target (`TSGX`); use **axis_tuning** without +FITL when validating real hardware. diff --git a/doc/images/README.md b/doc/images/README.md new file mode 100644 index 00000000..778f5ef4 --- /dev/null +++ b/doc/images/README.md @@ -0,0 +1,20 @@ +# Documentation images + +| File | Description | +|------|-------------| +| `architecture.svg` | espFoC component architecture (README) | +| `espfoc_tool_logo.svg` | espFoC Tool icon / README header | +| `espfoc_tool_logo.png` | Raster variant for window icon | +| `espfoc_demo.gif` | Hardware demo (motor running) | +| `espfoc_tool.gif` | *(optional)* espFoC Tool screen capture for README | + +## Recording `espfoc_tool.gif` + +When the UI is stable: + +1. Run `axis_tuning` on hardware and connect espFoC Tool. +2. Capture 1280×720, ~15 s: connect → Tune → Dashboard (iq step, scope). +3. Export as GIF (e.g. `ffmpeg` or Peek) and save as `espfoc_tool.gif`. +4. Link from the root `README.md` tuning section. + +Until then the SVG logo is used as the README visual. diff --git a/doc/images/espfoc_tool_logo.png b/doc/images/espfoc_tool_logo.png new file mode 100644 index 00000000..07ddd7f4 Binary files /dev/null and b/doc/images/espfoc_tool_logo.png differ diff --git a/doc/images/espfoc_tool_logo.svg b/doc/images/espfoc_tool_logo.svg new file mode 100644 index 00000000..e45f57f2 --- /dev/null +++ b/doc/images/espfoc_tool_logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + espFoC + tool + diff --git a/examples/axis_tuning/sdkconfig.defaults b/examples/axis_tuning/sdkconfig.defaults index b9a3a73b..f083498b 100644 --- a/examples/axis_tuning/sdkconfig.defaults +++ b/examples/axis_tuning/sdkconfig.defaults @@ -1,5 +1,5 @@ # axis_tuning — reference firmware with runtime tuner always enabled. -# Host drives align → run → tune → store → stop via tunerctl / Studio. +# Host drives align → run → tune → store → stop via espfocctl / espFoC Tool. CONFIG_ESP_TASK_WDT_EN=n diff --git a/include/espFoC/drivers/gui_link/esp_foc_bridge_uart.h b/include/espFoC/drivers/gui_link/esp_foc_bridge_uart.h index ee140b3b..5f8a3f5d 100644 --- a/include/espFoC/drivers/gui_link/esp_foc_bridge_uart.h +++ b/include/espFoC/drivers/gui_link/esp_foc_bridge_uart.h @@ -9,7 +9,7 @@ * @brief UART bridge for the espFoC tuner / scope link. * * Implements the weak callbacks declared by esp_foc_tuner.h so any - * espFoC build can talk to the host TunerStudio over a regular UART: + * espFoC build can talk to the host espFoC Tool over a regular UART: * * - esp_foc_tuner_init_bus_callback() -> driver install + RX task * - esp_foc_tuner_recv_callback() -> shim around uart_read_bytes diff --git a/tools/espfoc_studio/README.md b/tools/espfoc_studio/README.md deleted file mode 100644 index 996ed69c..00000000 --- a/tools/espfoc_studio/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# espfoc_studio - -Host-side tooling for the espFoC tuner stack. Three layers that share the -same wire format as the firmware (see `source/motor_control/esp_foc_link.c` -and `esp_foc_tuner.c`): - -- `espfoc_studio.link` — framing codec + transports (loopback, pyserial). -- `espfoc_studio.protocol` — synchronous `TunerClient` for read / write / - exec round-trips (axis state, gains, motion targets, calibration). -- `espfoc_studio.model` — analytical helpers (MPZ design, step response, - Bode, pole/zero, root locus). No Qt dependency. -- `espfoc_studio.cli.tunerctl` — argparse CLI on top of TunerClient. -- `espfoc_studio.gui` — PySide6 + pyqtgraph front-end (TunerStudio). - -## Install - -```bash -pip install -r tools/espfoc_studio/requirements.txt -``` - -`PySide6` and `pyqtgraph` are only needed for the GUI; the CLI and the -library layers work with just `pyserial` and `numpy`. - -## TunerStudio GUI - -Requires a serial target (UART or USB-CDC): - -```bash -PYTHONPATH=tools python3 -m espfoc_studio.gui --port /dev/ttyACM0 -``` - -The window opens with: - -- a **Tuning** panel on the left (live gains, manual edit, - override toggle, current references); -- an **Analysis** tab that redraws the predicted step response, Bode - magnitude, pole/zero map and root locus when motor or gain - parameters change; -- **Scope**, **Sensors**, and **SVM Hexagon** tabs. - -Flash firmware with a bridge enabled (`CONFIG_ESP_FOC_BRIDGE_UART` or -`CONFIG_ESP_FOC_BRIDGE_USBCDC`), then use the same `--port` command as above. - -The CLI `tunerctl` works against the same bridges: - -```bash -PYTHONPATH=tools python3 -m espfoc_studio.cli.tunerctl \ - --port /dev/ttyACM0 axis-state -``` - -## Run the host-side tests - -Each test file is standalone and exits with status 0 on success. - -```bash -PYTHONPATH=tools python3 tools/espfoc_studio/tests/test_link_codec.py -PYTHONPATH=tools python3 tools/espfoc_studio/tests/test_tuner_protocol.py -PYTHONPATH=tools python3 tools/espfoc_studio/tests/test_analysis.py -QT_QPA_PLATFORM=offscreen PYTHONPATH=tools \ - python3 tools/espfoc_studio/tests/test_gui_smoke.py -``` diff --git a/tools/espfoc_studio/__init__.py b/tools/espfoc_studio/__init__.py deleted file mode 100644 index f63fefad..00000000 --- a/tools/espfoc_studio/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""espfoc_studio — host-side tooling for the espFoC tuner stack. - -Currently exports the wire-level link codec (mirror of esp_foc_link.c). -Subsequent PRs will add transports (USB-CDC, UART), tuner protocol, -analysis library, CLI and GUI. -""" diff --git a/tools/espfoc_studio/gui/__init__.py b/tools/espfoc_studio/gui/__init__.py deleted file mode 100644 index bf0c7345..00000000 --- a/tools/espfoc_studio/gui/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""espfoc_studio.gui — PySide6 + pyqtgraph front-end for the tuner. - -Run with: - - PYTHONPATH=tools python3 -m espfoc_studio.gui --port /dev/ttyACM0 -""" diff --git a/tools/espfoc_studio/gui/__main__.py b/tools/espfoc_studio/gui/__main__.py deleted file mode 100644 index 43cc1992..00000000 --- a/tools/espfoc_studio/gui/__main__.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Entry point for `python -m espfoc_studio.gui`. - -Requires ``--port`` for a serial transport (UART or USB-CDC bridge). - -Closing the window stops the link reader and exits cleanly. -""" - -from __future__ import annotations - -import argparse -import os -import signal -import sys -from typing import Callable, Optional - -from .main_window import MainWindow -from .theme import apply_dark_theme - - -def _parse_args(argv: Optional[list[str]]) -> argparse.Namespace: - p = argparse.ArgumentParser(prog="python -m espfoc_studio.gui", - description=__doc__) - p.add_argument("--port", required=True, - help="serial port of a real espFoC target (e.g. /dev/ttyACM0)") - p.add_argument("--baud", type=int, default=921600, - help="baud rate") - p.add_argument("--axis", type=int, default=0, - help="axis id the GUI should attach to (0..3)") - p.add_argument( - "--scope-csv", action="store_true", - help="decode legacy SCOPE as CSV (match CONFIG_ESP_FOC_SCOPE_LEGACY_CSV on device)") - return p.parse_args(argv) - - -def _setup_serial(port: str, baud: int, axis: int - ) -> tuple[TunerClient, Callable[[], None]]: - from ..link.transport_serial import SerialTransport - transport = SerialTransport(port=port, baud=baud) - reader = LinkReader(transport) - reader.start() - client = TunerClient(reader, axis=axis) - - def shutdown() -> None: - reader.stop() - - return client, shutdown - - -def main(argv: Optional[list[str]] = None) -> int: - args = _parse_args(argv) - if getattr(args, "scope_csv", False): - os.environ["ESP_FOC_STUDIO_SCOPE_CSV"] = "1" - - from PySide6.QtCore import QTimer - from PySide6.QtWidgets import QApplication - - client, shutdown = _setup_serial(args.port, args.baud, args.axis) - title = f"espFoC TunerStudio — {args.port} @ {args.baud}" - link_descr = f"{args.port} @ {args.baud}" - serial_config = (args.port, args.baud, args.axis) - - app = QApplication.instance() or QApplication(sys.argv) - apply_dark_theme(app) - window = MainWindow( - client, title=title, link_descr=link_descr, - serial_config=serial_config) - window.show() - - signal.signal(signal.SIGINT, lambda *_: app.quit()) - interrupt_tick = QTimer() - interrupt_tick.start(200) - interrupt_tick.timeout.connect(lambda: None) - - exit_code = app.exec() - shutdown() - return exit_code - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/espfoc_studio/gui/main_window.py b/tools/espfoc_studio/gui/main_window.py deleted file mode 100644 index d947734e..00000000 --- a/tools/espfoc_studio/gui/main_window.py +++ /dev/null @@ -1,339 +0,0 @@ -"""Main window: assembles Tuning + Analysis + Scope into a single view. - -Tuner serial round-trips run on :class:`TunerPollWorker`; the GUI thread only -applies snapshots and updates badges.""" - -from __future__ import annotations - -import time -from typing import Optional, Tuple - -from PySide6.QtCore import QMetaObject, QTimer, Qt, QThread -from PySide6.QtWidgets import ( - QHBoxLayout, - QLabel, - QMessageBox, - QMainWindow, - QPushButton, - QSplitter, - QTabWidget, - QSizePolicy, - QWidget, -) - -from ..link import LinkReader -from ..protocol import TunerClient, TunerError -from .theme import make_badge_qss, make_reset_board_button_qss -from .analysis_panel import AnalysisPanel -from .sensors_debug_panel import SensorsDebugPanel -from .scope_panel import ScopePanel -from .scope_stream_timing import scope_uniform_dt_s -from .svm_panel import SvmPanel -from .tuning_panel import TuningPanel -from .tuner_poll_worker import TunerPollSnapshot, TunerPollWorker - -# Consecutive failed pings before the badge switches from CONNECTED to NO LINK. -_LINK_DOWN_AFTER_CONSECUTIVE_PING_FAILS = 10 - - -class MainWindow(QMainWindow): - def __init__(self, client: TunerClient, - title: str = "espFoC TunerStudio", - link_descr: str = "", - serial_config: Optional[Tuple[str, int, int]] = None) -> None: - super().__init__() - self._client = client - self._serial_config = serial_config - self._last_reconnect_mono: float = 0.0 - self._link_ping_seen: bool = False - self._ping_fail_streak: int = 0 - self.setWindowTitle(title) - # 900 px of vertical room is what fits the SVM hexagon (380) - # plus its three-phase waveform (220) plus axis labels and tab - # chrome without clipping. The Scope tab is more forgiving but - # benefits from the same headroom. - self.resize(1280, 900) - self.setMinimumSize(1024, 720) - - central = QWidget() - self.setCentralWidget(central) - root = QHBoxLayout(central) - - splitter = QSplitter(Qt.Horizontal) - root.addWidget(splitter, 1) - - self._analysis = AnalysisPanel(client=self._client) - # The scope and the SVM hexagon both subscribe to the same shared - # LinkReader. ch0/1/2 are (by convention) the three-phase SVPWM - # voltages, which the SVM panel reads exclusively. - self._scope = ScopePanel(reader=self._client.reader) - self._svm = SvmPanel(reader=self._client.reader) - self._sensors = SensorsDebugPanel(reader=self._client.reader) - - # Analysis plots are expensive (step sim + bode + root locus). - # Debounce spinbox storms so one nudge of the mouse wheel doesn't - # fire a dozen full recomputes back-to-back. Must be created - # BEFORE the TuningPanel because the panel primes an initial - # _on_params call during construction. - self._analysis_pending = None - self._analysis_debounce = QTimer(self) - self._analysis_debounce.setSingleShot(True) - self._analysis_debounce.setInterval(150) - self._analysis_debounce.timeout.connect(self._run_pending_analysis) - - self._tuning = TuningPanel( - self._client, on_params_changed=self._on_params) - splitter.addWidget(self._tuning) - - tabs = QTabWidget() - tabs.addTab(self._analysis, "Analysis") - tabs.addTab(self._sensors, "Sensors") - tabs.addTab(self._svm, "SVM Hexagon") - tabs.addTab(self._scope, "Scope") - - splitter.addWidget(tabs) - splitter.setStretchFactor(0, 0) - splitter.setStretchFactor(1, 1) - splitter.setSizes([380, 900]) - - # Status bar: transport + link badge (permanent, right). - if link_descr: - dtext = link_descr - elif serial_config is not None: - dtext = f"{serial_config[0]} @ {serial_config[1]}" - else: - dtext = "" - sb = self.statusBar() - self._link_badge = QLabel() - self._link_badge.setSizePolicy( - QSizePolicy.Minimum, QSizePolicy.Fixed) - self._link_descr = QLabel() - self._link_descr.setStyleSheet("color: #9aa0a6; font-size: 12px;") - self._link_descr.setText(dtext) - self._link_descr.setMinimumWidth(100) - self._reset_btn = QPushButton("RESET BOARD") - self._reset_btn.setCursor(Qt.PointingHandCursor) - self._reset_btn.setStyleSheet(make_reset_board_button_qss()) - self._reset_btn.setToolTip( - "Restart the target (esp_restart). Emergency use only.") - self._reset_btn.clicked.connect(self._on_reset_board_clicked) - sb.addPermanentWidget(self._link_badge, 0) - sb.addPermanentWidget(self._reset_btn, 0) - sb.addPermanentWidget(self._link_descr, 0) - self._set_link_badge("LINK_WAIT") - - self._poll_thread = QThread(self) - self._poll_worker = TunerPollWorker(self._client) - self._poll_worker.moveToThread(self._poll_thread) - self._poll_worker.poll_finished.connect( - self._on_poll_finished, Qt.QueuedConnection) - self._poll_worker.ping_finished.connect( - self._on_ping_finished, Qt.QueuedConnection) - self._poll_worker.device_reads_ready.connect( - self._on_device_reads_ready, Qt.QueuedConnection) - self._tuning.poll_refresh_requested.connect( - self._poll_worker.poll_tick, Qt.QueuedConnection) - self._tuning.long_operation.connect( - self._poll_worker.set_paused, Qt.QueuedConnection) - self._tuning.long_operation.connect( - self._scope.set_live_priority, Qt.QueuedConnection) - self._poll_thread.started.connect(self._poll_worker.start_timer) - self._poll_thread.start() - - self._update_link_badge() - self._set_sensors_interactive() - - def _on_device_reads_ready( - self, - fs_hz: float, - shadows: object, - pole: object, - ) -> None: - """Apply tuner readout gathered on :attr:`_poll_worker` (GUI thread only).""" - if fs_hz > 1.0: - self._analysis.set_loop_rate_hz(fs_hz) - self._tuning.set_loop_rate_hz(fs_hz) - scope_dt = scope_uniform_dt_s(fs_hz) - self._scope.set_uniform_sample_period_s(scope_dt) - self._svm.set_uniform_sample_period_s(scope_dt) - self._sensors.set_uniform_sample_period_s(scope_dt) - if shadows is not None: - t = shadows - self._tuning.apply_nvs_shadow_floats( - t[0], t[1], t[2], t[3], t[4], t[5]) - if pole is not None: - self._analysis.set_motor_pole_pairs_silent(int(pole)) - self._tuning._notify_params_changed() - - def closeEvent(self, event) -> None: - self._tuning.long_operation.emit(True) - QMetaObject.invokeMethod( - self._poll_worker, - "shutdown", - Qt.BlockingQueuedConnection, - ) - self._poll_thread.quit() - self._poll_thread.wait(5000) - super().closeEvent(event) - - def _on_reset_board_clicked(self) -> None: - r = QMessageBox.question( - self, "Reset board", - "Restart the board now? The USB link may drop briefly.", - QMessageBox.Yes | QMessageBox.No, QMessageBox.No, - ) - if r != QMessageBox.Yes: - return - try: - self._client.reset_board() - except TunerError as e: - QMessageBox.warning(self, "Reset failed", str(e)) - - def _set_sensors_interactive(self) -> None: - # Same readiness rule as Scope / SVM: live when the link reader is - # running. Do not gate on tuner poll success — scope frames can flow - # while CMD_TUNER polling is still warming up or temporarily failing. - r = self._client.reader - ok = bool(r and r.is_running) - self._sensors.set_interactive(ok) - - def _set_link_badge(self, key: str) -> None: - text, qss = make_badge_qss(key) - self._link_badge.setText(text) - self._link_badge.setStyleSheet(qss) - self._link_badge.setVisible(True) - - def _update_link_badge(self) -> None: - r = self._client.reader - if r is None or not r.is_running: - self._set_link_badge("LINK_DOWN") - return - if not self._link_ping_seen: - self._set_link_badge("LINK_WAIT") - return - if self._ping_fail_streak >= _LINK_DOWN_AFTER_CONSECUTIVE_PING_FAILS: - self._set_link_badge("LINK_DOWN") - else: - self._set_link_badge("LINK_OK") - - def _on_ping_finished(self, ok: bool, err: str) -> None: - self._link_ping_seen = True - if ok: - self._ping_fail_streak = 0 - else: - self._ping_fail_streak += 1 - self._update_link_badge() - if (not ok - and self._serial_config is not None - and self._poll_error_implies_dead_transport(err) - and self._maybe_reconnect()): - QMetaObject.invokeMethod( - self._poll_worker, - "ping_now", - Qt.QueuedConnection, - ) - - def _reconnect_serial(self) -> bool: - """Replace serial transport. Never acquire ``_bus_lock`` on the GUI thread — - that blocked the window while the worker held the mutex during poll/ping.""" - assert self._serial_config is not None - from ..link.transport_serial import SerialTransport - self._tuning.last_poll_ok = False - self._link_ping_seen = False - self._ping_fail_streak = 0 - self._poll_worker.suspend_requested.emit(True) - time.sleep(0.15) - success = False - try: - self._tuning.detach_log_reader() - old = self._client.reader - try: - old.stop() - except Exception: - pass - port, baud, _axis = self._serial_config - try: - t = SerialTransport(port=port, baud=baud) - except Exception: - success = False - else: - r = LinkReader(t) - r.start() - time.sleep(0.15) - self._client.replace_reader(r) - self._scope.attach_reader(r) - self._svm.attach_reader(r) - self._sensors.attach_reader(r) - self._tuning.rebind_log_reader() - success = True - finally: - QMetaObject.invokeMethod( - self._poll_worker, - "run_post_reconnect_reads", - Qt.QueuedConnection, - ) - QMetaObject.invokeMethod( - self._poll_worker, - "finish_reconnect", - Qt.QueuedConnection, - ) - return success - - def _maybe_reconnect(self) -> bool: - if self._serial_config is None: - return False - now = time.monotonic() - if now - self._last_reconnect_mono < 1.0: - return False - self._last_reconnect_mono = now - if self._reconnect_serial(): - return True - return False - - @staticmethod - def _poll_error_implies_dead_transport(err: str) -> bool: - """Host-side I/O loss or dead reader — safe to reopen serial.""" - el = (err or "").lower() - if ( - "link not running" in el - or "reader stopped" in el - or "link i/o:" in el): - return True - return any( - s in el for s in ( - "errno 5", - "[errno 5]", - "input/output error", - "bad file descriptor", - "serial send failed", - "timeout waiting for response", - "device disconnected", - )) - - def _on_poll_finished( - self, - ok: bool, - err: str, - snap: Optional[TunerPollSnapshot]) -> None: - if ok and snap is not None: - self._tuning.apply_poll_snapshot(snap) - else: - self._tuning.apply_poll_error(err or "poll failed") - self._set_sensors_interactive() - if (self._serial_config is not None - and not self._tuning.last_poll_ok - and self._poll_error_implies_dead_transport(err) - and self._maybe_reconnect()): - self._tuning.poll_refresh_requested.emit(True) - - def _on_params(self, r: float, l: float, bw: float, - kp: float, ki: float) -> None: - self._analysis_pending = (r, l, bw, kp, ki) - self._analysis_debounce.start() - - def _run_pending_analysis(self) -> None: - if self._analysis_pending is None: - return - r, l, bw, kp, ki = self._analysis_pending - self._analysis_pending = None - self._analysis.update_model(r, l, bw, kp, ki) diff --git a/tools/espfoc_studio/gui/scope_panel.py b/tools/espfoc_studio/gui/scope_panel.py deleted file mode 100644 index 8caa4834..00000000 --- a/tools/espfoc_studio/gui/scope_panel.py +++ /dev/null @@ -1,383 +0,0 @@ -"""Scope panel: rolling time-series of every channel emitted by the -firmware's esp_foc_scope. - -Decoded samples go into a **long ring buffer**; the plot shows a window -that lags the newest sample by :attr:`ScopePanel.DISPLAY_LAG_S` so USB -bursts are absorbed instead of discarded. Eviction drops only history -older than ``window + lag + margin``. Optional :meth:`set_live_priority` -shortens the lag while the GUI runs blocking tuner traffic. -""" - -from __future__ import annotations - -import threading -import time -from collections import deque -from typing import Deque, List, Optional, Tuple - -import numpy as np -import pyqtgraph as pg -from PySide6.QtCore import Qt, QTimer -from PySide6.QtGui import QColor -from PySide6.QtWidgets import ( - QCheckBox, - QFrame, - QHBoxLayout, - QLabel, - QPushButton, - QScrollArea, - QVBoxLayout, - QWidget, -) - -from ..link import LinkReader -from ..link.scope_sample import decode_scope_payload_to_floats_csv_first -from .crosshair import attach_crosshair -from .plot_display import ( - configure_dynamic_curve, - configure_rolling_time_xaxis, - decimation_indices_peak_union, - rolling_plot_x_upper, -) -from .scope_stream_timing import scope_uniform_dt_s - - -_CHANNEL_COLORS = ( - "#4fc3f7", - "#ffb74d", - "#81c784", - "#e57373", - "#ba68c8", - "#f06292", - "#aed581", - "#fff176", -) - - -class ScopePanel(QWidget): - WINDOW_S = 2.0 - RENDER_INTERVAL_MS = 55 - MAX_DISPLAY_POINTS_PER_CURVE = 1600 - INBOX_CAP = 8192 - MAX_RAW_FRAMES_DECODE_BATCH = 2048 - MAX_FRAMES_PER_UI_TICK = 2048 - # Legacy names for SensorsDebugPanel (strip charts still use pending merge). - BUFFER_CAP = 4096 - MAX_PENDING_DECODED = 8192 - MAX_MERGE_SAMPLES_PER_TICK = 8192 - - RING_MAX_SAMPLES = 200_000 - DISPLAY_LAG_S = 0.35 - LIVE_PRIORITY_LAG_S = 0.05 - EVICT_MARGIN_S = 1.5 - - def __init__(self, reader: Optional[LinkReader] = None, - sample_period_s: float = 1e-3 * 4, - async_decode: bool = True) -> None: - super().__init__() - self._async_decode = async_decode - self._reader = reader - self._sample_dt = sample_period_s - self._uniform_dt_s = scope_uniform_dt_s(20000.0) - self._scope_synth_t = 0.0 - self._display_lag_s = float(self.DISPLAY_LAG_S) - self._live_priority = False - - self._history_lock = threading.Lock() - self._history: Deque[Tuple[float, Tuple[float, ...]]] = deque( - maxlen=self.RING_MAX_SAMPLES) - - self._inbox_lock = threading.Lock() - self._inbox: Deque[Tuple[float, bytes]] = deque(maxlen=self.INBOX_CAP) - self._worker_stop = threading.Event() - self._decode_thread: Optional[threading.Thread] = None - self._t0 = time.monotonic() - self._curves: List[pg.PlotDataItem] = [] - self._checkboxes: List[QCheckBox] = [] - self._n_channels = 0 - - self._x_buf_a: Optional[np.ndarray] = None - self._x_buf_b: Optional[np.ndarray] = None - self._y_bufs_a: List[Optional[np.ndarray]] = [] - self._y_bufs_b: List[Optional[np.ndarray]] = [] - self._ping_pong = False - - root = QHBoxLayout(self) - - gutter = QFrame() - gutter.setFrameShape(QFrame.NoFrame) - gutter.setMinimumWidth(140) - self._gutter_layout = QVBoxLayout(gutter) - self._gutter_layout.setContentsMargins(4, 4, 4, 4) - autoset_btn = QPushButton("Autoset") - autoset_btn.setToolTip( - "Clear ring buffer and synthetic time; re-enable Y autorange.") - autoset_btn.clicked.connect(self.autoset) - self._gutter_layout.addWidget(autoset_btn) - self._gutter_layout.addWidget(QLabel("Channels")) - self._gutter_layout.addStretch(1) - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setMinimumWidth(160) - scroll.setMaximumWidth(220) - scroll.setWidget(gutter) - root.addWidget(scroll) - - self._plot = pg.PlotWidget(title="Scope — firmware CSV stream") - self._plot.setLabel('left', "amplitude") - self._plot.setLabel('bottom', "time", units='s') - self._plot.showGrid(x=True, y=True, alpha=0.3) - self._plot.setMinimumHeight(380) - configure_rolling_time_xaxis(self._plot) - self._plot.setXRange(0.0, self.WINDOW_S, padding=0) - self._plot.enableAutoRange(axis='x', enable=False) - self._crosshair = attach_crosshair( - self._plot, - fmt=lambda x, y: f"t = {x:.3f} s\ny = {y:+.4g}") - root.addWidget(self._plot, 1) - - self._render_timer = QTimer(self) - self._render_timer.setInterval(self.RENDER_INTERVAL_MS) - self._render_timer.timeout.connect(self._render_tick) - self._render_timer.start() - - if reader is not None: - reader.register_scope_callback(self._on_frame_reader_thread) - - if self._async_decode: - self._decode_thread = threading.Thread( - target=self._decode_worker_loop, - daemon=True, - name="espfoc-scope-decode", - ) - self._decode_thread.start() - else: - self._decode_thread = None - - def closeEvent(self, event) -> None: - self._worker_stop.set() - if self._decode_thread is not None: - self._decode_thread.join(timeout=1.5) - super().closeEvent(event) - - def _effective_lag_s(self) -> float: - if self._live_priority: - return min(self._display_lag_s, self.LIVE_PRIORITY_LAG_S) - return self._display_lag_s - - def _playback_head_s(self, t_newest: float, t_oldest: float) -> float: - """Replay head = newest minus lag; if lag exceeds buffered span, show up to newest.""" - h = t_newest - self._effective_lag_s() - if h < t_oldest: - return t_newest - return h - - def set_sample_period(self, dt_s: float) -> None: - if dt_s > 0: - self.set_uniform_sample_period_s(dt_s) - - def set_uniform_sample_period_s(self, dt_s: float) -> None: - if dt_s > 1e-9: - self._uniform_dt_s = float(dt_s) - self._sample_dt = self._uniform_dt_s - - def set_display_lag_s(self, lag_s: float) -> None: - """Plot trails newest sample by *lag_s* (absorbs transport bursts).""" - if lag_s >= 0.0: - self._display_lag_s = float(lag_s) - - def set_live_priority(self, active: bool) -> None: - """Shorten lag during blocking GUI→target traffic (e.g. NVS save).""" - self._live_priority = bool(active) - - def attach_reader(self, reader: LinkReader) -> None: - if self._reader is not None and self._reader is not reader: - try: - self._reader.unregister_scope_callback( - self._on_frame_reader_thread) - except ValueError: - pass - with self._inbox_lock: - self._inbox.clear() - with self._history_lock: - self._history.clear() - self._scope_synth_t = 0.0 - self._reader = reader - reader.register_scope_callback(self._on_frame_reader_thread) - - def autoset(self) -> None: - with self._inbox_lock: - self._inbox.clear() - with self._history_lock: - self._history.clear() - self._scope_synth_t = 0.0 - self._t0 = time.monotonic() - self._x_buf_a = self._x_buf_b = None - self._y_bufs_a = [] - self._y_bufs_b = [] - self._ping_pong = False - for curve in self._curves: - curve.setData([], []) - self._plot.setXRange(0.0, self.WINDOW_S, padding=0) - self._plot.enableAutoRange(axis='y', enable=True) - - def _on_frame_reader_thread(self, channel: int, seq: int, - payload: bytes) -> None: - t_mono = time.monotonic() - with self._inbox_lock: - self._inbox.append((t_mono, payload)) - - def _decode_batch_values(self, batch: List[Tuple[float, bytes]] - ) -> List[Tuple[float, ...]]: - out: List[Tuple[float, ...]] = [] - for _t_mono, payload in batch: - try: - values = decode_scope_payload_to_floats_csv_first(payload) - except ValueError: - continue - if not values: - continue - out.append(tuple(values)) - return out - - def _flush_rows_to_ring(self, rows: List[Tuple[float, ...]]) -> None: - if not rows: - return - dt = self._uniform_dt_s - with self._history_lock: - for vals in rows: - self._history.append((self._scope_synth_t, vals)) - self._scope_synth_t += dt - self._evict_old_locked() - - def _evict_old_locked(self) -> None: - if not self._history: - return - t_newest = self._history[-1][0] - t_oldest = self._history[0][0] - t_play = self._playback_head_s(t_newest, t_oldest) - t_cut = t_play - self.WINDOW_S - self.EVICT_MARGIN_S - while self._history and self._history[0][0] < t_cut: - self._history.popleft() - - def _decode_worker_loop(self) -> None: - while not self._worker_stop.is_set(): - batch: List[Tuple[float, bytes]] = [] - with self._inbox_lock: - n = len(self._inbox) - if n > 0: - take = min(n, self.MAX_RAW_FRAMES_DECODE_BATCH) - batch = [self._inbox.popleft() for _ in range(take)] - if not batch: - if self._worker_stop.wait(0.003): - break - continue - rows = self._decode_batch_values(batch) - self._flush_rows_to_ring(rows) - - def _render_tick(self) -> None: - if not self._async_decode: - batch: List[Tuple[float, bytes]] = [] - with self._inbox_lock: - n = len(self._inbox) - if n > 0: - take = min(n, self.MAX_RAW_FRAMES_DECODE_BATCH) - batch = [self._inbox.popleft() for _ in range(take)] - if batch: - self._flush_rows_to_ring(self._decode_batch_values(batch)) - - with self._history_lock: - if not self._history: - return - t_newest = self._history[-1][0] - t_oldest = self._history[0][0] - t_play = self._playback_head_s(t_newest, t_oldest) - t_lo = t_play - self.WINDOW_S - xs: List[float] = [] - ycols: Optional[List[List[float]]] = None - for t_s, vals in self._history: - if t_s < t_lo: - continue - if t_s > t_play: - break - xs.append(t_s) - if ycols is None: - ycols = [[] for _ in range(len(vals))] - nc = len(ycols) - for i in range(nc): - v = float(vals[i]) if i < len(vals) else 0.0 - ycols[i].append(v) - - if not xs or ycols is None: - return - max_ch = len(ycols) - self._ensure_channels(max_ch) - t_arr0 = np.asarray(xs, dtype=np.float64) - t_arr = t_arr0 - float(t_arr0[0]) - - use_a = not self._ping_pong - self._ping_pong = not self._ping_pong - if use_a: - self._x_buf_a = t_arr - x_plot = self._x_buf_a - else: - self._x_buf_b = t_arr - x_plot = self._x_buf_b - - n = int(x_plot.shape[0]) - mp = self.MAX_DISPLAY_POINTS_PER_CURVE - y_for_peak: List[np.ndarray] = [] - for i in range(min(len(ycols), len(self._checkboxes))): - if self._checkboxes[i].isChecked(): - y_for_peak.append(np.asarray(ycols[i], dtype=np.float64)) - if n <= mp or not y_for_peak: - dec_idx: Optional[np.ndarray] = None - x_dec = x_plot - else: - dec_idx = decimation_indices_peak_union(y_for_peak, mp) - x_dec = x_plot[dec_idx] - - for i, curve in enumerate(self._curves): - if i >= len(ycols) or not self._checkboxes[i].isChecked(): - curve.setData([], []) - continue - y_arr = np.asarray(ycols[i], dtype=np.float64) - y_dec = y_arr if dec_idx is None else y_arr[dec_idx] - if use_a: - while len(self._y_bufs_a) <= i: - self._y_bufs_a.append(None) - self._y_bufs_a[i] = y_dec - else: - while len(self._y_bufs_b) <= i: - self._y_bufs_b.append(None) - self._y_bufs_b[i] = y_dec - curve.setData(x_dec, y_dec) - - x_up = rolling_plot_x_upper(x_dec, self.WINDOW_S) - self._plot.setXRange(0.0, x_up, padding=0) - - def _ensure_channels(self, n: int) -> None: - if n <= self._n_channels: - return - spacer = self._gutter_layout.takeAt(self._gutter_layout.count() - 1) - for idx in range(self._n_channels, n): - color = _CHANNEL_COLORS[idx % len(_CHANNEL_COLORS)] - cb = QCheckBox(f"ch{idx}") - cb.setChecked(False) - cb.setStyleSheet( - f"QCheckBox {{ color: {color}; font-family: monospace; " - f"font-weight: bold; }}") - self._gutter_layout.addWidget(cb) - self._checkboxes.append(cb) - curve = self._plot.plot( - pen=pg.mkPen(color=QColor(color), width=2)) - configure_dynamic_curve(curve) - self._curves.append(curve) - if spacer is not None and spacer.spacerItem() is not None: - self._gutter_layout.addItem(spacer) - else: - self._gutter_layout.addStretch(1) - self._n_channels = n - - def poll(self) -> None: - pass diff --git a/tools/espfoc_studio/gui/sensors_debug_panel.py b/tools/espfoc_studio/gui/sensors_debug_panel.py deleted file mode 100644 index 61230f11..00000000 --- a/tools/espfoc_studio/gui/sensors_debug_panel.py +++ /dev/null @@ -1,452 +0,0 @@ -"""Sensors: Q16 raw readout, engineering value, and rolling subplots for -SCOPE channels 6..11 (tuner_studio_target wire convention). X-axis uses the -same 2 s rolling window as the main Scope view.""" - -from __future__ import annotations - -import math -import threading -import time -from collections import deque -from dataclasses import dataclass -from typing import Deque, List, Optional, Tuple - -import numpy as np -import pyqtgraph as pg -from PySide6.QtCore import Qt, QTimer -from PySide6.QtGui import QColor -from PySide6.QtWidgets import ( - QCheckBox, - QFormLayout, - QFrame, - QHBoxLayout, - QLabel, - QLineEdit, - QScrollArea, - QSpinBox, - QVBoxLayout, - QWidget, -) - -from ..link import LinkReader -from ..link.scope_sample import decode_scope_payload_to_floats_csv_first -from .crosshair import attach_crosshair -from .plot_display import ( - configure_dynamic_curve, - configure_rolling_time_xaxis, - decimation_indices_peak_union, - rolling_plot_x_upper, -) -from .scope_panel import ScopePanel -from .scope_stream_timing import scope_uniform_dt_s - -SENSOR_CH_FIRST = 6 -SENSOR_N = 6 -HOT_PATH_US_CH = 12 -WINDOW_S = ScopePanel.WINDOW_S -BUFFER_CAP = ScopePanel.BUFFER_CAP -INBOX_CAP = ScopePanel.INBOX_CAP -MAX_PENDING_DECODED = ScopePanel.MAX_PENDING_DECODED -MAX_MERGE_SAMPLES_PER_TICK = ScopePanel.MAX_MERGE_SAMPLES_PER_TICK -MAX_RAW_FRAMES_DECODE_BATCH = ScopePanel.MAX_RAW_FRAMES_DECODE_BATCH -# Six stacked PlotWidgets: modest UI rate; full-rate merge stays in deques. -SENSORS_RENDER_INTERVAL_MS = 55 -STRIP_DISPLAY_MAX_POINTS = 800 -# Tall strip chart; scroll the page to see numeric readouts below. -PLOT_MIN_HEIGHT = 220 - - -def _float_to_q16_int(f: float) -> int: - v = f * 65536.0 - vi = int(round(v)) - if vi > 0x7FFFFFFF: - return 0x7FFFFFFF - if vi < -0x80000000: - return -0x80000000 - return vi - - -def _wrap_counts(ch_f: float, cpr: int) -> float: - """Scope float for ch6 == engineering encoder counts (same as q16/65536).""" - if cpr <= 0: - cpr = 1 - cf = float(cpr) - x = ch_f % cf - if x < 0.0: - x += cf - return x - - -def _eng_text(row: int, ch_f: float, cpr: int) -> str: - if cpr <= 0: - cpr = 1 - if row == 0: - cnt = _wrap_counts(ch_f, cpr) - deg = (cnt / float(cpr)) * 360.0 - return ( - f"θ_m = {deg:+.2f}° ({cnt:.1f} counts / {cpr}, " - f"q16 engineering counts)" - ) - if row == 1: - cps = ch_f - rev_s = cps / float(cpr) - rpm_m = rev_s * 60.0 - w_mech = rev_s * (2.0 * math.pi) - return ( - f"dθ/dt = {cps:+.2f} counts/s | {rev_s:+.6f} rev/s mech | " - f"{rpm_m:+.2f} rpm mech | ω_mech = {w_mech:+.4f} rad/s" - ) - return f"{ch_f:+.5f} A" - - -def _plot_y(row: int, f: float, cpr: int) -> float: - if cpr <= 0: - cpr = 1 - if row == 0: - cnt = _wrap_counts(f, cpr) - return (cnt / float(cpr)) * 360.0 - if row == 1: - return f - return f - - -@dataclass(frozen=True) -class _RowSpec: - title: str - - -def _all_specs() -> List[_RowSpec]: - return [ - _RowSpec("Encoder counts (ch6 → rotor_shaft_ticks)"), - _RowSpec("Velocity counts/s (ch7 → current_speed)"), - _RowSpec("Phase current I_U (ch8)"), - _RowSpec("Phase current I_V (ch9)"), - _RowSpec("Iα Clarke (ch10)"), - _RowSpec("Iβ Clarke (ch11)"), - ] - - -class SensorsDebugPanel(QWidget): - def __init__( - self, - reader: Optional[LinkReader] = None, - async_decode: bool = True) -> None: - super().__init__() - self._reader = reader - self._async_decode = async_decode - self._worker_stop = threading.Event() - self._inbox_lock = threading.Lock() - self._inbox: Deque[Tuple[float, bytes]] = deque(maxlen=INBOX_CAP) - self._pending_lock = threading.Lock() - self._pending_decoded: List[Tuple[float, Tuple[float, ...]]] = [] - self._decode_thread: Optional[threading.Thread] = None - self._t0 = time.monotonic() - self._uniform_dt_s = scope_uniform_dt_s(20000.0) - self._scope_synth_t = 0.0 - self._time_buf: Deque[float] = deque(maxlen=BUFFER_CAP) - self._plot_bufs: List[Deque[float]] = [ - deque(maxlen=BUFFER_CAP) for _ in range(SENSOR_N)] - self._eng_labels: List[QLabel] = [] - self._raw_fields: List[QLineEdit] = [] - self._trace_cbs: List[QCheckBox] = [] - self._curves: List[pg.PlotDataItem] = [] - self._plots: List[pg.PlotWidget] = [] - self._active = True - self._row_specs = _all_specs() - - root = QVBoxLayout(self) - intro = ( - f"Requires SCOPE stream with at least 12 fields (13 for hot-path µs). " - f"Ch{SENSOR_CH_FIRST}: encoder position as Q16 engineering counts " - f"(e.g. AS5600 0…4095); ch{SENSOR_CH_FIRST + 1}: Δcounts×sample rate [counts/s]. " - f"Set CPR below to convert to degrees / mech rpm. " - f"Ch{SENSOR_CH_FIRST + 2}…: currents; ch{HOT_PATH_US_CH}: FOC hot-path [µs]. " - f"Window: {WINDOW_S:.0f} s." - ) - l0 = QLabel(intro) - l0.setWordWrap(True) - l0.setStyleSheet("color: #9aa0a6; font-size: 11px;") - root.addWidget(l0) - - cpr_row = QHBoxLayout() - cpr_row.addWidget(QLabel("Encoder CPR (counts/rev)")) - self._cpr_spin = QSpinBox() - self._cpr_spin.setRange(1, 65536) - self._cpr_spin.setValue(4096) - self._cpr_spin.setToolTip( - "Must match firmware rotor sensor (AS5600: 4096, AS5048: 16384, …)." - ) - cpr_row.addWidget(self._cpr_spin) - cpr_row.addStretch(1) - root.addLayout(cpr_row) - - hp = QHBoxLayout() - hp.addWidget(QLabel("FOC hot path")) - self._hot_path_us = QLineEdit() - self._hot_path_us.setReadOnly(True) - self._hot_path_us.setPlaceholderText("—") - self._hot_path_us.setToolTip( - f"Last channel (ch {HOT_PATH_US_CH}): execution time of the FOC hot " - "path on the target (µs), from esp_foc_now_useconds().") - hp.addWidget(self._hot_path_us, 1) - root.addLayout(hp) - - scroll = QScrollArea() - scroll.setWidgetResizable(True) - body = QWidget() - col = QVBoxLayout(body) - for r in range(SENSOR_N): - col.addWidget(self._build_row(self._row_specs[r])) - col.addStretch(0) - scroll.setWidget(body) - root.addWidget(scroll, 1) - - self._render_timer = QTimer(self) - self._render_timer.setInterval(SENSORS_RENDER_INTERVAL_MS) - self._render_timer.timeout.connect(self._render_tick) - self._render_timer.start() - if reader is not None: - reader.register_scope_callback(self._on_frame_reader_thread) - if self._async_decode: - self._decode_thread = threading.Thread( - target=self._decode_worker_loop, - daemon=True, - name="espfoc-sensors-decode", - ) - self._decode_thread.start() - else: - self._decode_thread = None - - def closeEvent(self, event) -> None: - self._worker_stop.set() - if self._decode_thread is not None: - self._decode_thread.join(timeout=1.5) - super().closeEvent(event) - - def _build_row(self, sp: _RowSpec) -> QWidget: - fr = QFrame() - v_l = QVBoxLayout(fr) - head = QHBoxLayout() - title = QLabel(sp.title) - title.setStyleSheet("font-weight: 600;") - head.addWidget(title) - trace_cb = QCheckBox("Trace") - trace_cb.setChecked(True) - trace_cb.setToolTip( - "Rolling strip for this channel. Uncheck to save CPU.") - head.addWidget(trace_cb) - head.addStretch(1) - v_l.addLayout(head) - self._trace_cbs.append(trace_cb) - plot = pg.PlotWidget() - if "Encoder" in sp.title: - y_lab = "deg (from counts)" - elif "Velocity" in sp.title: - y_lab = "counts/s" - else: - y_lab = "A" - plot.setLabel("left", y_lab) - plot.setLabel("bottom", "time", units="s") - plot.showGrid(x=True, y=True, alpha=0.25) - plot.setMinimumHeight(PLOT_MIN_HEIGHT) - configure_rolling_time_xaxis(plot) - plot.setXRange(0.0, WINDOW_S, padding=0) - plot.enableAutoRange(axis="x", enable=False) - plot.enableAutoRange(axis="y", enable=True) - vb = plot.getViewBox() - vb.setMouseEnabled(x=False, y=True) - c = plot.plot(pen=pg.mkPen(QColor("#4fc3f7"), width=1.5)) - configure_dynamic_curve(c) - _ = attach_crosshair( - plot, fmt=lambda x, y: f"t = {x:.3f} s\ny = {y:+.5g}") - self._plots.append(plot) - self._curves.append(c) - v_l.addWidget(plot, 0) - form = QFormLayout() - r = QLineEdit() - r.setReadOnly(True) - r.setFixedWidth(220) - form.addRow("Raw (Q16 int32)", r) - e = QLabel("—") - e.setTextInteractionFlags( - Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) - form.addRow("Value", e) - v_l.addLayout(form) - self._raw_fields.append(r) - self._eng_labels.append(e) - return fr - - def set_interactive(self, active: bool) -> None: - self._active = active - self.setEnabled(active) - - def set_uniform_sample_period_s(self, dt_s: float) -> None: - if dt_s > 1e-9: - self._uniform_dt_s = float(dt_s) - - def set_counts_per_rev(self, cpr: int) -> None: - """Set encoder counts-per-revolution hint for the synthetic plot (1…65536).""" - if cpr < 1: - cpr = 1 - if cpr > 65536: - cpr = 65536 - self._cpr_spin.setValue(cpr) - - def attach_reader(self, reader: LinkReader) -> None: - if self._reader is not None and self._reader is not reader: - try: - self._reader.unregister_scope_callback( - self._on_frame_reader_thread) - except ValueError: - pass - with self._inbox_lock: - self._inbox.clear() - with self._pending_lock: - self._pending_decoded.clear() - self._scope_synth_t = 0.0 - self._reader = reader - reader.register_scope_callback(self._on_frame_reader_thread) - - def _decode_raw_batch(self, batch: List[Tuple[float, bytes]]) -> None: - out: List[Tuple[float, Tuple[float, ...]]] = [] - for t_mono, payload in batch: - try: - values = decode_scope_payload_to_floats_csv_first(payload) - except ValueError: - continue - if not values: - continue - out.append((t_mono, tuple(values))) - if not out: - return - with self._pending_lock: - self._pending_decoded.extend(out) - while len(self._pending_decoded) > MAX_PENDING_DECODED: - del self._pending_decoded[: len(self._pending_decoded) // 2] - - def _decode_worker_loop(self) -> None: - while not self._worker_stop.is_set(): - batch: List[Tuple[float, bytes]] = [] - with self._inbox_lock: - n = len(self._inbox) - if n > 0: - take = min(n, MAX_RAW_FRAMES_DECODE_BATCH) - batch = [self._inbox.popleft() for _ in range(take)] - if not batch: - if self._worker_stop.wait(0.003): - break - continue - self._decode_raw_batch(batch) - - def _on_frame_reader_thread( - self, _c: int, _s: int, payload: bytes) -> None: - t_mono = time.monotonic() - with self._inbox_lock: - self._inbox.append((t_mono, payload)) - - def _render_tick(self) -> None: - if not self._async_decode: - batch: List[Tuple[float, bytes]] = [] - with self._inbox_lock: - n = len(self._inbox) - if n > 0: - take = min(n, MAX_RAW_FRAMES_DECODE_BATCH) - batch = [self._inbox.popleft() for _ in range(take)] - if batch: - self._decode_raw_batch(batch) - - chunk: List[Tuple[float, Tuple[float, ...]]] - with self._pending_lock: - chunk = self._pending_decoded - self._pending_decoded = [] - - cap = MAX_MERGE_SAMPLES_PER_TICK - if len(chunk) > cap: - chunk = chunk[-cap:] - - need = SENSOR_CH_FIRST + SENSOR_N - last_vals: Optional[Tuple[float, ...]] = None - for _t_mono, vals in chunk: - if len(vals) < need or not self._active: - continue - last_vals = vals - self._time_buf.append(self._scope_synth_t) - self._scope_synth_t += self._uniform_dt_s - cpr = self._cpr_spin.value() - for r in range(SENSOR_N): - f = vals[SENSOR_CH_FIRST + r] - self._plot_bufs[r].append(_plot_y(r, f, cpr)) - - if last_vals is not None and self._active: - cpr = self._cpr_spin.value() - for r in range(SENSOR_N): - f = last_vals[SENSOR_CH_FIRST + r] - self._raw_fields[r].setText(str(_float_to_q16_int(f))) - self._eng_labels[r].setText(_eng_text(r, f, cpr)) - if len(last_vals) > HOT_PATH_US_CH: - self._hot_path_us.setText( - f"{last_vals[HOT_PATH_US_CH]:.2f} µs") - else: - self._hot_path_us.clear() - self._hot_path_us.setPlaceholderText("—") - - while (self._time_buf - and (self._time_buf[-1] - self._time_buf[0] > WINDOW_S)): - self._time_buf.popleft() - for pb in self._plot_bufs: - if pb: - pb.popleft() - - if not self._active: - for p in self._plots: - p.setXRange(0.0, WINDOW_S, padding=0) - return - - if not self._time_buf: - for c in self._curves: - c.setData([], []) - for p in self._plots: - p.setXRange(0.0, WINDOW_S, padding=0) - return - - t_rels = np.fromiter( - self._time_buf, dtype=float, count=len(self._time_buf)) - t_arr = t_rels - float(t_rels[0]) - n = min(len(t_arr), min(len(b) for b in self._plot_bufs)) - if n <= 0: - for c in self._curves: - c.setData([], []) - for p in self._plots: - p.setXRange(0.0, WINDOW_S, padding=0) - return - t_arr = t_arr[-n:] - mp = STRIP_DISPLAY_MAX_POINTS - y_checked = [ - np.fromiter(list(self._plot_bufs[r])[-n:], dtype=float, count=n) - for r in range(len(self._curves)) - if self._trace_cbs[r].isChecked() - ] - if n <= mp: - strip_idx = None - t_d = t_arr - elif y_checked: - strip_idx = decimation_indices_peak_union(y_checked, mp) - t_d = t_arr[strip_idx] - else: - strip_idx = None - t_d = t_arr - for r, c in enumerate(self._curves): - if self._trace_cbs[r].isChecked(): - y = np.fromiter( - list(self._plot_bufs[r])[-n:], dtype=float, count=n) - y_d = y if strip_idx is None else y[strip_idx] - c.setData(t_d, y_d) - else: - c.setData([], []) - - x_up = rolling_plot_x_upper(t_d, WINDOW_S) - for p in self._plots: - p.setXRange(0.0, x_up, padding=0) - - def poll(self) -> None: - pass diff --git a/tools/espfoc_studio/gui/theme.py b/tools/espfoc_studio/gui/theme.py deleted file mode 100644 index 7606c52c..00000000 --- a/tools/espfoc_studio/gui/theme.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Dark theme applied before any window is shown. - -Uses Qt's Fusion style with a handcrafted palette so it looks the same -on every OS and does not require an extra dependency. pyqtgraph gets -its own dark background via setConfigOption; the two conventions need -to agree or the plots look washed out against the panel chrome. -""" - -from __future__ import annotations - -import pyqtgraph as pg -from PySide6.QtCore import Qt -from PySide6.QtGui import QColor, QPalette -from PySide6.QtWidgets import QApplication - - -_BG = "#1e1f22" # window background -_BG_ALT = "#26272b" # group box / card -_FG = "#e6e6e6" # primary text -_DIM = "#9aa0a6" # secondary text -_ACCENT = "#4fc3f7" # highlight (links, selection) -_BORDER = "#3a3b3f" -_ERROR = "#ef5350" - - -# Axis-state badge palette. Picked the dominant flag (override > running > -# aligned > init > none) and rendered it as a single colored pill instead -# of the old "+INITIALIZED -ALIGNED -RUNNING -TUNER_OVERRIDE" text. Keep -# the foreground / background pair high-contrast on the dark theme. -BADGE_STYLES = { - "OFFLINE": ("OFFLINE", "#0b0c0d", "#6c757d"), - "INIT": ("INIT", "#0b0c0d", "#26a69a"), - "ALIGNING": ("ALIGNING", "#0b0c0d", "#ff9800"), - "ALIGNED": ("ALIGNED", "#0b0c0d", _ACCENT), - "RUNNING": ("RUNNING", "#0b0c0d", "#66bb6a"), - "OVERRIDE": ("OVERRIDE", "#ffffff", "#ab47bc"), - "LINK_OK": ("CONNECTED", "#0b0c0d", "#66bb6a"), - "LINK_WAIT": ("CONNECTING", "#0b0c0d", "#ffb300"), - "LINK_DOWN": ("NO LINK", "#ffffff", _ERROR), -} - - -def make_badge_qss(state_key: str) -> tuple[str, str]: - """Returns (label, qss) for the axis state badge. Unknown keys - fall back to the OFFLINE style. Caller applies the qss to a - plain QLabel via setStyleSheet().""" - label, fg, bg = BADGE_STYLES.get(state_key, BADGE_STYLES["OFFLINE"]) - qss = ( - f"QLabel {{" - f" background-color: {bg};" - f" color: {fg};" - f" border-radius: 6px;" - f" padding: 4px 12px;" - f" font-weight: 600;" - f" font-size: 11px;" - f" letter-spacing: 1px;" - f" min-width: 78px;" - f" qproperty-alignment: 'AlignCenter';" - f"}}" - ) - return label, qss - - -def make_reset_board_button_qss() -> str: - """Pill matching link badges, with a clear border and hover for a - pushbutton (emergency board reset).""" - fg, bg = "#ffffff", _ERROR - return ( - f"QPushButton {{" - f" background-color: {bg};" - f" color: {fg};" - f" border: 1px solid #ffcdd2;" - f" border-radius: 6px;" - f" padding: 4px 12px;" - f" font-weight: 600;" - f" font-size: 11px;" - f" letter-spacing: 0.5px;" - f" min-width: 96px;" - f"}}" - f"QPushButton:hover {{" - f" background-color: #e53935;" - f" border: 1px solid #ffebee;" - f" color: #fff;" - f"}}" - f"QPushButton:pressed {{ background-color: #c62828; }}" - f"QPushButton:disabled {{ background-color: #4e342e; color: #bcaaa4;" - f" border-color: #5d4037; }}" - ) - - -def apply_dark_theme(app: QApplication) -> None: - app.setStyle("Fusion") - - pal = QPalette() - pal.setColor(QPalette.Window, QColor(_BG)) - pal.setColor(QPalette.WindowText, QColor(_FG)) - pal.setColor(QPalette.Base, QColor(_BG_ALT)) - pal.setColor(QPalette.AlternateBase, QColor(_BG)) - pal.setColor(QPalette.Text, QColor(_FG)) - pal.setColor(QPalette.Button, QColor(_BG_ALT)) - pal.setColor(QPalette.ButtonText, QColor(_FG)) - pal.setColor(QPalette.ToolTipBase, QColor(_BG)) - pal.setColor(QPalette.ToolTipText, QColor(_FG)) - pal.setColor(QPalette.PlaceholderText, QColor(_DIM)) - pal.setColor(QPalette.Highlight, QColor(_ACCENT)) - pal.setColor(QPalette.HighlightedText, QColor("#0b0c0d")) - pal.setColor(QPalette.Link, QColor(_ACCENT)) - pal.setColor(QPalette.BrightText, QColor(_ERROR)) - pal.setColor(QPalette.Disabled, QPalette.Text, QColor(_DIM)) - pal.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(_DIM)) - pal.setColor(QPalette.Disabled, QPalette.WindowText, QColor(_DIM)) - app.setPalette(pal) - - # Extra polish on top of Fusion: softer group-box borders, denser - # header typography. Kept short so the theme ships dep-free. - app.setStyleSheet(f""" - QMainWindow, QWidget {{ - background-color: {_BG}; - color: {_FG}; - }} - QGroupBox {{ - border: 1px solid {_BORDER}; - border-radius: 6px; - margin-top: 12px; - padding: 6px; - background-color: {_BG_ALT}; - }} - QGroupBox::title {{ - subcontrol-origin: margin; - subcontrol-position: top left; - left: 8px; - padding: 0 4px; - color: {_DIM}; - font-size: 11px; - letter-spacing: 0.5px; - text-transform: uppercase; - }} - QLabel, QCheckBox, QRadioButton {{ - color: {_FG}; - }} - /* Fusion's default indicator melts into the dark background. - * Force a visible square with a clear checked state. */ - QCheckBox::indicator {{ - width: 16px; - height: 16px; - border: 1px solid {_BORDER}; - border-radius: 3px; - background-color: #2b2c30; - }} - QCheckBox::indicator:hover {{ - border: 1px solid {_ACCENT}; - }} - QCheckBox::indicator:checked {{ - background-color: {_ACCENT}; - border: 1px solid {_ACCENT}; - image: none; - }} - QCheckBox::indicator:disabled {{ - background-color: #1a1b1d; - border: 1px solid {_BORDER}; - }} - QLineEdit, QDoubleSpinBox, QSpinBox, QComboBox {{ - background-color: #2b2c30; - border: 1px solid {_BORDER}; - border-radius: 4px; - padding: 3px 6px; - selection-background-color: {_ACCENT}; - selection-color: #0b0c0d; - }} - QPushButton {{ - background-color: #303236; - border: 1px solid {_BORDER}; - border-radius: 4px; - padding: 6px 12px; - }} - QPushButton:hover {{ background-color: #3a3c41; }} - QPushButton:pressed {{ background-color: #2a2c30; }} - QTabWidget::pane {{ border: 1px solid {_BORDER}; border-radius: 4px; }} - QTabBar::tab {{ - background: {_BG}; - color: {_DIM}; - padding: 6px 12px; - border: 1px solid transparent; - border-top-left-radius: 4px; - border-top-right-radius: 4px; - }} - QTabBar::tab:selected {{ - background: {_BG_ALT}; - color: {_FG}; - border: 1px solid {_BORDER}; - border-bottom-color: {_BG_ALT}; - }} - QSplitter::handle {{ background: {_BORDER}; }} - QScrollArea, QScrollArea > QWidget > QWidget {{ - background-color: {_BG}; - }} - """) - - # Align pyqtgraph's own colours with the palette so the plot canvas - # blends with the panel around it. - pg.setConfigOption("background", _BG_ALT) - pg.setConfigOption("foreground", _FG) - pg.setConfigOption("antialias", True) diff --git a/tools/espfoc_studio/gui/tuning_panel.py b/tools/espfoc_studio/gui/tuning_panel.py deleted file mode 100644 index 5cb74f7b..00000000 --- a/tools/espfoc_studio/gui/tuning_panel.py +++ /dev/null @@ -1,490 +0,0 @@ -"""Tuning panel: live gain readout, manual override, MPZ retune.""" - -from __future__ import annotations - -from typing import Callable, Optional - -from PySide6.QtCore import Qt, QThread, QTimer, Signal -from PySide6.QtGui import QFont -from PySide6.QtWidgets import ( - QCheckBox, - QDoubleSpinBox, - QFormLayout, - QGroupBox, - QHBoxLayout, - QLabel, - QPlainTextEdit, - QPushButton, - QScrollArea, - QSizePolicy, - QVBoxLayout, - QWidget, -) - -from ..protocol import AxisStateFlag, TunerClient, TunerError -from .alignment_progress import AlignmentProgressDialog -from .tuner_poll_worker import TunerPollSnapshot -from .theme import make_badge_qss - - -def _spin(minimum: float, maximum: float, - decimals: int, value: float, - step: float = 0.1, - suffix: str = "") -> QDoubleSpinBox: - """Convenience wrapper matching Qt's (min, max) ordering and with an - optional unit suffix. Type-in is always allowed — the step value - only controls the ± buttons / scroll wheel.""" - if minimum > maximum: - minimum, maximum = maximum, minimum - box = QDoubleSpinBox() - box.setRange(minimum, maximum) - box.setDecimals(decimals) - box.setSingleStep(step) - box.setValue(value) - if suffix: - box.setSuffix(suffix) - # Keep the spinner buttons; make the field wide enough so long - # suffixes like " Ω" do not visually collide with the number. - box.setMinimumWidth(140) - return box - - -class _AlignAxisThread(QThread): - """Runs firmware align off the Qt GUI thread; align can block for - many seconds and must not stall repaints, timers, or the scope view.""" - - success = Signal() - failed = Signal(str) - - def __init__(self, client: TunerClient, parent=None) -> None: - super().__init__(parent) - self._client = client - - def run(self) -> None: # noqa: N802 - try: - self._client.align_axis() - except TunerError as e: - self.failed.emit(str(e)) - else: - self.success.emit() - - -class TuningPanel(QWidget): - """Left-hand side of the main window: controls + live readout. - - Most slots use short TunerClient round-trips. Long executables - (align) run in a QThread; MainWindow pauses the background poll - worker to keep bus usage serialized. - - Periodic tuner reads run on :class:`TunerPollWorker`; results arrive via - :meth:`apply_poll_snapshot`.""" - - _logFromReader = Signal(str) - long_operation = Signal(bool) - poll_refresh_requested = Signal(bool) - - def __init__(self, client: TunerClient, - on_params_changed: Optional[Callable[[float, float, float, - float, float], None]] = None) -> None: - super().__init__() - self._client = client - self.last_poll_ok: bool = False - self._on_params_changed = on_params_changed - self._last_motor_r = 1.08 - self._last_motor_l = 0.0018 - self._last_bw = 150.0 - self._cal_present = False - self._align_thread: Optional[_AlignAxisThread] = None - self._align_progress: Optional[AlignmentProgressDialog] = None - self.last_axis_state: Optional[AxisStateFlag] = None - - # The whole panel sits inside a QScrollArea so a small window - # gets a vertical scroll bar instead of clipping content. Keeps - # every existing widget the operator already knows. - outer = QVBoxLayout(self) - outer.setContentsMargins(0, 0, 0, 0) - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - scroll.setFrameShape(QScrollArea.NoFrame) - outer.addWidget(scroll) - - body = QWidget() - scroll.setWidget(body) - root = QVBoxLayout(body) - - # --- Axis state badge (single colored pill, dominant flag) --- - state_row = QHBoxLayout() - state_row.addWidget(QLabel("Axis status:")) - self._state_label = QLabel("OFFLINE") - label, qss = make_badge_qss("OFFLINE") - self._state_label.setText(label) - self._state_label.setStyleSheet(qss) - self._state_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) - state_row.addWidget(self._state_label) - state_row.addStretch(1) - root.addLayout(state_row) - - # --- Live gains readout --- - live_box = QGroupBox("Live gains") - live_form = QFormLayout(live_box) - self._kp_label = QLabel("-") - self._ki_label = QLabel("-") - self._lim_label = QLabel("-") - self._vmax_label = QLabel("-") - for lbl in (self._kp_label, self._ki_label, - self._lim_label, self._vmax_label): - lbl.setStyleSheet("font-family: monospace;") - self._fc_label = QLabel("-") - self._fc_label.setStyleSheet("font-family: monospace;") - self._loop_fs_label = QLabel("-") - self._loop_fs_label.setStyleSheet("font-family: monospace;") - live_form.addRow("Kp [V/A]", self._kp_label) - live_form.addRow("Ki [V/(A·s)]", self._ki_label) - live_form.addRow("ILim [V]", self._lim_label) - live_form.addRow("Vmax [V]", self._vmax_label) - live_form.addRow("I-LPF fc [Hz]", self._fc_label) - live_form.addRow("Loop fs [Hz]", self._loop_fs_label) - root.addWidget(live_box) - - # --- Manual gain editor --- - manual = QGroupBox("Manual gain override") - mform = QFormLayout(manual) - self._kp_spin = _spin(0.0, 500.0, 4, 1.46, step=0.01, suffix=" V/A") - self._ki_spin = _spin(0.0, 1_000_000.0, 2, 659.17, step=10.0, - suffix=" V/(A·s)") - self._lim_spin = _spin(0.0, 200.0, 3, 12.0, step=0.1, suffix=" V") - mform.addRow("Kp", self._kp_spin) - mform.addRow("Ki", self._ki_spin) - mform.addRow("ILim", self._lim_spin) - btn_apply = QPushButton("Apply manual gains") - btn_apply.clicked.connect(self._on_apply_manual) - mform.addRow(btn_apply) - root.addWidget(manual) - - # --- Motor model (analysis tab) --- - mpz = QGroupBox("Motor model (analysis)") - mfrm = QFormLayout(mpz) - self._r_spin = _spin(0.001, 1000.0, 4, self._last_motor_r, - step=0.01, suffix=" Ω") - self._l_mh_spin = _spin(0.001, 10_000.0, 4, - self._last_motor_l * 1000.0, - step=0.01, suffix=" mH") - self._bw_spin = _spin(1.0, 5000.0, 1, self._last_bw, - step=10.0, suffix=" Hz") - self._fc_spin = _spin(10.0, 20000.0, 1, 300.0, - step=10.0, suffix=" Hz") - mfrm.addRow("R", self._r_spin) - mfrm.addRow("L", self._l_mh_spin) - mfrm.addRow("Bandwidth", self._bw_spin) - mfrm.addRow("I-LPF fc", self._fc_spin) - btn_fc = QPushButton("Apply current LPF cutoff") - btn_fc.clicked.connect(self._on_apply_fc) - mfrm.addRow(btn_fc) - root.addWidget(mpz) - # Push any initial model to the Analysis view. - if self._on_params_changed is not None: - self._on_params_changed(self._last_motor_r, self._last_motor_l, - self._last_bw, - self._kp_spin.value(), - self._ki_spin.value()) - for spin in (self._r_spin, self._l_mh_spin, self._bw_spin, - self._kp_spin, self._ki_spin): - spin.valueChanged.connect(self._notify_params_changed) - - # --- Override + motion targets --- - ovr = QGroupBox("Tuner override / motion") - ovr_layout = QVBoxLayout(ovr) - self._override_box = QCheckBox("Override active") - self._override_box.toggled.connect(self._on_override_toggled) - ovr_layout.addWidget(self._override_box) - target_form = QFormLayout() - self._iq_spin = _spin(-50.0, 50.0, 4, 0.0, step=0.1, suffix=" A") - self._id_spin = _spin(-50.0, 50.0, 4, 0.0, step=0.1, suffix=" A") - self._iq_spin.setEnabled(False) - self._id_spin.setEnabled(False) - self._iq_spin.valueChanged.connect(self._on_iq_changed) - self._id_spin.valueChanged.connect(self._on_id_changed) - target_form.addRow("iq ref [A]", self._iq_spin) - target_form.addRow("id ref [A]", self._id_spin) - ovr_layout.addLayout(target_form) - root.addWidget(ovr) - - # --- Alignment + calibration --- - align = QGroupBox("Alignment & calibration") - align_layout = QVBoxLayout(align) - self._align_btn = QPushButton("Align axis") - self._align_btn.clicked.connect(self._on_align) - align_layout.addWidget(self._align_btn) - self._cal_label = QLabel("calibration: -") - self._cal_label.setStyleSheet("font-family: monospace; color: #9aa0a6;") - align_layout.addWidget(self._cal_label) - cal_btns = QHBoxLayout() - for label, slot in (("Store NVS", self._on_persist), - ("Erase", self._on_erase)): - b = QPushButton(label) - b.clicked.connect(slot) - cal_btns.addWidget(b) - align_layout.addLayout(cal_btns) - root.addWidget(align) - - # --- Log channel viewer --- - self._log_view = QPlainTextEdit() - self._log_view.setReadOnly(True) - self._log_view.setMaximumBlockCount(80) - self._log_view.setMaximumHeight(110) - f = QFont("monospace") - f.setPointSize(9) - self._log_view.setFont(f) - root.addWidget(self._log_view) - self._logFromReader.connect(self._append_log) - self.rebind_log_reader() - - # --- Status / errors bar --- - self._status = QLabel("") - self._status.setStyleSheet("color: #c62828;") - root.addWidget(self._status) - root.addStretch(1) - - # --- Public slots (driven by MainWindow / poll worker) ----------------- - - def set_loop_rate_hz(self, fs_hz: float) -> None: - """Receive the firmware's current PI sample rate (read once - on connect by MainWindow). Cached so the UI can show it - without a round-trip per refresh — the value is fixed for - the duration of a session.""" - if fs_hz > 1.0: - self._loop_fs_hz = fs_hz - self._loop_fs_label.setText(f"{fs_hz:9.1f}") - - def rebind_log_reader(self) -> None: - try: - self._client.reader.register_log_callback(self._on_log_reader) - except Exception: - pass - - def detach_log_reader(self) -> None: - try: - self._client.reader.register_log_callback(None) - except Exception: - pass - - def request_full_tuner_poll(self) -> None: - """Ask the background worker for Vmax + NVS-present on the next pass.""" - self.poll_refresh_requested.emit(True) - - def apply_poll_snapshot(self, snap: TunerPollSnapshot) -> None: - """Apply a snapshot emitted by :class:`TunerPollWorker` (GUI thread).""" - self.last_poll_ok = snap.last_poll_ok - self._cal_present = snap.cal_present - self._status.setText("") - self._kp_label.setText(f"{snap.kp:9.4f}") - self._ki_label.setText(f"{snap.ki:9.2f}") - self._lim_label.setText(f"{snap.lim:9.3f}") - self._vmax_label.setText(f"{snap.vmax:9.3f}") - self._fc_label.setText(f"{snap.fc:9.1f}") - self.last_axis_state = AxisStateFlag(snap.state) - badge_key = self._badge_key_for_state(self.last_axis_state) - label, qss = make_badge_qss(badge_key) - self._state_label.setText(label) - self._state_label.setStyleSheet(qss) - self._cal_label.setText("calibration: " + - ("\u2713 present in NVS" if snap.cal_present - else "\u2717 none stored")) - self._override_box.blockSignals(True) - self._override_box.setChecked(snap.override_active) - self._override_box.blockSignals(False) - self._iq_spin.setEnabled(snap.override_active) - self._id_spin.setEnabled(snap.override_active) - - def apply_poll_error(self, msg: str) -> None: - """Worker poll failed (transport / tuner error).""" - self._status.setText(msg) - self.last_poll_ok = False - - # --- Handlers ---------------------------------------------------------- - - def _on_apply_manual(self) -> None: - try: - self._client.write_kp(self._kp_spin.value()) - self._client.write_ki(self._ki_spin.value()) - self._client.write_int_lim(self._lim_spin.value()) - except TunerError as e: - self._status.setText(str(e)) - return - self._status.setText("") - self._notify_params_changed() - - def _on_apply_fc(self) -> None: - try: - self._client.write_current_filter_fc(self._fc_spin.value()) - except TunerError as e: - self._status.setText(str(e)) - return - self._status.setText("") - - def _on_override_toggled(self, checked: bool) -> None: - try: - if checked: - self._client.run_axis() - else: - self._client.stop_axis() - except TunerError as e: - self._status.setText(str(e)) - self._override_box.blockSignals(True) - self._override_box.setChecked(not checked) - self._override_box.blockSignals(False) - return - self._status.setText("") - self._iq_spin.setEnabled(checked) - self._id_spin.setEnabled(checked) - self._status.setText("") - - def _on_iq_changed(self, value: float) -> None: - try: - self._client.write_target_iq(value) - except TunerError as e: - self._status.setText(str(e)) - - def _on_id_changed(self, value: float) -> None: - try: - self._client.write_target_id(value) - except TunerError as e: - self._status.setText(str(e)) - - def _on_align(self) -> None: - if self._align_thread is not None and self._align_thread.isRunning(): - return - self._append_log("> alignment requested") - self._align_btn.setEnabled(False) - self._set_axis_badge_key("ALIGNING") - self.long_operation.emit(True) - par = self.window() - parent_widget = par if isinstance(par, QWidget) else self - self._align_progress = AlignmentProgressDialog(parent_widget) - self._align_progress.show() - self._align_thread = _AlignAxisThread(self._client, self) - self._align_thread.success.connect( - self._on_align_succeeded, Qt.QueuedConnection) - self._align_thread.failed.connect( - self._on_align_failed, Qt.QueuedConnection) - self._align_thread.finished.connect( - self._on_align_thread_finished, Qt.QueuedConnection) - self._align_thread.start() - - def _on_align_succeeded(self) -> None: - self._status.setText("") - - def _on_align_failed(self, err: str) -> None: - self._status.setText(err) - - def _on_align_thread_finished(self) -> None: - if self._align_progress is not None: - self._align_progress.close() - self._align_progress = None - self._align_btn.setEnabled(True) - self.long_operation.emit(False) - self._align_thread = None - try: - self.poll_refresh_requested.emit(True) - except Exception: - pass - - def _set_axis_badge_key(self, key: str) -> None: - label, qss = make_badge_qss(key) - self._state_label.setText(label) - self._state_label.setStyleSheet(qss) - - def _on_persist(self) -> None: - try: - self._client.store_calibration() - except TunerError as e: - self._status.setText(str(e)) - return - self._status.setText("") - self.poll_refresh_requested.emit(True) - - def _on_erase(self) -> None: - try: - self._client.erase_calibration() - except TunerError as e: - self._status.setText(str(e)) - return - self.poll_refresh_requested.emit(True) - - # --- LOG channel viewer -------------------------------------------- - - def _on_log_reader(self, seq: int, payload: bytes) -> None: - """Reader thread context — bounce to the Qt thread.""" - try: - self._logFromReader.emit(payload.decode("ascii", errors="replace")) - except Exception: - pass - - def _append_log(self, line: str) -> None: - if line: - self._log_view.appendPlainText(line.rstrip("\n")) - - def _notify_params_changed(self) -> None: - if self._on_params_changed is None: - return - motor_l_h = self._l_mh_spin.value() * 1e-3 # mH -> H for the model - self._on_params_changed(self._r_spin.value(), motor_l_h, - self._bw_spin.value(), - self._kp_spin.value(), self._ki_spin.value()) - - def apply_nvs_shadow_floats( - self, - r: float, - l_h: float, - bw: float, - kp: float, - ki: float, - fc: float, - ) -> None: - """Apply NVS-shadow values to spinboxes (GUI thread only; no I/O).""" - for sp in (self._r_spin, self._l_mh_spin, self._bw_spin, - self._kp_spin, self._ki_spin, self._fc_spin): - sp.blockSignals(True) - try: - if r > 1e-10: - self._r_spin.setValue(r) - if l_h > 1e-12: - self._l_mh_spin.setValue(l_h * 1e3) - if bw > 0.5: - self._bw_spin.setValue(bw) - self._kp_spin.setValue(kp) - self._ki_spin.setValue(ki) - if fc > 0.0: - self._fc_spin.setValue(fc) - finally: - for sp in (self._r_spin, self._l_mh_spin, self._bw_spin, - self._kp_spin, self._ki_spin, self._fc_spin): - sp.blockSignals(False) - self._last_motor_r = self._r_spin.value() - self._last_motor_l = self._l_mh_spin.value() * 1e-3 - self._last_bw = self._bw_spin.value() - - def sync_motor_from_nvs_shadows(self) -> None: - """Pull R/L/BW and live controls from the target (blocks on :class:`TunerClient`). - - Prefer having the poll worker read and call :meth:`apply_nvs_shadow_floats` - from the GUI thread so the main thread never holds the bus lock.""" - try: - kp = self._client.read_kp() - ki = self._client.read_ki() - fc = self._client.read_current_filter_fc() - except TunerError: - return - self.apply_nvs_shadow_floats(0.0, 0.0, 0.0, kp, ki, fc) - - @staticmethod - def _badge_key_for_state(s: AxisStateFlag) -> str: - """Pick the dominant flag and map it to a badge palette key.""" - if s & AxisStateFlag.RUNNING: - return "RUNNING" - if s & AxisStateFlag.ALIGNED: - return "ALIGNED" - if s & AxisStateFlag.INITIALIZED: - return "INIT" - return "OFFLINE" diff --git a/tools/espfoc_studio/tests/test_gui_smoke.py b/tools/espfoc_studio/tests/test_gui_smoke.py deleted file mode 100644 index d7324241..00000000 --- a/tools/espfoc_studio/tests/test_gui_smoke.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env python3 -"""Headless smoke tests for TunerStudio GUI pieces. - -QT_QPA_PLATFORM=offscreen keeps CI headless. - -Run: - QT_QPA_PLATFORM=offscreen PYTHONPATH=tools \\ - python3 tools/espfoc_studio/tests/test_gui_smoke.py -""" - -from __future__ import annotations - -import os -import sys -import time - -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - -HERE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, os.path.dirname(os.path.dirname(HERE))) - -from PySide6.QtWidgets import QApplication - -from espfoc_studio.link.scope_sample import pack_scope_i32_to_payload - - -def _scope_bin_32(*vals: float) -> bytes: - q = [int(round(f * 65536.0)) for f in vals] - return pack_scope_i32_to_payload(q) - - -def test_scope_panel_uniform_time_and_autoset(): - """X-axis advances by uniform_dt per frame (low-speed scope), even when - USB delivers a burst with identical wall timestamps.""" - from espfoc_studio.gui.scope_panel import ScopePanel - - app = QApplication.instance() or QApplication(sys.argv) - panel = ScopePanel(async_decode=False) - panel.set_uniform_sample_period_s(1e-3) - panel.set_display_lag_s(0.0) - n_inj = min(40, ScopePanel.MAX_MERGE_SAMPLES_PER_TICK - 1) - base = time.monotonic() - for _i in range(n_inj): - with panel._inbox_lock: - panel._inbox.append((base, _scope_bin_32(1.0, 2.0, 3.0))) - panel._render_tick() - assert panel._n_channels == 3 - with panel._history_lock: - hist = list(panel._history) - assert len(hist) == n_inj - times = [t for t, _ in hist] - span = times[-1] - times[0] - exp = (n_inj - 1) * 1e-3 - assert abs(span - exp) < 1e-9, f"span={span!r} expected {exp!r}" - for i in range(1, len(times)): - assert abs(times[i] - times[i - 1] - 1e-3) < 1e-12 - - panel.autoset() - with panel._history_lock: - assert len(panel._history) == 0 - with panel._inbox_lock: - panel._inbox.append((time.monotonic(), _scope_bin_32(4.0, 5.0, 6.0))) - panel._render_tick() - with panel._history_lock: - assert len(panel._history) == 1 - assert panel._history[0][0] == 0.0 - - -def test_scope_stream_timing_hw(): - from espfoc_studio.gui.scope_stream_timing import ( - LOW_SPEED_DOWNSAMPLING, - scope_uniform_dt_s, - ) - - dt_hw = scope_uniform_dt_s(20000.0) - assert abs(dt_hw - LOW_SPEED_DOWNSAMPLING / 20000.0) < 1e-15 - - -def test_scope_panel_roll_mode_x_axis_stays_bounded(): - """X is seconds within the visible window (0 = oldest on screen); - values stay in [0, WINDOW_S] even if the panel has been running - for a long time (t0 is not on the plot axis).""" - from espfoc_studio.gui.scope_panel import ScopePanel - - app = QApplication.instance() or QApplication(sys.argv) - panel = ScopePanel(async_decode=False) - panel._t0 = time.monotonic() - 3600.0 - with panel._inbox_lock: - panel._inbox.append((time.monotonic() - 0.010, _scope_bin_32(1.0, 2.0))) - panel._inbox.append(( - time.monotonic() - 0.005, _scope_bin_32(1.5, 2.5))) - panel._inbox.append((time.monotonic(), _scope_bin_32(2.0, 3.0))) - panel._render_tick() - assert panel._n_channels == 2 - panel._checkboxes[0].setChecked(True) - panel._render_tick() - x_data, _ = panel._curves[0].getData() - assert x_data is not None and len(x_data) == 3 - assert x_data[0] < 0.02, f"oldest sample at x={x_data[0]!r}, expected ~0" - assert x_data[-1] > x_data[0] - assert x_data[-1] < panel.WINDOW_S + 0.1, ( - f"newest x={x_data[-1]!r} past window") - assert min(x_data) >= -0.01, "X should be non-negative (time from t_oldest)" - - -def main() -> int: - tests = [ - test_scope_panel_uniform_time_and_autoset, - test_scope_stream_timing_hw, - test_scope_panel_roll_mode_x_axis_stays_bounded, - ] - failed = 0 - for t in tests: - try: - t() - print(f"OK {t.__name__}") - except Exception as e: - failed += 1 - print(f"FAIL {t.__name__}: {e}") - if failed: - print(f"\n{failed} test(s) failed", file=sys.stderr) - return 1 - print(f"\nAll {len(tests)} tests passed") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/espfoc_tool/README.md b/tools/espfoc_tool/README.md new file mode 100644 index 00000000..30597a74 --- /dev/null +++ b/tools/espfoc_tool/README.md @@ -0,0 +1,86 @@ +# espFoC Tool (host) + +Desktop GUI and CLI for espFoC targets over the link/tuner protocol. + +**Full workflow:** [`doc/TUNING.md`](../../doc/TUNING.md) + +## Install + +```bash +pip install -r tools/espfoc_tool/requirements.txt +``` + +## GUI + +```bash +PYTHONPATH=tools python3 -m espfoc_tool.gui +``` + +| Flag | Description | +|------|-------------| +| `--port` | Fixed serial port (omit to auto-scan USB) | +| `--baud` | Default 921600 | +| `--axis` | Axis index 0..3 | +| `--no-gl` | CPU plot rendering | +| `--scope-csv` | Legacy CSV scope decode | + +Connection state (scanning, connected, link health) is shown in the **status bar**. +All device actions stay disabled until a board is connected; views remain navigable offline. + +## Views + +### Tune + +Setup, flash, and MPZ preview in one screen: + +| Column | Content | +|--------|---------| +| Left | Live gains, manual editor (Kp/Ki/lim/filter), firmware log | +| Center | Flash diff (device vs pending); **Read** / **Write** / **Patch** | +| Right | Motor model (R, L, bandwidth) + four MPZ plots | + +- **Apply gains** (editor): push spinbox values to RAM. +- **Apply gains** (plots): write MPZ-designed Kp/Ki/lim to RAM. +- **Patch**: write dirty fields, then store calibration to flash (firmware RMW). + +### Dashboard + +Runtime control and scope: + +| Column | Content | +|--------|---------| +| Left | Motion (manual id/iq, nudge), **Actions** (align, E-STOP, Autoset) | +| Right | SVPWM hexagon + three-phase waveforms (top), rolling scope channels (bottom) | + +**Autoset** clears SVM trail/waveform history and resets the per-unit scale. + +## CLI + +```bash +PYTHONPATH=tools python3 -m espfoc_tool.cli.espfocctl --port /dev/ttyACM0 -i +``` + +## Layout + +``` +espfoc_tool/ +├── client/ # EspFocClient alias (TunerClient) +├── link/ # framing, serial, scope decode +├── protocol/ # tuner requests +├── model/ # MPZ / Bode (numpy) +├── gui/ +│ ├── views/ # tune_view, dashboard_view +│ ├── theme.py # dark palette + button/surface styles +│ ├── widgets.py +│ └── ... +└── cli/espfocctl.py +``` + +## Tests + +```bash +QT_QPA_PLATFORM=offscreen ESPFOC_TOOL_NO_GL=1 PYTHONPATH=tools \ + python3 -m pytest tools/espfoc_tool/tests/ -v +``` + +`FakeTunerLoopback` is unit-test only — not used by the GUI. diff --git a/tools/espfoc_tool/__init__.py b/tools/espfoc_tool/__init__.py new file mode 100644 index 00000000..04347204 --- /dev/null +++ b/tools/espfoc_tool/__init__.py @@ -0,0 +1,3 @@ +"""espFoC Tool — host-side GUI and CLI for motor control.""" + +__version__ = "0.1.0" diff --git a/tools/espfoc_studio/cli/__init__.py b/tools/espfoc_tool/cli/__init__.py similarity index 100% rename from tools/espfoc_studio/cli/__init__.py rename to tools/espfoc_tool/cli/__init__.py diff --git a/tools/espfoc_studio/cli/tunerctl.py b/tools/espfoc_tool/cli/espfocctl.py similarity index 91% rename from tools/espfoc_studio/cli/tunerctl.py rename to tools/espfoc_tool/cli/espfocctl.py index 24548318..a764d308 100644 --- a/tools/espfoc_studio/cli/tunerctl.py +++ b/tools/espfoc_tool/cli/espfocctl.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -"""tunerctl — interactive espFoC tuner client (protocol v2). +"""espfocctl — interactive espFoC device client (protocol v2). Opens one serial session, runs connect, then accepts subcommands on stdin. -One-shot mode: ``tunerctl --port DEV align`` → connect → align → disconnect. +One-shot: ``espfocctl --port DEV align`` → connect → align → disconnect. """ from __future__ import annotations @@ -55,6 +55,7 @@ def __init__(self, cli: TunerClient) -> None: "scope-stop": self._cmd_scope_stop, "cutoff": self._cmd_cutoff, "firmware-type": self._cmd_firmware_type, + "estop": self._cmd_estop, "quit": self._cmd_quit, "exit": self._cmd_quit, } @@ -62,7 +63,7 @@ def __init__(self, cli: TunerClient) -> None: def _cmd_help(self, _args: list[str]) -> int: print("commands: connect disconnect status read write align run stop " "store erase set-target scope-start scope-stop cutoff " - "firmware-type quit") + "firmware-type estop quit") return 0 def _cmd_connect(self, _args: list[str]) -> int: @@ -185,6 +186,13 @@ def _cmd_firmware_type(self, _args: list[str]) -> int: print(f"firmware_type = 0x{fw:08x} (\"{fourcc}\")") return 0 + def _cmd_estop(self, _args: list[str]) -> int: + self.cli.write_target_id(0.0) + self.cli.write_target_iq(0.0) + self.cli.stop_axis() + print("estop: id/iq=0, axis stopped, inverter disabled") + return 0 + def _cmd_quit(self, _args: list[str]) -> int: return 130 @@ -205,7 +213,7 @@ def run_line(self, line: str) -> int: return handler(parts[1:]) def repl(self) -> int: - print("tunerctl interactive — type 'help' or 'quit'") + print("espfocctl interactive — type 'help' or 'quit'") while True: try: line = input("espfoc> ") @@ -223,7 +231,7 @@ def _one_shot(cli: TunerClient, cmd: str, cmd_args: list[str]) -> int: try: cli.connect() except TunerError as e: - print(f"tunerctl: connect failed: {e}", file=sys.stderr) + print(f"espfocctl: connect failed: {e}", file=sys.stderr) return 1 try: return shell.run_line(" ".join([cmd] + cmd_args)) @@ -235,7 +243,7 @@ def _one_shot(cli: TunerClient, cmd: str, cmd_args: list[str]) -> int: def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(prog="tunerctl") + p = argparse.ArgumentParser(prog="espfocctl") p.add_argument("--port", help="serial port (required except -h)") p.add_argument("--baud", type=int, default=921600) p.add_argument("--axis", type=int, default=0) @@ -260,7 +268,7 @@ def main(argv: Optional[list[str]] = None) -> int: try: shell._cmd_connect([]) except TunerError as e: - print(f"tunerctl: connect failed: {e}", file=sys.stderr) + print(f"espfocctl: connect failed: {e}", file=sys.stderr) return 1 try: return shell.repl() @@ -271,7 +279,7 @@ def main(argv: Optional[list[str]] = None) -> int: pass return _one_shot(cli, args.command[0], args.command[1:]) except TunerError as e: - print(f"tunerctl: {e}", file=sys.stderr) + print(f"espfocctl: {e}", file=sys.stderr) return 1 finally: cli.close() diff --git a/tools/espfoc_tool/client/__init__.py b/tools/espfoc_tool/client/__init__.py new file mode 100644 index 00000000..7d252376 --- /dev/null +++ b/tools/espfoc_tool/client/__init__.py @@ -0,0 +1,17 @@ +"""Host-side device API shared by espFoC Tool GUI and espfocctl.""" + +from ..protocol import TunerClient, TunerError, AxisStateFlag, ParamId, Op +from ..protocol.tuner import ConnectInfo, TUNER_FIRMWARE_TYPE_TSGX + +EspFocClient = TunerClient + +__all__ = [ + "EspFocClient", + "TunerClient", + "TunerError", + "AxisStateFlag", + "ParamId", + "Op", + "ConnectInfo", + "TUNER_FIRMWARE_TYPE_TSGX", +] diff --git a/tools/espfoc_studio/fake_tuner_loopback.py b/tools/espfoc_tool/fake_tuner_loopback.py similarity index 100% rename from tools/espfoc_studio/fake_tuner_loopback.py rename to tools/espfoc_tool/fake_tuner_loopback.py diff --git a/tools/espfoc_tool/gui/__init__.py b/tools/espfoc_tool/gui/__init__.py new file mode 100644 index 00000000..a22eef56 --- /dev/null +++ b/tools/espfoc_tool/gui/__init__.py @@ -0,0 +1,6 @@ +"""espfoc_tool.gui — PySide6 + pyqtgraph front-end for the tuner. + +Run with: + + PYTHONPATH=tools python3 -m espfoc_tool.gui --port /dev/ttyACM0 +""" diff --git a/tools/espfoc_tool/gui/__main__.py b/tools/espfoc_tool/gui/__main__.py new file mode 100644 index 00000000..443dc950 --- /dev/null +++ b/tools/espfoc_tool/gui/__main__.py @@ -0,0 +1,73 @@ +"""Entry point: ``python -m espfoc_tool.gui``.""" + +from __future__ import annotations + +import argparse +import os +import signal +import sys +from pathlib import Path +from typing import Optional + +from PySide6.QtGui import QIcon + +from .app import create_application +from .connection_manager import ConnectionManager +from .main_window import MainWindow + + +def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: + p = argparse.ArgumentParser( + prog="python -m espfoc_tool.gui", + description="espFoC Tool — motor control GUI (optional --port).", + ) + p.add_argument( + "--port", + default=None, + help="serial port (e.g. /dev/ttyACM0); omit to auto-scan USB", + ) + p.add_argument("--baud", type=int, default=921600) + p.add_argument("--axis", type=int, default=0) + p.add_argument( + "--no-gl", + action="store_true", + help="disable OpenGL plot rendering", + ) + p.add_argument( + "--scope-csv", + action="store_true", + help="legacy SCOPE CSV decode (CONFIG_ESP_FOC_SCOPE_LEGACY_CSV)", + ) + return p.parse_args(argv) + + +def main(argv: Optional[list[str]] = None) -> int: + args = _parse_args(argv) + if args.no_gl: + os.environ["ESPFOC_TOOL_NO_GL"] = "1" + if args.scope_csv: + os.environ["ESPFOC_TOOL_SCOPE_CSV"] = "1" + + from PySide6.QtCore import QTimer + + app = create_application() + for icon_name in ("espfoc_tool_logo.svg", "espfoc_tool_logo.png"): + icon_path = Path(__file__).resolve().parents[3] / "doc" / "images" / icon_name + if icon_path.is_file(): + app.setWindowIcon(QIcon(str(icon_path))) + break + conn = ConnectionManager( + baud=args.baud, axis=args.axis, fixed_port=args.port) + window = MainWindow(conn, title="espFoC Tool") + window.show() + + signal.signal(signal.SIGINT, lambda *_: app.quit()) + tick = QTimer() + tick.start(200) + tick.timeout.connect(lambda: None) + + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/espfoc_studio/gui/alignment_progress.py b/tools/espfoc_tool/gui/alignment_progress.py similarity index 100% rename from tools/espfoc_studio/gui/alignment_progress.py rename to tools/espfoc_tool/gui/alignment_progress.py diff --git a/tools/espfoc_studio/gui/analysis_panel.py b/tools/espfoc_tool/gui/analysis_panel.py similarity index 84% rename from tools/espfoc_studio/gui/analysis_panel.py rename to tools/espfoc_tool/gui/analysis_panel.py index 5d5509e0..1f21ef42 100644 --- a/tools/espfoc_studio/gui/analysis_panel.py +++ b/tools/espfoc_tool/gui/analysis_panel.py @@ -10,15 +10,22 @@ from PySide6.QtWidgets import ( QFormLayout, QGridLayout, + QHBoxLayout, + QPushButton, QSpinBox, QVBoxLayout, QWidget, ) +from . import labels as L +from .buttons import action_button +from .widgets import SurfaceCard + from ..model import ( MotorParams, PiGains, bode, + mpz_design, pole_zero_map, root_locus, step_response, @@ -35,21 +42,24 @@ class AnalysisPanel(QWidget): def __init__(self, client: Optional[TunerClient] = None) -> None: super().__init__() self._client = client + self._last_r = 1.08 + self._last_l = 0.0018 + self._last_bw = 150.0 pg.setConfigOptions(antialias=True) root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(10) + toolbar = SurfaceCard() top = QFormLayout() self._motor_pole_pairs = QSpinBox() self._motor_pole_pairs.setRange(1, 64) self._motor_pole_pairs.setValue(7) - self._motor_pole_pairs.setToolTip( - "Number of motor pole pairs p (sent to the target). Use " - "Tuning \u2192 Save to NVS to store in flash together with the " - "rest of the calibration.") - top.addRow("Pole pairs count", self._motor_pole_pairs) + top.addRow(L.POLE_PAIRS, self._motor_pole_pairs) self._motor_pole_pairs.valueChanged.connect( self._on_motor_pole_pairs_changed) - root.addLayout(top) + toolbar.body_layout.addLayout(top) + root.addWidget(toolbar) grid = QGridLayout() root.addLayout(grid, 1) @@ -115,9 +125,33 @@ def __init__(self, client: Optional[TunerClient] = None) -> None: grid.addWidget(self._pz_plot, 1, 0) grid.addWidget(self._rl_plot, 1, 1) + btn_row = QHBoxLayout() + self._apply_mpz_btn = action_button("Apply gains", "BtnDefault") + self._apply_mpz_btn.clicked.connect(self._on_apply_mpz) + btn_row.addWidget(self._apply_mpz_btn) + btn_row.addStretch(1) + root.addLayout(btn_row) + + def _on_apply_mpz(self) -> None: + if self._client is None: + return + try: + motor = MotorParams( + r_ohm=self._last_r, l_h=self._last_l, + ts_s=self._ts_s(), v_max=12.0) + gains = mpz_design(motor, self._last_bw) + self._client.write_kp(gains.kp) + self._client.write_ki(gains.ki) + self._client.write_int_lim(gains.int_lim) + except (TunerError, ValueError) as e: + return + def update_model(self, motor_r: float, motor_l: float, bw_hz: float, kp: float, ki: float) -> None: """Re-render everything for the given operating point.""" + self._last_r = motor_r + self._last_l = motor_l + self._last_bw = bw_hz try: motor = MotorParams(r_ohm=motor_r, l_h=motor_l, ts_s=self._ts_s(), v_max=12.0) diff --git a/tools/espfoc_tool/gui/app.py b/tools/espfoc_tool/gui/app.py new file mode 100644 index 00000000..70fd4b44 --- /dev/null +++ b/tools/espfoc_tool/gui/app.py @@ -0,0 +1,38 @@ +"""Qt application bootstrap: theme, OpenGL plots, optional GPU opt-out.""" + +from __future__ import annotations + +import os +import sys + +import pyqtgraph as pg +from PySide6.QtCore import Qt +from PySide6.QtGui import QSurfaceFormat +from PySide6.QtWidgets import QApplication + +from .theme import apply_dark_theme + + +def graphics_disabled() -> bool: + v = os.environ.get("ESPFOC_TOOL_NO_GL", "").strip().lower() + return v in ("1", "true", "yes", "on") + + +def configure_graphics() -> None: + if graphics_disabled(): + pg.setConfigOptions(useOpenGL=False) + return + QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseDesktopOpenGL, True) + fmt = QSurfaceFormat() + fmt.setSamples(4) + QSurfaceFormat.setDefaultFormat(fmt) + pg.setConfigOptions(useOpenGL=True) + + +def create_application(argv: list[str] | None = None) -> QApplication: + app = QApplication.instance() + if app is None: + app = QApplication(argv if argv is not None else sys.argv) + configure_graphics() + apply_dark_theme(app) + return app diff --git a/tools/espfoc_tool/gui/buttons.py b/tools/espfoc_tool/gui/buttons.py new file mode 100644 index 00000000..dd250cff --- /dev/null +++ b/tools/espfoc_tool/gui/buttons.py @@ -0,0 +1,50 @@ +"""Styled push buttons (single neutral action palette).""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont +from PySide6.QtWidgets import QPushButton + +from .theme import button_font, monospace_font + +def action_button(text: str, role: str = "BtnDefault") -> QPushButton: + btn = QPushButton(text) + apply_button_style(btn, role) + return btn + + +def apply_button_style(btn: QPushButton, role: str) -> None: + estop = role == "BtnEstop" or btn.text().upper() in ("E-STOP", "ESTOP") + nudge = role == "BtnNudge" + reset = role == "BtnReset" + if role == "BtnNav": + obj = "BtnNav" + elif estop: + obj = "BtnEstop" + elif nudge: + obj = "BtnNudge" + elif reset: + obj = "BtnReset" + elif role == "BtnCompact": + obj = "BtnCompact" + else: + obj = "BtnDefault" + btn.setObjectName(obj) + btn.setCursor(Qt.PointingHandCursor) + btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + + if obj == "BtnNav": + btn.setFont(button_font(13, QFont.Weight.Medium)) + elif estop: + f = button_font(13, QFont.Weight.Bold) + f.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 1.5) + btn.setFont(f) + elif nudge: + btn.setFont(monospace_font(10)) + elif obj == "BtnCompact": + btn.setFont(button_font(11, QFont.Weight.Medium)) + elif reset: + btn.setFont(button_font(11, QFont.Weight.Medium)) + else: + btn.setFont(button_font(12, QFont.Weight.Medium)) diff --git a/tools/espfoc_tool/gui/connection_manager.py b/tools/espfoc_tool/gui/connection_manager.py new file mode 100644 index 00000000..410b434e --- /dev/null +++ b/tools/espfoc_tool/gui/connection_manager.py @@ -0,0 +1,189 @@ +"""USB discovery and serial session lifecycle for espFoC Tool.""" + +from __future__ import annotations + +import time +from typing import Callable, Optional + +from PySide6.QtCore import QObject, QTimer, Signal + +from ..link import LinkReader +from ..link.transport_serial import SerialTransport +from ..protocol import TunerClient, TunerError +from ..protocol.tuner import TUNER_FIRMWARE_TYPE_TSGX + +SCAN_INTERVAL_MS = 2000 +CONNECT_PROBE_TIMEOUT_S = 0.35 + + +def list_candidate_ports() -> list[str]: + try: + from serial.tools import list_ports + except ImportError: + return [] + out: list[str] = [] + for info in list_ports.comports(): + dev = info.device + if not dev: + continue + desc = (info.description or "").lower() + if "bluetooth" in desc: + continue + out.append(dev) + return out + + +def probe_port(port: str, baud: int, axis: int) -> Optional[TunerClient]: + """Try CONNECT on *port*; return a live client or None.""" + transport: Optional[SerialTransport] = None + reader: Optional[LinkReader] = None + try: + transport = SerialTransport(port=port, baud=baud) + reader = LinkReader(transport) + reader.start() + client = TunerClient(reader, axis=axis) + info = client.connect(timeout=CONNECT_PROBE_TIMEOUT_S) + if info.firmware_type != TUNER_FIRMWARE_TYPE_TSGX: + client.disconnect() + return None + return client + except (TunerError, OSError, Exception): + if reader is not None: + try: + reader.stop() + except Exception: + pass + if transport is not None: + try: + transport.close() + except Exception: + pass + return None + + +class ConnectionManager(QObject): + """Background scan when offline; holds the active :class:`TunerClient`.""" + + state_changed = Signal(str) + client_ready = Signal(object) + client_lost = Signal() + port_descr_changed = Signal(str) + + STATE_NO_DEVICE = "NO_DEVICE" + STATE_SCANNING = "SCANNING" + STATE_CONNECTING = "CONNECTING" + STATE_CONNECTED = "CONNECTED" + + def __init__( + self, + baud: int = 921600, + axis: int = 0, + fixed_port: Optional[str] = None, + parent: QObject | None = None, + ) -> None: + super().__init__(parent) + self._baud = baud + self._axis = axis + self._fixed_port = fixed_port + self._client: Optional[TunerClient] = None + self._state = self.STATE_NO_DEVICE + self._scan_timer = QTimer(self) + self._scan_timer.setInterval(SCAN_INTERVAL_MS) + self._scan_timer.timeout.connect(self._on_scan_tick) + self._busy = False + self._active_port: Optional[str] = None + + @property + def active_port(self) -> Optional[str]: + return self._active_port + + @property + def client(self) -> Optional[TunerClient]: + return self._client + + @property + def connected(self) -> bool: + return self._client is not None + + @property + def state(self) -> str: + return self._state + + def start(self) -> None: + if self._fixed_port: + self._set_state(self.STATE_CONNECTING) + self.port_descr_changed.emit( + f"{self._fixed_port} @ {self._baud}") + self._try_open(self._fixed_port) + return + self._set_state(self.STATE_SCANNING) + self.port_descr_changed.emit("Scanning USB…") + self._scan_timer.start() + self._on_scan_tick() + + def stop(self) -> None: + self._scan_timer.stop() + self._release_client() + + def _set_state(self, state: str) -> None: + if self._state == state: + return + self._state = state + self.state_changed.emit(state) + + def _release_client(self) -> None: + if self._client is None: + return + try: + self._client.disconnect() + except Exception: + pass + try: + self._client.reader.stop() + except Exception: + pass + self._client = None + self._active_port = None + self.client_lost.emit() + self._set_state(self.STATE_NO_DEVICE) + + def _try_open(self, port: str) -> bool: + if self._busy: + return False + self._busy = True + try: + if self._client is not None: + return True + self._set_state(self.STATE_CONNECTING) + client = probe_port(port, self._baud, self._axis) + if client is None: + return False + self._client = client + self._active_port = port + self._set_state(self.STATE_CONNECTED) + self.port_descr_changed.emit(f"{port} @ {self._baud}") + self.client_ready.emit(client) + self._scan_timer.stop() + return True + finally: + self._busy = False + + def _on_scan_tick(self) -> None: + if self._fixed_port or self._client is not None or self._busy: + return + ports = list_candidate_ports() + if not ports: + self._set_state(self.STATE_SCANNING) + self.port_descr_changed.emit("No USB serial device") + return + self._set_state(self.STATE_SCANNING) + for port in ports: + if self._try_open(port): + return + self.port_descr_changed.emit( + f"Scanning ({len(ports)} port(s))…") + + def replace_client_reader(self, setup: Callable[[TunerClient], None]) -> None: + """After serial reconnect, *setup* rebinds scope subscribers.""" + if self._client is not None: + setup(self._client) diff --git a/tools/espfoc_tool/gui/control_rail.py b/tools/espfoc_tool/gui/control_rail.py new file mode 100644 index 00000000..43114d95 --- /dev/null +++ b/tools/espfoc_tool/gui/control_rail.py @@ -0,0 +1,268 @@ +"""Control view sidebar: id/iq targets, align, E-stop.""" + +from __future__ import annotations + +from typing import Callable, Optional + +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtWidgets import ( + QCheckBox, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from ..protocol import TunerClient, TunerError +from . import labels as L +from .alignment_progress import AlignmentProgressDialog +from .buttons import action_button +from .widgets import SurfaceCard + + +def _spin(minimum: float, maximum: float, decimals: int, value: float, + step: float = 0.1, suffix: str = "") -> QDoubleSpinBox: + box = QDoubleSpinBox() + box.setRange(minimum, maximum) + box.setDecimals(decimals) + box.setSingleStep(step) + box.setValue(value) + if suffix: + box.setSuffix(suffix) + box.setMinimumWidth(140) + return box + + +class _AlignThread(QThread): + success = Signal() + failed = Signal(str) + + def __init__(self, client: TunerClient, parent=None) -> None: + super().__init__(parent) + self._client = client + + def run(self) -> None: # noqa: N802 + try: + self._client.align_axis() + except TunerError as e: + self.failed.emit(str(e)) + else: + self.success.emit() + + +class ControlRail(QWidget): + long_operation = Signal(bool) + + def __init__( + self, + client: Optional[TunerClient], + connected: Callable[[], bool], + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self._client = client + self._connected = connected + self._align_thread: Optional[_AlignThread] = None + self._align_progress: Optional[AlignmentProgressDialog] = None + self._autoset_cb: Optional[Callable[[], None]] = None + + self.setSizePolicy( + QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(12) + + motion_card = SurfaceCard("Motion") + mform = QFormLayout() + mform.setLabelAlignment(Qt.AlignRight) + self._override_box = QCheckBox("Manual setpoints") + self._override_box.toggled.connect(self._on_override_toggled) + mform.addRow(self._override_box) + + self._id_spin = _spin(-50.0, 50.0, 4, 0.0, step=0.1, suffix=" A") + self._iq_spin = _spin(-50.0, 50.0, 4, 0.0, step=0.1, suffix=" A") + self._id_spin.setEnabled(False) + self._iq_spin.setEnabled(False) + mform.addRow(L.D_AXIS_CURRENT, self._id_spin) + mform.addRow(L.Q_AXIS_CURRENT, self._iq_spin) + + id_row = QHBoxLayout() + id_row.setSpacing(6) + for label, delta in (("−", -0.1), ("0", None), ("+", 0.1)): + b = action_button(label, "BtnNudge") + b.clicked.connect( + lambda _c=False, d=delta, s=self._id_spin: self._nudge(s, d)) + id_row.addWidget(b) + id_row.addStretch(1) + mform.addRow("d-axis", id_row) + + iq_row = QHBoxLayout() + iq_row.setSpacing(6) + for label, delta in (("−", -0.1), ("0", None), ("+", 0.1)): + b = action_button(label, "BtnNudge") + b.clicked.connect( + lambda _c=False, d=delta, s=self._iq_spin: self._nudge(s, d)) + iq_row.addWidget(b) + iq_row.addStretch(1) + mform.addRow("q-axis", iq_row) + + self._id_spin.valueChanged.connect(self._on_id_changed) + self._iq_spin.valueChanged.connect(self._on_iq_changed) + motion_card.body_layout.addLayout(mform) + root.addWidget(motion_card) + + safety_card = SurfaceCard("Actions") + safety_lay = QVBoxLayout() + safety_lay.setSpacing(10) + self._align_btn = action_button("Run alignment", "BtnDefault") + self._align_btn.clicked.connect(self._on_align) + safety_lay.addWidget(self._align_btn) + self._estop_btn = action_button("E-STOP", "BtnEstop") + self._estop_btn.clicked.connect(self._on_estop) + safety_lay.addWidget(self._estop_btn) + self._autoset_btn = action_button("Autoset", "BtnDefault") + self._autoset_btn.clicked.connect(self._on_autoset) + safety_lay.addWidget(self._autoset_btn) + safety_card.body_layout.addLayout(safety_lay) + root.addWidget(safety_card) + + self._status = QLabel("") + self._status.setStyleSheet("color: #ef5350; font-size: 11px;") + self._status.setWordWrap(True) + root.addWidget(self._status) + root.addStretch(1) + + self.set_actions_enabled(False) + + def set_client(self, client: Optional[TunerClient]) -> None: + self._client = client + + def bind_svm_autoset(self, callback: Callable[[], None]) -> None: + self._autoset_cb = callback + + def set_actions_enabled(self, on: bool) -> None: + for w in ( + self._override_box, + self._id_spin, + self._iq_spin, + self._align_btn, + self._estop_btn, + self._autoset_btn, + ): + w.setEnabled(on) + for btn in self.findChildren(QPushButton): + if btn is not self._estop_btn: + btn.setEnabled(on) + + def _on_autoset(self) -> None: + if self._autoset_cb is not None: + self._autoset_cb() + + def apply_override_state(self, active: bool) -> None: + self._override_box.blockSignals(True) + self._override_box.setChecked(active) + self._override_box.blockSignals(False) + self._id_spin.setEnabled(active and self._connected()) + self._iq_spin.setEnabled(active and self._connected()) + + @staticmethod + def _nudge(spin: QDoubleSpinBox, delta: Optional[float]) -> None: + if delta is None: + spin.setValue(0.0) + else: + spin.setValue(spin.value() + delta) + + def _require_client(self) -> Optional[TunerClient]: + if not self._connected() or self._client is None: + self._status.setText("Connect a board to run this action.") + return None + self._status.setText("") + return self._client + + def _on_override_toggled(self, on: bool) -> None: + cli = self._require_client() + if cli is None: + self._override_box.blockSignals(True) + self._override_box.setChecked(False) + self._override_box.blockSignals(False) + return + try: + if on: + cli.run_axis() + else: + cli.stop_axis() + except TunerError as e: + self._status.setText(str(e)) + self._override_box.blockSignals(True) + self._override_box.setChecked(not on) + self._override_box.blockSignals(False) + return + self._id_spin.setEnabled(on) + self._iq_spin.setEnabled(on) + + def _on_id_changed(self, v: float) -> None: + cli = self._require_client() + if cli is None: + return + try: + cli.write_target_id(v) + except TunerError as e: + self._status.setText(str(e)) + + def _on_iq_changed(self, v: float) -> None: + cli = self._require_client() + if cli is None: + return + try: + cli.write_target_iq(v) + except TunerError as e: + self._status.setText(str(e)) + + def _on_align(self) -> None: + cli = self._require_client() + if cli is None: + return + self.long_operation.emit(True) + self._align_progress = AlignmentProgressDialog(self) + self._align_progress.show() + self._align_thread = _AlignThread(cli, self) + self._align_thread.success.connect(self._on_align_done) + self._align_thread.failed.connect(self._on_align_failed) + self._align_thread.finished.connect(self._align_thread.deleteLater) + self._align_thread.start() + + def _on_align_done(self) -> None: + if self._align_progress is not None: + self._align_progress.accept() + self._align_progress = None + self.long_operation.emit(False) + + def _on_align_failed(self, msg: str) -> None: + if self._align_progress is not None: + self._align_progress.reject() + self._align_progress = None + self._status.setText(msg) + self.long_operation.emit(False) + + def _on_estop(self) -> None: + cli = self._require_client() + if cli is None: + return + try: + cli.write_target_id(0.0) + cli.write_target_iq(0.0) + cli.stop_axis() + self._override_box.blockSignals(True) + self._override_box.setChecked(False) + self._override_box.blockSignals(False) + self._id_spin.setValue(0.0) + self._iq_spin.setValue(0.0) + self._id_spin.setEnabled(False) + self._iq_spin.setEnabled(False) + except TunerError as e: + self._status.setText(str(e)) diff --git a/tools/espfoc_studio/gui/crosshair.py b/tools/espfoc_tool/gui/crosshair.py similarity index 100% rename from tools/espfoc_studio/gui/crosshair.py rename to tools/espfoc_tool/gui/crosshair.py diff --git a/tools/espfoc_tool/gui/labels.py b/tools/espfoc_tool/gui/labels.py new file mode 100644 index 00000000..3658684a --- /dev/null +++ b/tools/espfoc_tool/gui/labels.py @@ -0,0 +1,16 @@ +"""Plain-language UI labels shared across panels.""" + +PROPORTIONAL_GAIN = "Proportional gain" +INTEGRAL_GAIN = "Integral gain" +CURRENT_LIMIT = "Current limit" +VOLTAGE_LIMIT = "Voltage limit" +CURRENT_FILTER = "Current filter" +LOOP_RATE = "Loop rate" + +RESISTANCE = "Resistance" +INDUCTANCE = "Inductance" +CONTROL_BANDWIDTH = "Control bandwidth" +POLE_PAIRS = "Pole pairs" + +D_AXIS_CURRENT = "d-axis current" +Q_AXIS_CURRENT = "q-axis current" diff --git a/tools/espfoc_tool/gui/main_window.py b/tools/espfoc_tool/gui/main_window.py new file mode 100644 index 00000000..0ebc6b11 --- /dev/null +++ b/tools/espfoc_tool/gui/main_window.py @@ -0,0 +1,373 @@ +"""espFoC Tool main window: nav rail + Tune/Dashboard, optional USB auto-connect.""" + +from __future__ import annotations + +import time +from typing import Optional, Tuple + +from PySide6.QtCore import QMetaObject, Qt, QThread, QTimer +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QMessageBox, + QMainWindow, + QPushButton, + QStackedWidget, + QSizePolicy, + QWidget, +) + +from ..link import LinkReader +from ..link.transport_serial import SerialTransport +from ..protocol import TunerClient, TunerError +from .analysis_panel import AnalysisPanel +from .connection_manager import ConnectionManager +from .control_rail import ControlRail +from .nav_rail import NavRail +from .scope_stream_timing import scope_uniform_dt_s +from .states_panel import StatesPanel +from .svm_panel import SvmPanel +from .buttons import action_button +from .theme import make_badge_qss +from .tuning_panel import TuningPanel +from .tuner_poll_worker import TunerPollSnapshot, TunerPollWorker +from .views import DashboardView, TuneView + +_LINK_DOWN_AFTER_CONSECUTIVE_PING_FAILS = 10 + + +class MainWindow(QMainWindow): + def __init__( + self, + conn: ConnectionManager, + title: str = "espFoC Tool", + ) -> None: + super().__init__() + self._conn = conn + self._client: Optional[TunerClient] = None + self._serial_config: Optional[Tuple[str, int, int]] = None + self._link_ping_seen = False + self._ping_fail_streak = 0 + self._last_reconnect_mono = 0.0 + + self.setWindowTitle(title) + self.resize(1320, 900) + self.setMinimumSize(1080, 720) + + central = QWidget() + self.setCentralWidget(central) + outer = QHBoxLayout(central) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + self._nav = NavRail() + outer.addWidget(self._nav) + + self._stack = QStackedWidget() + self._nav.page_selected.connect(self._stack.setCurrentIndex) + outer.addWidget(self._stack, 1) + + self._analysis = AnalysisPanel(client=None) + self._analysis_debounce = QTimer(self) + self._analysis_debounce.setSingleShot(True) + self._analysis_debounce.setInterval(150) + self._analysis_debounce.timeout.connect(self._run_pending_analysis) + self._analysis_pending = None + + self._tuning = TuningPanel(client=None, scrollable=False) + self._tune = TuneView( + self._tuning, + self._analysis, + on_params_changed=self._on_params, + ) + self._control = ControlRail( + client=None, connected=self._device_connected) + self._svm = SvmPanel(reader=None) + self._states = StatesPanel(reader=None) + self._dashboard = DashboardView( + self._control, self._svm, self._states) + + self._stack.addWidget(self._tune) + self._stack.addWidget(self._dashboard) + + sb = self.statusBar() + self._link_badge = QLabel() + self._link_badge.setSizePolicy( + QSizePolicy.Minimum, QSizePolicy.Fixed) + self._link_descr = QLabel() + self._link_descr.setStyleSheet("color: #9aa0a6; font-size: 12px;") + self._reset_btn = action_button("RESET BOARD", "BtnDefault") + self._reset_btn.clicked.connect(self._on_reset_board_clicked) + sb.addPermanentWidget(self._link_badge) + sb.addPermanentWidget(self._reset_btn) + sb.addPermanentWidget(self._link_descr) + + self._poll_thread: Optional[QThread] = None + self._poll_worker: Optional[TunerPollWorker] = None + + self._conn.state_changed.connect(self._on_conn_state) + self._conn.port_descr_changed.connect(self._link_descr.setText) + self._conn.client_ready.connect(self._on_client_ready) + self._conn.client_lost.connect(self._on_client_lost) + + self._control.long_operation.connect(self._set_poll_paused) + self._tuning.long_operation.connect(self._set_poll_paused) + + self._on_conn_state(self._conn.state) + self._conn.start() + + def _device_connected(self) -> bool: + return self._client is not None and self._conn.connected + + def _set_device_actions_enabled(self, on: bool) -> None: + self._reset_btn.setEnabled(on) + self._tuning.set_actions_enabled(on) + self._tune.nvs.set_actions_enabled(on) + self._tune.nvs.set_client(self._client if on else None) + self._control.set_actions_enabled(on) + self._analysis._apply_mpz_btn.setEnabled(on) + self._analysis._motor_pole_pairs.setEnabled(on) + if on: + self._states.set_interactive(True) + if self._client is not None: + self._svm.attach_reader(self._client.reader) + self._states.attach_reader(self._client.reader) + else: + self._states.set_interactive(False) + + def _on_conn_state(self, state: str) -> None: + key = { + ConnectionManager.STATE_NO_DEVICE: "NO_DEVICE", + ConnectionManager.STATE_SCANNING: "SCANNING", + ConnectionManager.STATE_CONNECTING: "LINK_WAIT", + ConnectionManager.STATE_CONNECTED: "LINK_WAIT", + }.get(state, "NO_DEVICE") + text, qss = make_badge_qss(key) + self._link_badge.setText(text) + self._link_badge.setStyleSheet(qss) + + def _on_client_ready(self, client: object) -> None: + self._client = client # type: ignore[assignment] + assert isinstance(self._client, TunerClient) + port = self._conn.active_port or self._conn._fixed_port + if port: + self._serial_config = (port, self._conn._baud, self._conn._axis) + self._tuning.set_client(self._client) + self._control.set_client(self._client) + self._analysis._client = self._client + self._svm.attach_reader(self._client.reader) + self._states.attach_reader(self._client.reader) + self._tuning.rebind_log_reader() + self._start_poll_worker() + self._set_device_actions_enabled(True) + try: + self._client.scope_start() + except TunerError: + pass + self._link_ping_seen = False + self._ping_fail_streak = 0 + text, qss = make_badge_qss("LINK_WAIT") + self._link_badge.setText(text) + self._link_badge.setStyleSheet(qss) + + def _on_client_lost(self) -> None: + self._stop_poll_worker() + self._client = None + self._serial_config = None + self._tuning.set_client(None) + self._control.set_client(None) + self._analysis._client = None + self._svm.detach() + self._states.attach_reader(None) + self._set_device_actions_enabled(False) + self._link_ping_seen = False + self._ping_fail_streak = 0 + + def _start_poll_worker(self) -> None: + if self._client is None or self._poll_thread is not None: + return + self._poll_thread = QThread(self) + self._poll_worker = TunerPollWorker(self._client) + self._poll_worker.moveToThread(self._poll_thread) + self._poll_worker.poll_finished.connect( + self._on_poll_finished, Qt.QueuedConnection) + self._poll_worker.ping_finished.connect( + self._on_ping_finished, Qt.QueuedConnection) + self._poll_worker.device_reads_ready.connect( + self._on_device_reads_ready, Qt.QueuedConnection) + self._tuning.poll_refresh_requested.connect( + self._poll_worker.poll_tick, Qt.QueuedConnection) + self._poll_thread.started.connect(self._poll_worker.start_timer) + self._poll_thread.start() + self._tuning.poll_refresh_requested.emit(True) + + def _stop_poll_worker(self) -> None: + if self._poll_worker is None or self._poll_thread is None: + return + self._tuning.long_operation.emit(True) + QMetaObject.invokeMethod( + self._poll_worker, "shutdown", Qt.BlockingQueuedConnection) + self._poll_thread.quit() + self._poll_thread.wait(3000) + self._poll_thread = None + self._poll_worker = None + + def _set_poll_paused(self, paused: bool) -> None: + if self._poll_worker is not None: + self._poll_worker.set_paused(paused) + + def closeEvent(self, event) -> None: + self._conn.stop() + self._stop_poll_worker() + super().closeEvent(event) + + def _on_reset_board_clicked(self) -> None: + if not self._device_connected() or self._client is None: + return + r = QMessageBox.question( + self, "Reset board", + "Restart the board now? The USB link may drop briefly.", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if r != QMessageBox.Yes: + return + try: + self._client.reset_board() + except TunerError as e: + QMessageBox.warning(self, "Reset failed", str(e)) + + def _on_ping_finished(self, ok: bool, err: str) -> None: + self._link_ping_seen = True + if ok: + self._ping_fail_streak = 0 + else: + self._ping_fail_streak += 1 + if self._client is None: + return + if self._ping_fail_streak >= _LINK_DOWN_AFTER_CONSECUTIVE_PING_FAILS: + self._set_link_badge("LINK_DOWN") + else: + self._set_link_badge("LINK_OK") + if (not ok + and self._serial_config is not None + and self._poll_error_implies_dead_transport(err) + and self._maybe_reconnect()): + QMetaObject.invokeMethod( + self._poll_worker, "ping_now", Qt.QueuedConnection) + + def _set_link_badge(self, key: str) -> None: + text, qss = make_badge_qss(key) + self._link_badge.setText(text) + self._link_badge.setStyleSheet(qss) + + @staticmethod + def _poll_error_implies_dead_transport(err: str) -> bool: + el = (err or "").lower() + if ("link not running" in el or "reader stopped" in el + or "link i/o:" in el): + return True + return any(s in el for s in ( + "errno 5", "[errno 5]", "input/output error", + "bad file descriptor", "serial send failed", + "timeout waiting for response", "device disconnected", + )) + + def _maybe_reconnect(self) -> bool: + if self._serial_config is None: + return False + now = time.monotonic() + if now - self._last_reconnect_mono < 1.0: + return False + self._last_reconnect_mono = now + return self._reconnect_serial() + + def _reconnect_serial(self) -> bool: + assert self._serial_config is not None + self._tuning.last_poll_ok = False + self._link_ping_seen = False + self._ping_fail_streak = 0 + if self._poll_worker is not None: + self._poll_worker.suspend_requested.emit(True) + time.sleep(0.15) + success = False + try: + self._tuning.detach_log_reader() + old = self._client.reader if self._client else None + if old is not None: + try: + old.stop() + except Exception: + pass + port, baud, _axis = self._serial_config + t = SerialTransport(port=port, baud=baud) + r = LinkReader(t) + r.start() + time.sleep(0.15) + if self._client is not None: + self._client.replace_reader(r) + self._svm.attach_reader(r) + self._states.attach_reader(r) + self._tuning.rebind_log_reader() + success = True + except Exception: + success = False + finally: + if self._poll_worker is not None: + QMetaObject.invokeMethod( + self._poll_worker, "run_post_reconnect_reads", + Qt.QueuedConnection) + QMetaObject.invokeMethod( + self._poll_worker, "finish_reconnect", + Qt.QueuedConnection) + return success + + def _on_poll_finished( + self, + ok: bool, + err: str, + snap: Optional[TunerPollSnapshot]) -> None: + if ok and snap is not None: + self._tuning.apply_poll_snapshot(snap) + self._control.apply_override_state(snap.override_active) + self._tune.nvs.update_live( + snap.kp, snap.ki, snap.lim, snap.fc) + self._tune.nvs.set_calibration_present(snap.cal_present) + self._tune.motor.set_kp_ki_hint(snap.kp, snap.ki) + else: + self._tuning.apply_poll_error(err or "poll failed") + if (self._serial_config is not None + and not self._tuning.last_poll_ok + and self._poll_error_implies_dead_transport(err) + and self._maybe_reconnect()): + self._tuning.poll_refresh_requested.emit(True) + if self._client is not None: + self._states.set_interactive(self._client.reader.is_running) + + def _on_device_reads_ready( + self, fs_hz: float, shadows: object, pole: object) -> None: + if fs_hz > 1.0: + self._analysis.set_loop_rate_hz(fs_hz) + self._tuning.set_loop_rate_hz(fs_hz) + dt = scope_uniform_dt_s(fs_hz) + self._svm.set_uniform_sample_period_s(dt) + self._states.set_uniform_sample_period_s(dt) + if shadows is not None: + t = shadows + self._tuning.apply_nvs_shadow_floats( + t[0], t[1], t[2], t[3], t[4], t[5]) + self._tune.motor.apply_nvs_motor(t[0], t[1], t[2]) + self._tune.nvs.capture_nvs_reference( + t[3], t[4], self._tuning._lim_spin.value(), t[5]) + if pole is not None: + self._analysis.set_motor_pole_pairs_silent(int(pole)) + + def _on_params(self, r: float, l: float, bw: float, + kp: float, ki: float) -> None: + self._analysis_pending = (r, l, bw, kp, ki) + self._analysis_debounce.start() + + def _run_pending_analysis(self) -> None: + if self._analysis_pending is None: + return + r, l, bw, kp, ki = self._analysis_pending + self._analysis_pending = None + self._analysis.update_model(r, l, bw, kp, ki) diff --git a/tools/espfoc_tool/gui/motor_model_rail.py b/tools/espfoc_tool/gui/motor_model_rail.py new file mode 100644 index 00000000..57d79cc2 --- /dev/null +++ b/tools/espfoc_tool/gui/motor_model_rail.py @@ -0,0 +1,84 @@ +"""Motor parameters bar for MPZ preview (R, L, bandwidth).""" + +from __future__ import annotations + +from typing import Callable, Optional + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QFormLayout, QHBoxLayout, QSizePolicy, QWidget + +from . import labels as L +from .widgets import LiveMetricGrid, SurfaceCard, spin_box + + +class MotorModelRail(QWidget): + def __init__( + self, + on_params_changed: Optional[Callable[[float, float, float, + float, float], None]] = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self._on_params_changed = on_params_changed + self._kp_hint = 1.0 + self._ki_hint = 100.0 + self.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + card = SurfaceCard("Motor model") + row = QHBoxLayout() + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(24) + + form = QFormLayout() + form.setLabelAlignment(Qt.AlignRight) + form.setHorizontalSpacing(12) + self._r_spin = spin_box(0.001, 1000.0, 4, 1.08, step=0.01, suffix=" Ω") + self._l_mh_spin = spin_box(0.001, 10_000.0, 4, 1.8, step=0.01, suffix=" mH") + self._bw_spin = spin_box(1.0, 5000.0, 1, 150.0, step=10.0, suffix=" Hz") + form.addRow(L.RESISTANCE, self._r_spin) + form.addRow(L.INDUCTANCE, self._l_mh_spin) + form.addRow(L.CONTROL_BANDWIDTH, self._bw_spin) + row.addLayout(form) + + self._gain_grid = LiveMetricGrid([L.PROPORTIONAL_GAIN, L.INTEGRAL_GAIN]) + row.addWidget(self._gain_grid, 1) + + card.body_layout.addLayout(row) + outer = QHBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.addWidget(card) + + for sp in (self._r_spin, self._l_mh_spin, self._bw_spin): + sp.valueChanged.connect(self._notify) + if self._on_params_changed is not None: + self._notify() + + def set_kp_ki_hint(self, kp: float, ki: float) -> None: + self._kp_hint = kp + self._ki_hint = ki + self._gain_grid.set_values([f"{kp:.4f} V/A", f"{ki:.1f} V/(A·s)"]) + self._notify() + + def _notify(self) -> None: + if self._on_params_changed is None: + return + l_h = self._l_mh_spin.value() * 1e-3 + self._on_params_changed( + self._r_spin.value(), l_h, self._bw_spin.value(), + self._kp_hint, self._ki_hint) + + def apply_nvs_motor(self, r: float, l_h: float, bw: float) -> None: + for sp in (self._r_spin, self._l_mh_spin, self._bw_spin): + sp.blockSignals(True) + try: + if r > 1e-10: + self._r_spin.setValue(r) + if l_h > 1e-12: + self._l_mh_spin.setValue(l_h * 1e3) + if bw > 0.5: + self._bw_spin.setValue(bw) + finally: + for sp in (self._r_spin, self._l_mh_spin, self._bw_spin): + sp.blockSignals(False) + self._notify() diff --git a/tools/espfoc_tool/gui/nav_rail.py b/tools/espfoc_tool/gui/nav_rail.py new file mode 100644 index 00000000..c0ee44bb --- /dev/null +++ b/tools/espfoc_tool/gui/nav_rail.py @@ -0,0 +1,44 @@ +"""Left navigation rail (Material-style list).""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QButtonGroup, QLabel, QPushButton, QVBoxLayout, QWidget + +from .buttons import apply_button_style + + +class NavRail(QWidget): + page_selected = Signal(int) + + LABELS = ("Tune", "Dashboard") + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("NavRail") + self.setFixedWidth(132) + lay = QVBoxLayout(self) + lay.setContentsMargins(8, 12, 8, 12) + lay.setSpacing(6) + brand = QLabel("espFoC") + brand.setObjectName("NavBrand") + lay.addWidget(brand) + self._group = QButtonGroup(self) + self._group.setExclusive(True) + self._buttons: list[QPushButton] = [] + for i, label in enumerate(self.LABELS): + b = QPushButton(label) + b.setCheckable(True) + b.setCursor(Qt.PointingHandCursor) + apply_button_style(b, "BtnNav") + if i == 0: + b.setChecked(True) + b.clicked.connect(lambda _c=False, idx=i: self.page_selected.emit(idx)) + self._group.addButton(b, i) + self._buttons.append(b) + lay.addWidget(b) + lay.addStretch(1) + + def set_current_index(self, index: int) -> None: + if 0 <= index < len(self._buttons): + self._buttons[index].setChecked(True) diff --git a/tools/espfoc_tool/gui/nvs_diff_panel.py b/tools/espfoc_tool/gui/nvs_diff_panel.py new file mode 100644 index 00000000..a86c8e34 --- /dev/null +++ b/tools/espfoc_tool/gui/nvs_diff_panel.py @@ -0,0 +1,225 @@ +"""NVS / live tuning diff: editor vs device, RMW store.""" + +from __future__ import annotations + +from typing import Optional + +from PySide6.QtWidgets import ( + QGridLayout, + QHBoxLayout, + QLabel, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from ..protocol import TunerClient, TunerError +from . import labels as L +from .theme import make_nvs_badge_qss, monospace_font +from .buttons import action_button +from .widgets import SurfaceCard + + +class NvsDiffPanel(QWidget): + """Right-hand Config column: live vs editor, write dirty fields, store NVS.""" + + _EPS_KP = 0.02 + _EPS_KI = 1.0 + _EPS_LIM = 0.05 + _EPS_FC = 1.0 + + def __init__( + self, + client: Optional[TunerClient] = None, + kp_spin=None, + ki_spin=None, + lim_spin=None, + fc_spin=None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self._client = client + self._kp_spin = kp_spin + self._ki_spin = ki_spin + self._lim_spin = lim_spin + self._fc_spin = fc_spin + self._live = {"kp": 0.0, "ki": 0.0, "lim": 0.0, "fc": 0.0} + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(12) + + card = SurfaceCard("Flash") + grid = QGridLayout() + grid.setHorizontalSpacing(12) + grid.setVerticalSpacing(8) + hdr_style = "color: #9aa0a6; font-size: 11px; font-weight: 600;" + mono_font = monospace_font(12) + for col, text in enumerate(("", "Device", "Pending")): + h = QLabel(text) + h.setStyleSheet(hdr_style) + grid.addWidget(h, 0, col) + + self._rows: list[tuple[str, QLabel, QLabel]] = [] + row_labels = ( + ("kp", L.PROPORTIONAL_GAIN), + ("ki", L.INTEGRAL_GAIN), + ("lim", L.CURRENT_LIMIT), + ("fc", L.CURRENT_FILTER), + ) + for row_i, (key, label) in enumerate(row_labels, start=1): + name = QLabel(label) + live = QLabel("—") + live.setFont(mono_font) + delta = QLabel("—") + delta.setFont(mono_font) + grid.addWidget(name, row_i, 0) + grid.addWidget(live, row_i, 1) + grid.addWidget(delta, row_i, 2) + self._rows.append((key, live, delta)) + + self._live_kp = self._rows[0][1] + self._live_ki = self._rows[1][1] + self._live_lim = self._rows[2][1] + self._live_fc = self._rows[3][1] + self._delta_kp = self._rows[0][2] + self._delta_ki = self._rows[1][2] + self._delta_lim = self._rows[2][2] + self._delta_fc = self._rows[3][2] + + card.body_layout.addLayout(grid) + self._nvs_badge = QLabel("—") + self._nvs_badge.setObjectName("NvsBadge") + card.body_layout.addWidget(self._nvs_badge) + root.addWidget(card) + + btn_row = QHBoxLayout() + btn_row.setSpacing(6) + btn_row.setContentsMargins(0, 0, 0, 0) + self._read_btn = action_button("Read", "BtnCompact") + self._write_btn = action_button("Write", "BtnCompact") + self._patch_btn = action_button("Patch", "BtnCompact") + for btn in (self._read_btn, self._write_btn, self._patch_btn): + btn.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + btn_row.addWidget(btn, 1) + root.addLayout(btn_row) + root.addStretch(1) + self._status = QLabel("") + self._status.setStyleSheet("color: #ef5350; font-size: 11px;") + root.addWidget(self._status) + + self._read_btn.clicked.connect(self._on_read) + self._write_btn.clicked.connect(self._on_write_dirty) + self._patch_btn.clicked.connect(self._on_patch) + + def set_client(self, client: Optional[TunerClient]) -> None: + self._client = client + + def set_actions_enabled(self, on: bool) -> None: + self._read_btn.setEnabled(on) + self._write_btn.setEnabled(on) + self._patch_btn.setEnabled(on) + + def set_calibration_present(self, present: bool) -> None: + self._nvs_badge.setText("Stored" if present else "Empty") + self._nvs_badge.setStyleSheet(make_nvs_badge_qss(present)) + + def update_live(self, kp: float, ki: float, lim: float, fc: float) -> None: + self._live = {"kp": kp, "ki": ki, "lim": lim, "fc": fc} + self._live_kp.setText(f"{kp:.4f}") + self._live_ki.setText(f"{ki:.1f}") + self._live_lim.setText(f"{lim:.2f}") + self._live_fc.setText(f"{fc:.0f}") + self._refresh_deltas() + + def _editor_values(self) -> dict[str, float]: + return { + "kp": self._kp_spin.value() if self._kp_spin else 0.0, + "ki": self._ki_spin.value() if self._ki_spin else 0.0, + "lim": self._lim_spin.value() if self._lim_spin else 0.0, + "fc": self._fc_spin.value() if self._fc_spin else 0.0, + } + + def _refresh_deltas(self) -> None: + ed = self._editor_values() + self._set_delta(self._delta_kp, ed["kp"] - self._live["kp"], self._EPS_KP) + self._set_delta(self._delta_ki, ed["ki"] - self._live["ki"], self._EPS_KI) + self._set_delta(self._delta_lim, ed["lim"] - self._live["lim"], self._EPS_LIM) + self._set_delta(self._delta_fc, ed["fc"] - self._live["fc"], self._EPS_FC) + + @staticmethod + def _set_delta(lbl: QLabel, d: float, eps: float) -> None: + if abs(d) <= eps: + lbl.setText("—") + lbl.setStyleSheet("color: #66bb6a;") + else: + lbl.setText(f"{d:+.4f}") + lbl.setStyleSheet("color: #ffb300;") + + def refresh_from_editor(self) -> None: + self._refresh_deltas() + + def _require_client(self) -> Optional[TunerClient]: + if self._client is None: + self._status.setText("Connect a board first.") + return None + self._status.setText("") + return self._client + + def _on_read(self) -> None: + cli = self._require_client() + if cli is None: + return + if not all((self._kp_spin, self._ki_spin, self._lim_spin, self._fc_spin)): + return + try: + kp = cli.read_kp() + ki = cli.read_ki() + lim = cli.read_int_lim() + fc = cli.read_current_filter_fc() + except TunerError as e: + self._status.setText(str(e)) + return + for sp in (self._kp_spin, self._ki_spin, self._lim_spin, self._fc_spin): + sp.blockSignals(True) + try: + self._kp_spin.setValue(kp) + self._ki_spin.setValue(ki) + self._lim_spin.setValue(lim) + self._fc_spin.setValue(fc) + finally: + for sp in (self._kp_spin, self._ki_spin, self._lim_spin, self._fc_spin): + sp.blockSignals(False) + self.update_live(kp, ki, lim, fc) + self._refresh_deltas() + + def _on_write_dirty(self) -> None: + cli = self._require_client() + if cli is None: + return + ed = self._editor_values() + try: + if abs(ed["kp"] - self._live["kp"]) > self._EPS_KP: + cli.write_kp(ed["kp"]) + if abs(ed["ki"] - self._live["ki"]) > self._EPS_KI: + cli.write_ki(ed["ki"]) + if abs(ed["lim"] - self._live["lim"]) > self._EPS_LIM: + cli.write_int_lim(ed["lim"]) + if abs(ed["fc"] - self._live["fc"]) > self._EPS_FC: + cli.write_current_filter_fc(ed["fc"]) + except TunerError as e: + self._status.setText(str(e)) + + def _on_patch(self) -> None: + cli = self._require_client() + if cli is None: + return + try: + self._on_write_dirty() + cli.store_calibration() + except TunerError as e: + self._status.setText(str(e)) + + def capture_nvs_reference(self, *args, **kwargs) -> None: + pass diff --git a/tools/espfoc_studio/gui/plot_display.py b/tools/espfoc_tool/gui/plot_display.py similarity index 100% rename from tools/espfoc_studio/gui/plot_display.py rename to tools/espfoc_tool/gui/plot_display.py diff --git a/tools/espfoc_tool/gui/scope_constants.py b/tools/espfoc_tool/gui/scope_constants.py new file mode 100644 index 00000000..c5d10bfa --- /dev/null +++ b/tools/espfoc_tool/gui/scope_constants.py @@ -0,0 +1,8 @@ +"""Shared scope stream buffer sizing (axis_tuning wire map).""" + +WINDOW_S = 2.0 +BUFFER_CAP = 4096 +INBOX_CAP = 8192 +MAX_PENDING_DECODED = 8192 +MAX_MERGE_SAMPLES_PER_TICK = 8192 +MAX_RAW_FRAMES_DECODE_BATCH = 2048 diff --git a/tools/espfoc_studio/gui/scope_stream_timing.py b/tools/espfoc_tool/gui/scope_stream_timing.py similarity index 100% rename from tools/espfoc_studio/gui/scope_stream_timing.py rename to tools/espfoc_tool/gui/scope_stream_timing.py diff --git a/tools/espfoc_tool/gui/states_panel.py b/tools/espfoc_tool/gui/states_panel.py new file mode 100644 index 00000000..cf4472dc --- /dev/null +++ b/tools/espfoc_tool/gui/states_panel.py @@ -0,0 +1,242 @@ +"""States view: named scope channels (axis_tuning wire map, ch 0–13).""" + +from __future__ import annotations + +import math +import threading +import time +from collections import deque +from dataclasses import dataclass +from typing import Deque, List, Optional, Tuple + +import numpy as np +import pyqtgraph as pg +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QCheckBox, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QScrollArea, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from ..link import LinkReader +from ..link.scope_sample import decode_scope_payload_to_floats_csv_first +from .crosshair import attach_crosshair +from .plot_display import ( + configure_dynamic_curve, + configure_rolling_time_xaxis, + decimation_indices_peak_union, + rolling_plot_x_upper, +) +from . import scope_constants as sc +from .scope_stream_timing import scope_uniform_dt_s + +STATES_CH_FIRST = 0 +STATES_N = 14 +HOT_PATH_CH = 13 +WINDOW_S = sc.WINDOW_S +BUFFER_CAP = sc.BUFFER_CAP +INBOX_CAP = sc.INBOX_CAP +MAX_PENDING_DECODED = sc.MAX_PENDING_DECODED +MAX_MERGE_SAMPLES_PER_TICK = sc.MAX_MERGE_SAMPLES_PER_TICK +MAX_RAW_FRAMES_DECODE_BATCH = sc.MAX_RAW_FRAMES_DECODE_BATCH +RENDER_INTERVAL_MS = 55 +STRIP_DISPLAY_MAX_POINTS = 800 +PLOT_MIN_HEIGHT = 160 + +AXIS_TUNING_LABELS = ( + "id target", + "id meas", + "iq target", + "iq meas", + "ud", + "uq", + "θ_meas mech", + "θ_est mech", + "ω_est mech", + "PLL error", + "iu", + "iv", + "iα", + "FOC hot-path µs", +) + + +@dataclass(frozen=True) +class _RowSpec: + title: str + + +def _all_specs() -> List[_RowSpec]: + return [_RowSpec(f"ch{i}: {AXIS_TUNING_LABELS[i]}") for i in range(STATES_N)] + + +def _eng_text(row: int, ch_f: float, cpr: int) -> str: + if row == 6: + cnt = ch_f % float(max(cpr, 1)) + deg = (cnt / float(max(cpr, 1))) * 360.0 + return f"θ_meas = {deg:+.2f}° ({cnt:.1f} counts)" + if row == 8: + return f"ω_est = {ch_f:+.5f} (per-unit turns/s)" + if row in (10, 11, 12): + return f"{ch_f:+.5f} A" + if row == HOT_PATH_CH: + return f"{ch_f * 65536.0:+.1f} µs (q16→µs)" + return f"{ch_f:+.5f}" + + +class StatesPanel(QWidget): + def __init__( + self, + reader: Optional[LinkReader] = None, + async_decode: bool = True) -> None: + super().__init__() + self._reader = reader + self._async_decode = async_decode + self._worker_stop = threading.Event() + self._inbox_lock = threading.Lock() + self._inbox: Deque[Tuple[float, bytes]] = deque(maxlen=INBOX_CAP) + self._pending_lock = threading.Lock() + self._pending_decoded: List[Tuple[float, Tuple[float, ...]]] = [] + self._decode_thread: Optional[threading.Thread] = None + self._uniform_dt_s = scope_uniform_dt_s(20000.0) + self._scope_synth_t = 0.0 + self._time_buf: Deque[float] = deque(maxlen=BUFFER_CAP) + self._plot_bufs: List[Deque[float]] = [ + deque(maxlen=BUFFER_CAP) for _ in range(STATES_N)] + self._eng_labels: List[QLabel] = [] + self._raw_fields: List[QLineEdit] = [] + self._trace_cbs: List[QCheckBox] = [] + self._curves: List[pg.PlotDataItem] = [] + self._plots: List[pg.PlotWidget] = [] + self._active = True + self._row_specs = _all_specs() + + root = QVBoxLayout(self) + + cpr_row = QHBoxLayout() + cpr_row.addWidget(QLabel("Encoder counts per rev")) + self._cpr_spin = QSpinBox() + self._cpr_spin.setRange(1, 65536) + self._cpr_spin.setValue(4096) + cpr_row.addWidget(self._cpr_spin) + cpr_row.addStretch(1) + root.addLayout(cpr_row) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.NoFrame) + body = QWidget() + scroll.setWidget(body) + body_lay = QVBoxLayout(body) + for r, spec in enumerate(self._row_specs): + body_lay.addWidget(self._make_strip(r, spec.title)) + body_lay.addStretch(1) + root.addWidget(scroll, 1) + + self._render_timer = QTimer(self) + self._render_timer.timeout.connect(self._render_tick) + self._render_timer.start(RENDER_INTERVAL_MS) + if reader is not None: + self.attach_reader(reader) + + def _make_strip(self, row: int, title: str) -> QWidget: + box = QWidget() + lay = QVBoxLayout(box) + lay.setContentsMargins(0, 4, 0, 8) + head = QLabel(title) + head.setStyleSheet("font-weight: 600; color: #e6e6e6;") + lay.addWidget(head) + plot = pg.PlotWidget() + plot.setMinimumHeight(PLOT_MIN_HEIGHT) + plot.showGrid(x=True, y=True, alpha=0.25) + configure_rolling_time_xaxis(plot) + curve = plot.plot(pen=pg.mkPen("#4fc3f7", width=1.5)) + attach_crosshair(plot) + configure_dynamic_curve(curve) + self._plots.append(plot) + self._curves.append(curve) + lay.addWidget(plot) + form = QFormLayout() + raw = QLineEdit() + raw.setReadOnly(True) + raw.setStyleSheet("font-family: monospace;") + eng = QLabel("-") + eng.setStyleSheet("color: #9aa0a6; font-size: 11px;") + cb = QCheckBox("plot") + cb.setChecked(row < 4) + self._raw_fields.append(raw) + self._eng_labels.append(eng) + self._trace_cbs.append(cb) + form.addRow("Q16", raw) + form.addRow("value", eng) + form.addRow(cb) + lay.addLayout(form) + return box + + def attach_reader(self, reader: Optional[LinkReader]) -> None: + if self._reader is not None: + self._reader.unregister_scope_callback( + self._on_frame_reader_thread) + self._reader = reader + if reader is not None: + reader.register_scope_callback(self._on_frame_reader_thread) + + def set_interactive(self, on: bool) -> None: + self._active = on + + def set_uniform_sample_period_s(self, dt_s: float) -> None: + if dt_s > 0.0: + self._uniform_dt_s = dt_s + + def _on_frame_reader_thread(self, channel: int, seq: int, + payload: bytes) -> None: + if not self._active: + return + _ = channel, seq + with self._inbox_lock: + self._inbox.append((time.monotonic(), payload)) + + def _render_tick(self) -> None: + batch: List[Tuple[float, bytes]] = [] + with self._inbox_lock: + while self._inbox: + batch.append(self._inbox.popleft()) + if not batch: + return + cpr = self._cpr_spin.value() + for _wall, payload in batch[-MAX_MERGE_SAMPLES_PER_TICK:]: + vals = decode_scope_payload_to_floats_csv_first(payload) + if len(vals) < STATES_CH_FIRST + STATES_N: + continue + self._scope_synth_t += self._uniform_dt_s + self._time_buf.append(self._scope_synth_t) + for r in range(STATES_N): + f = vals[STATES_CH_FIRST + r] + self._plot_bufs[r].append(f) + q16 = int(round(f * 65536.0)) + self._raw_fields[r].setText(str(q16)) + self._eng_labels[r].setText(_eng_text(r, f, cpr)) + n = len(self._time_buf) + if n < 2: + return + t_arr = np.fromiter(list(self._time_buf)[-n:], dtype=float, count=n) + t_lo = max(0.0, self._scope_synth_t - WINDOW_S) + for r, c in enumerate(self._curves): + if not self._trace_cbs[r].isChecked(): + c.setData([], []) + continue + y = np.fromiter(list(self._plot_bufs[r])[-n:], dtype=float, count=n) + mp = min(STRIP_DISPLAY_MAX_POINTS, len(t_arr)) + idx = decimation_indices_peak_union(y, mp) + t_d = t_arr[idx] + y_d = y[idx] + c.setData(t_d, y_d) + self._plots[r].setXRange(0.0, rolling_plot_x_upper(t_d, WINDOW_S), + padding=0) diff --git a/tools/espfoc_studio/gui/svm_panel.py b/tools/espfoc_tool/gui/svm_panel.py similarity index 85% rename from tools/espfoc_studio/gui/svm_panel.py rename to tools/espfoc_tool/gui/svm_panel.py index d514f865..656cd1bc 100644 --- a/tools/espfoc_studio/gui/svm_panel.py +++ b/tools/espfoc_tool/gui/svm_panel.py @@ -1,6 +1,6 @@ """Space-vector PWM hexagon view. -Reads channels 0/1/2 (u_u, u_v, u_w) from the firmware scope stream +Reads scope channels 10/11/12 (iu, iv, iα) from axis_tuning firmware map and shows: * three fixed phase axes (A/B/C at 0°/120°/240°) with a colored @@ -11,8 +11,8 @@ fading trail; α and β are in **per-unit** relative to a running scale so the view stays in [-1, 1] and phase rails stay inside the unit hex; -* the three-phase time-series underneath, physical units (V), matching - the scope tab's channels 0/1/2. +* the three-phase time-series beside the hexagon (same row height), + physical units (V), matching the scope tab's channels 0/1/2. Rendering is buffered on the reader side and flushed at a modest UI rate so scope bursts after a current step do not stall the Qt loop. @@ -30,13 +30,7 @@ import pyqtgraph as pg from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QColor -from PySide6.QtWidgets import ( - QHBoxLayout, - QLabel, - QPushButton, - QVBoxLayout, - QWidget, -) +from PySide6.QtWidgets import QSizePolicy, QVBoxLayout, QWidget from ..link import LinkReader from ..link.scope_sample import decode_scope_payload_to_floats_csv_first @@ -48,8 +42,8 @@ decimation_indices_peak_union, rolling_plot_x_upper, ) -from .scope_panel import ScopePanel from .scope_stream_timing import scope_uniform_dt_s +from .widgets import horizontal_splitter _HEX_COLOR = "#6e7681" @@ -57,7 +51,6 @@ _VERTEX_COLOR = "#ffcc66" _TRAIL_COLOR = "#4fc3f7" _ARROW_COLOR = "#ff7043" -_LABEL_COLOR = "#9aa0a6" # Keep these in sync with ScopePanel's first three entries so a phase # colour in the SVM view matches its counterpart in the scope tab. _PHASE_COLORS = ("#4fc3f7", "#ffb74d", "#81c784") @@ -118,18 +111,18 @@ def __init__(self, reader: Optional[LinkReader] = None, self._last_abc: tuple[float, float, float] = (0.0, 0.0, 0.0) root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) - # --- Top half: hexagon + readout column -------------------------- - top_row = QHBoxLayout() - root.addLayout(top_row, 2) - - self._plot = pg.PlotWidget(title="SVPWM voltage vector (per unit)") + self._plot = pg.PlotWidget(title="SVPWM vector (pu)") self._plot.setAspectLocked(True) self._plot.setLabel('left', "β (pu)", units="") self._plot.setLabel('bottom', "α (pu)", units="") self._plot.showGrid(x=True, y=True, alpha=0.15) configure_rolling_time_xaxis(self._plot) - self._plot.setMinimumHeight(380) + plot_min_h = 300 + self._plot.setMinimumHeight(plot_min_h) + self._plot.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self._hex_crosshair = attach_crosshair( self._plot, fmt=lambda x, y: f"α = {x:+.3f} pu\nβ = {y:+.3f} pu") @@ -184,37 +177,15 @@ def __init__(self, reader: Optional[LinkReader] = None, self._plot.addItem(self._head_scatter) self._redraw_hexagon() self._apply_hex_viewport() - top_row.addWidget(self._plot, 1) - - side = QVBoxLayout() - side.setContentsMargins(6, 6, 6, 6) - side.setSpacing(4) - self._alpha_label = QLabel("α = 0.000 pu") - self._beta_label = QLabel("β = 0.000 pu") - self._mag_label = QLabel("|V| = 0.000 pu") - self._sector_label = QLabel("sector: -") - self._scale_label = QLabel("1 pu = 1.00 (arb.)") - for lbl in (self._alpha_label, self._beta_label, - self._mag_label, self._sector_label, self._scale_label): - lbl.setStyleSheet("font-family: monospace; color: %s;" - % _LABEL_COLOR) - side.addWidget(lbl) - autoset_btn = QPushButton("Autoset") - autoset_btn.setToolTip( - "Clear the trail, reset the per-unit scale, lock the SVM view " - "to [-1,1], rebase the waveform, and re-enable voltage autorange.") - autoset_btn.clicked.connect(self.autoset) - side.addWidget(autoset_btn) - side.addStretch(1) - top_row.addLayout(side, 0) - - # --- Bottom half: three-phase waveform --------------------------- + # X is seconds within the rolling window (0 = oldest on screen). - self._wave_plot = pg.PlotWidget(title="Three-phase output") + self._wave_plot = pg.PlotWidget(title="Three-phase (V)") self._wave_plot.setLabel('left', "voltage", units='V') self._wave_plot.setLabel('bottom', "time", units='s') self._wave_plot.showGrid(x=True, y=True, alpha=0.2) - self._wave_plot.setMinimumHeight(220) + self._wave_plot.setMinimumHeight(plot_min_h) + self._wave_plot.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) configure_rolling_time_xaxis(self._wave_plot) self._wave_plot.setXRange(0.0, self.WAVEFORM_WINDOW_S, padding=0) self._wave_plot.enableAutoRange(axis='x', enable=False) @@ -223,14 +194,20 @@ def __init__(self, reader: Optional[LinkReader] = None, self._wave_plot, fmt=lambda x, y: f"t = {x:.4f} s\nu = {y:+.3f} V") self._uu_curve = self._wave_plot.plot( - pen=pg.mkPen(QColor(_PHASE_COLORS[0]), width=2), name="u_u (ch0)") + pen=pg.mkPen(QColor(_PHASE_COLORS[0]), width=2), name="iu (ch10)") self._uv_curve = self._wave_plot.plot( - pen=pg.mkPen(QColor(_PHASE_COLORS[1]), width=2), name="u_v (ch1)") + pen=pg.mkPen(QColor(_PHASE_COLORS[1]), width=2), name="iv (ch11)") self._uw_curve = self._wave_plot.plot( - pen=pg.mkPen(QColor(_PHASE_COLORS[2]), width=2), name="u_w (ch2)") + pen=pg.mkPen(QColor(_PHASE_COLORS[2]), width=2), name="iw (ch12 proxy)") for _c in (self._uu_curve, self._uv_curve, self._uw_curve): configure_dynamic_curve(_c) - root.addWidget(self._wave_plot, 1) + + root.addWidget(horizontal_splitter( + self._plot, + self._wave_plot, + stretches=(1, 1), + sizes=(520, 520), + ), 1) # --- Render timer ------------------------------------------------ self._render_timer = QTimer(self) @@ -305,7 +282,12 @@ def _render_tick(self) -> None: if len(allv) < 3: continue vals = allv[:3] - u_u, u_v, u_w = vals[0], vals[1], vals[2] + if len(vals) < 13: + return + iu, iv, ialpha = vals[10], vals[11], vals[12] + ibeta = -(iu + iv) * 0.57735026919 + u_u, u_v, u_w = iu, iv, -(iu + iv) + _ = ialpha, ibeta a, b = _clarke(u_u, u_v, u_w) self._last_abc = (u_u, u_v, u_w) self._alpha_buf.append(a) @@ -390,22 +372,6 @@ def _render_tick(self) -> None: x_up = rolling_plot_x_upper(t_d, self.WAVEFORM_WINDOW_S) self._wave_plot.setXRange(0.0, x_up, padding=0) - mag_pu = mag * inv - self._alpha_label.setText(f"α = {a_n:+8.3f} pu") - self._beta_label.setText(f"β = {b_n:+8.3f} pu") - self._mag_label.setText(f"|V| = {mag_pu:8.3f} pu") - self._scale_label.setText(f"1 pu = {self._pu_ref:8.4f} (ch0–2 units)") - if mag < 1e-20: - self._sector_label.setText("sector: -") - else: - ang = math.atan2(b_n, a_n) - if ang < 0: - ang += 2.0 * math.pi - sec = int(ang // (math.pi / 3.0)) + 1 - if sec > 6: - sec = 6 - self._sector_label.setText(f"sector: {sec}") - # --- Static geometry -------------------------------------------------- # Phase-axis unit vectors (A=0°, B=120°, C=240°). Dashed rails in the diff --git a/tools/espfoc_tool/gui/theme.py b/tools/espfoc_tool/gui/theme.py new file mode 100644 index 00000000..ed614dcd --- /dev/null +++ b/tools/espfoc_tool/gui/theme.py @@ -0,0 +1,425 @@ +"""Dark theme applied before any window is shown. + +Uses Qt's Fusion style with a handcrafted palette so it looks the same +on every OS and does not require an extra dependency. pyqtgraph gets +its own dark background via setConfigOption; the two conventions need +to agree or the plots look washed out against the panel chrome. +""" + +from __future__ import annotations + +import sys + +import pyqtgraph as pg +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QFont, QPalette +from PySide6.QtWidgets import QApplication + + +_BG = "#1e1f22" # window background +_BG_ALT = "#26272b" # group box / card +_FG = "#e6e6e6" # primary text +_DIM = "#9aa0a6" # secondary text +_ACCENT = "#4fc3f7" # highlight (links, selection) +_BORDER = "#3a3b3f" +_ERROR = "#ef5350" + +# Raised surfaces (neutral warm gray — cards, metrics, inputs) +_SURF_TOP = "#35383f" +_SURF_BOT = "#27292e" +_SURF_BORDER = "#43474f" +_SURF_INSET_TOP = "#2f3238" +_SURF_INSET_BOT = "#25272c" + +# Action buttons (cool slate — distinct from surfaces) +_BTN_TOP = "#3d5563" +_BTN_MID = "#334a57" +_BTN_BOT = "#2a3b47" +_BTN_BORDER = "#4a6a7c" +_BTN_FG = "#b8d4e0" +_BTN_HOVER_TOP = "#476372" +_BTN_HOVER_BOT = "#324957" +_BTN_PRESS = "#243038" + + +# Axis-state badge palette. Picked the dominant flag (override > running > +# aligned > init > none) and rendered it as a single colored pill instead +# of the old "+INITIALIZED -ALIGNED -RUNNING -TUNER_OVERRIDE" text. Keep +# the foreground / background pair high-contrast on the dark theme. +BADGE_STYLES = { + "NO_DEVICE": ("NO DEVICE", "#0b0c0d", "#6c757d"), + "SCANNING": ("SCANNING", "#0b0c0d", "#ffb300"), + "OFFLINE": ("OFFLINE", "#0b0c0d", "#6c757d"), + "INIT": ("INIT", "#0b0c0d", "#26a69a"), + "ALIGNING": ("ALIGNING", "#0b0c0d", "#ff9800"), + "ALIGNED": ("ALIGNED", "#0b0c0d", _ACCENT), + "RUNNING": ("RUNNING", "#0b0c0d", "#66bb6a"), + "OVERRIDE": ("OVERRIDE", "#ffffff", "#ab47bc"), + "LINK_OK": ("CONNECTED", "#0b0c0d", "#66bb6a"), + "LINK_WAIT": ("CONNECTING", "#0b0c0d", "#ffb300"), + "LINK_DOWN": ("NO LINK", "#ffffff", _ERROR), +} + + +def make_nvs_badge_qss(stored: bool) -> str: + if stored: + grad = ( + "qlineargradient(x1:0,y1:0,x2:0,y2:1," + "stop:0 #2e4038, stop:1 #243530)" + ) + fg = "#a8c8b4" + border = "#3d5a4a" + else: + grad = ( + f"qlineargradient(x1:0,y1:0,x2:0,y2:1," + f"stop:0 {_SURF_TOP}, stop:1 {_SURF_BOT})" + ) + fg = _DIM + border = _SURF_BORDER + return ( + f"QLabel#NvsBadge {{" + f" background: {grad}; color: {fg};" + f" border: 1px solid {border};" + f" border-radius: 8px; padding: 4px 12px;" + f" font-size: 11px; font-weight: 600;" + f"}}" + ) + + +def make_badge_qss(state_key: str) -> tuple[str, str]: + """Returns (label, qss) for the axis state badge. Unknown keys + fall back to the OFFLINE style. Caller applies the qss to a + plain QLabel via setStyleSheet().""" + label, fg, bg = BADGE_STYLES.get(state_key, BADGE_STYLES["OFFLINE"]) + qss = ( + f"QLabel {{" + f" background-color: {bg};" + f" color: {fg};" + f" border-radius: 6px;" + f" padding: 4px 12px;" + f" font-weight: 600;" + f" font-size: 11px;" + f" letter-spacing: 1px;" + f" min-width: 78px;" + f" qproperty-alignment: 'AlignCenter';" + f"}}" + ) + return label, qss + + +def _btn_qss_block() -> str: + """Single neutral action style; legacy role names map to BtnDefault.""" + disabled = ( + "background: #2e3036; color: #6c757d; border: 1px solid #3a3b3f;" + ) + action_sel = ( + "QPushButton#BtnDefault," + "QPushButton#BtnSecondary," + "QPushButton#BtnPrimary," + "QPushButton#PrimaryButton," + "QPushButton#BtnAccent," + "QPushButton#BtnApply," + "QPushButton#BtnAlign," + "QPushButton#BtnDanger," + "QPushButton#BtnEstop," + "QPushButton#BtnReset," + "QPushButton#BtnNudge," + "QPushButton#BtnCompact" + ) + return f""" + QPushButton#BtnNav {{ + background: transparent; + color: {_DIM}; + border: none; + border-radius: 12px; + padding: 10px 14px; + font-size: 13px; + font-weight: 500; + text-align: left; + }} + QPushButton#BtnNav:hover {{ + background: qlineargradient(x1:0,y1:0,x2:0,y2:1, + stop:0 #32363d, stop:1 #2a2c31); + color: {_FG}; + }} + QPushButton#BtnNav:checked {{ + background: qlineargradient(x1:0,y1:0,x2:1,y2:1, + stop:0 #2e353d, stop:0.5 #2a3038, stop:1 #262a31); + color: {_FG}; + border: 1px solid #4a4e56; + font-weight: 600; + }} + {action_sel} {{ + background: qlineargradient(x1:0,y1:0,x2:0,y2:1, + stop:0 {_BTN_TOP}, stop:0.5 {_BTN_MID}, stop:1 {_BTN_BOT}); + color: {_BTN_FG}; + border: 1px solid {_BTN_BORDER}; + border-radius: 8px; + padding: 8px 14px; + min-height: 20px; + }} + {action_sel}:hover {{ + background: qlineargradient(x1:0,y1:0,x2:0,y2:1, + stop:0 {_BTN_HOVER_TOP}, stop:1 {_BTN_HOVER_BOT}); + border-color: {_ACCENT}; + color: #d4eaf2; + }} + {action_sel}:pressed {{ + background: qlineargradient(x1:0,y1:0,x2:0,y2:1, + stop:0 {_BTN_PRESS}, stop:1 #1e2a32); + border-color: #3a5560; + color: #e8f4f8; + }} + {action_sel}:disabled {{ {disabled} }} + QPushButton#BtnEstop {{ + padding: 12px 16px; + min-height: 28px; + font-weight: 600; + }} + QPushButton#BtnNudge {{ + padding: 6px 8px; + min-width: 36px; + max-width: 48px; + }} + QPushButton#BtnReset {{ + padding: 5px 12px; + min-width: 88px; + font-size: 11px; + }} + QPushButton#BtnCompact {{ + padding: 7px 10px; + min-height: 18px; + }} + """ + + +def _ui_font() -> QFont: + font = QFont() + if sys.platform == "darwin": + font.setFamilies([".AppleSystemUIFont", "SF Pro Text", "Helvetica Neue"]) + elif sys.platform == "win32": + font.setFamilies(["Segoe UI Variable", "Segoe UI"]) + else: + font.setFamilies(["Ubuntu", "Cantarell", "Noto Sans", "sans-serif"]) + font.setPointSize(10) + font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias) + return font + + +def monospace_font(point_size: int = 10) -> QFont: + f = QFont() + f.setFamilies([ + "JetBrains Mono", "Cascadia Mono", "SF Mono", + "Consolas", "Liberation Mono", "monospace", + ]) + f.setPointSize(point_size) + f.setStyleStrategy(QFont.StyleStrategy.PreferAntialias) + return f + + +def button_font( + point_size: int = 12, + weight: QFont.Weight = QFont.Weight.Medium, +) -> QFont: + f = _ui_font() + f.setPointSize(point_size) + f.setWeight(weight) + return f + + +def apply_dark_theme(app: QApplication, *, use_opengl_plots: bool = True) -> None: + app.setStyle("Fusion") + app.setFont(_ui_font()) + + pal = QPalette() + pal.setColor(QPalette.Window, QColor(_BG)) + pal.setColor(QPalette.WindowText, QColor(_FG)) + pal.setColor(QPalette.Base, QColor(_BG_ALT)) + pal.setColor(QPalette.AlternateBase, QColor(_BG)) + pal.setColor(QPalette.Text, QColor(_FG)) + pal.setColor(QPalette.Button, QColor(_BG_ALT)) + pal.setColor(QPalette.ButtonText, QColor(_FG)) + pal.setColor(QPalette.ToolTipBase, QColor(_BG)) + pal.setColor(QPalette.ToolTipText, QColor(_FG)) + pal.setColor(QPalette.PlaceholderText, QColor(_DIM)) + pal.setColor(QPalette.Highlight, QColor(_ACCENT)) + pal.setColor(QPalette.HighlightedText, QColor("#0b0c0d")) + pal.setColor(QPalette.Link, QColor(_ACCENT)) + pal.setColor(QPalette.BrightText, QColor(_ERROR)) + pal.setColor(QPalette.Disabled, QPalette.Text, QColor(_DIM)) + pal.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(_DIM)) + pal.setColor(QPalette.Disabled, QPalette.WindowText, QColor(_DIM)) + app.setPalette(pal) + + # Extra polish on top of Fusion: softer group-box borders, denser + # header typography. Kept short so the theme ships dep-free. + app.setStyleSheet(f""" + QMainWindow, QWidget {{ + background-color: {_BG}; + color: {_FG}; + }} + QGroupBox {{ + border: 1px solid {_SURF_BORDER}; + border-radius: 12px; + margin-top: 14px; + padding: 10px 8px 8px 8px; + background: qlineargradient(x1:0,y1:0,x2:0,y2:1, + stop:0 {_SURF_TOP}, stop:1 {_SURF_BOT}); + }} + QGroupBox::title {{ + subcontrol-origin: margin; + subcontrol-position: top left; + left: 8px; + padding: 0 4px; + color: {_DIM}; + font-size: 11px; + letter-spacing: 0.5px; + text-transform: uppercase; + }} + QLabel, QCheckBox, QRadioButton {{ + color: {_FG}; + font-size: 13px; + }} + QFormLayout QLabel {{ + color: #c8ccd2; + font-size: 12px; + }} + /* Fusion's default indicator melts into the dark background. + * Force a visible square with a clear checked state. */ + QCheckBox::indicator {{ + width: 16px; + height: 16px; + border: 1px solid {_SURF_BORDER}; + border-radius: 3px; + background: qlineargradient(x1:0,y1:0,x2:0,y2:1, + stop:0 {_SURF_INSET_TOP}, stop:1 {_SURF_INSET_BOT}); + }} + QCheckBox::indicator:hover {{ + border: 1px solid {_ACCENT}; + }} + QCheckBox::indicator:checked {{ + background-color: {_ACCENT}; + border: 1px solid {_ACCENT}; + image: none; + }} + QCheckBox::indicator:disabled {{ + background-color: #1a1b1d; + border: 1px solid {_BORDER}; + }} + QLineEdit, QDoubleSpinBox, QSpinBox, QComboBox {{ + background: qlineargradient(x1:0,y1:0,x2:0,y2:1, + stop:0 {_SURF_INSET_TOP}, stop:1 {_SURF_INSET_BOT}); + border: 1px solid {_SURF_BORDER}; + border-radius: 6px; + padding: 3px 6px; + selection-background-color: {_ACCENT}; + selection-color: #0b0c0d; + }} + QPlainTextEdit {{ + background: qlineargradient(x1:0,y1:0,x2:0,y2:1, + stop:0 {_SURF_INSET_TOP}, stop:1 {_SURF_INSET_BOT}); + border: 1px solid {_SURF_BORDER}; + border-radius: 8px; + padding: 6px; + selection-background-color: {_ACCENT}; + selection-color: #0b0c0d; + }} + {_btn_qss_block()} + QTabWidget::pane {{ border: 1px solid {_BORDER}; border-radius: 4px; }} + QTabBar::tab {{ + background: {_BG}; + color: {_DIM}; + padding: 6px 12px; + border: 1px solid transparent; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + }} + QTabBar::tab:selected {{ + background: {_BG_ALT}; + color: {_FG}; + border: 1px solid {_BORDER}; + border-bottom-color: {_BG_ALT}; + }} + QSplitter::handle {{ + background: qlineargradient(x1:0,y1:0,x2:0,y2:1, + stop:0 #3a3d44, stop:1 #2a2c31); + width: 5px; + margin: 4px 0; + border-radius: 2px; + }} + QSplitter::handle:hover {{ + background: qlineargradient(x1:0,y1:0,x2:0,y2:1, + stop:0 #4a6270, stop:1 #354a57); + }} + #NavRail {{ + background: qlineargradient(x1:0,y1:0,x2:0,y2:1, + stop:0 #1c1e22, stop:1 #141518); + border-right: 1px solid {_BORDER}; + }} + #NavBrand {{ + font-size: 15px; + font-weight: 700; + color: {_ACCENT}; + letter-spacing: 0.5px; + padding: 4px 6px 12px 6px; + }} + #NavHint {{ + color: {_DIM}; + font-size: 10px; + padding: 0 6px 10px 6px; + }} + #PageTitle {{ + font-size: 24px; + font-weight: 600; + color: {_FG}; + letter-spacing: -0.3px; + }} + #SurfaceCard {{ + background: qlineargradient(x1:0,y1:0,x2:0,y2:1, + stop:0 {_SURF_TOP}, stop:1 {_SURF_BOT}); + border: 1px solid {_SURF_BORDER}; + border-radius: 14px; + }} + #CardTitle {{ + font-size: 12px; + font-weight: 600; + color: #c8ccd2; + letter-spacing: 0.6px; + text-transform: uppercase; + }} + #NvsBadge {{ + font-size: 11px; + font-weight: 600; + letter-spacing: 0.4px; + padding: 4px 10px; + border-radius: 8px; + }} + #LiveMetric {{ + background: qlineargradient(x1:0,y1:0,x2:0,y2:1, + stop:0 {_SURF_INSET_TOP}, stop:1 {_SURF_INSET_BOT}); + border: 1px solid {_SURF_BORDER}; + border-radius: 10px; + }} + #MetricCaption {{ + color: {_DIM}; + font-size: 11px; + font-weight: 500; + }} + #MetricValue {{ + font-size: 15px; + font-weight: 500; + color: {_FG}; + letter-spacing: -0.2px; + }} + QScrollArea, QScrollArea > QWidget > QWidget {{ + background-color: transparent; + }} + """) + + # Align pyqtgraph's own colours with the palette so the plot canvas + # blends with the panel around it. + pg.setConfigOption("background", _BG_ALT) + pg.setConfigOption("foreground", _FG) + pg.setConfigOption("antialias", True) + if not use_opengl_plots: + pg.setConfigOptions(useOpenGL=False) diff --git a/tools/espfoc_studio/gui/tuner_poll_worker.py b/tools/espfoc_tool/gui/tuner_poll_worker.py similarity index 100% rename from tools/espfoc_studio/gui/tuner_poll_worker.py rename to tools/espfoc_tool/gui/tuner_poll_worker.py diff --git a/tools/espfoc_tool/gui/tuning_panel.py b/tools/espfoc_tool/gui/tuning_panel.py new file mode 100644 index 00000000..edfb0c53 --- /dev/null +++ b/tools/espfoc_tool/gui/tuning_panel.py @@ -0,0 +1,227 @@ +"""Tuning panel: live gain readout, manual override, MPZ retune.""" + +from __future__ import annotations + +from typing import Optional + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLabel, + QPlainTextEdit, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from ..protocol import TunerClient, TunerError +from . import labels as L +from .tuner_poll_worker import TunerPollSnapshot +from .theme import monospace_font +from .buttons import action_button +from .widgets import LiveMetricGrid, SurfaceCard, spin_box + + +class TuningPanel(QWidget): + """Left column of Config: status, live metrics, overrides, log.""" + + _logFromReader = Signal(str) + long_operation = Signal(bool) + poll_refresh_requested = Signal(bool) + + def __init__( + self, + client: Optional[TunerClient] = None, + *, + scrollable: bool = True, + ) -> None: + super().__init__() + self._client = client + self.last_poll_ok: bool = False + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + + body = QWidget() + root = QVBoxLayout(body) + root.setContentsMargins(0, 0, 8, 0) + root.setSpacing(12) + + live_card = SurfaceCard("Live") + self._live_grid = LiveMetricGrid([ + L.PROPORTIONAL_GAIN, + L.INTEGRAL_GAIN, + L.CURRENT_LIMIT, + L.VOLTAGE_LIMIT, + L.CURRENT_FILTER, + L.LOOP_RATE, + ]) + live_card.body_layout.addWidget(self._live_grid) + root.addWidget(live_card) + + editor_card = SurfaceCard("Editor") + mform = QFormLayout() + mform.setLabelAlignment(Qt.AlignRight) + self._kp_spin = spin_box(0.0, 500.0, 4, 1.46, step=0.01, suffix=" V/A") + self._ki_spin = spin_box(0.0, 1_000_000.0, 2, 659.17, step=10.0, + suffix=" V/(A·s)") + self._lim_spin = spin_box(0.0, 200.0, 3, 12.0, step=0.1, suffix=" V") + self._fc_spin = spin_box(10.0, 20000.0, 1, 300.0, step=10.0, suffix=" Hz") + mform.addRow(L.PROPORTIONAL_GAIN, self._kp_spin) + mform.addRow(L.INTEGRAL_GAIN, self._ki_spin) + mform.addRow(L.CURRENT_LIMIT, self._lim_spin) + mform.addRow(L.CURRENT_FILTER, self._fc_spin) + btn_apply = action_button("Apply gains", "BtnDefault") + btn_apply.clicked.connect(self._on_apply_manual) + btn_fc = action_button("Apply filter", "BtnDefault") + btn_fc.clicked.connect(self._on_apply_fc) + btn_row = QHBoxLayout() + btn_row.setSpacing(8) + btn_row.addWidget(btn_apply) + btn_row.addWidget(btn_fc) + btn_row.addStretch(1) + editor_card.body_layout.addLayout(mform) + editor_card.body_layout.addLayout(btn_row) + root.addWidget(editor_card) + + log_card = SurfaceCard("Log") + self._log_view = QPlainTextEdit() + self._log_view.setReadOnly(True) + self._log_view.setMaximumBlockCount(80) + self._log_view.setMaximumHeight(120) + self._log_view.setFont(monospace_font(9)) + log_card.body_layout.addWidget(self._log_view) + root.addWidget(log_card) + + self._status = QLabel("") + self._status.setStyleSheet("color: #c62828; font-size: 11px;") + root.addWidget(self._status) + + if scrollable: + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll.setFrameShape(QScrollArea.NoFrame) + scroll.setWidget(body) + outer.addWidget(scroll) + else: + outer.addWidget(body) + + self._loop_fs_hz = 0.0 + self._logFromReader.connect(self._append_log) + self.rebind_log_reader() + + def set_loop_rate_hz(self, fs_hz: float) -> None: + if fs_hz > 1.0: + self._loop_fs_hz = fs_hz + self._live_grid.metric(5).set_value(f"{fs_hz:.1f} Hz") + + def set_client(self, client: Optional[TunerClient]) -> None: + self._client = client + self.rebind_log_reader() + + def rebind_log_reader(self) -> None: + if self._client is None: + return + try: + self._client.reader.register_log_callback(self._on_log_reader) + except Exception: + pass + + def detach_log_reader(self) -> None: + if self._client is None: + return + try: + self._client.reader.register_log_callback(None) + except Exception: + pass + + def request_full_tuner_poll(self) -> None: + self.poll_refresh_requested.emit(True) + + def apply_poll_snapshot(self, snap: TunerPollSnapshot) -> None: + self.last_poll_ok = snap.last_poll_ok + self._status.setText("") + fs_str = (f"{self._loop_fs_hz:.1f} Hz" if self._loop_fs_hz > 1.0 + else "—") + self._live_grid.set_values([ + f"{snap.kp:.4f} V/A", + f"{snap.ki:.1f} V/(A·s)", + f"{snap.lim:.2f} V", + f"{snap.vmax:.2f} V", + f"{snap.fc:.0f} Hz", + fs_str, + ]) + def apply_poll_error(self, msg: str) -> None: + self._status.setText(msg) + self.last_poll_ok = False + + def _on_apply_manual(self) -> None: + if self._client is None: + return + try: + self._client.write_kp(self._kp_spin.value()) + self._client.write_ki(self._ki_spin.value()) + self._client.write_int_lim(self._lim_spin.value()) + except TunerError as e: + self._status.setText(str(e)) + return + self._status.setText("") + + def _on_apply_fc(self) -> None: + if self._client is None: + return + try: + self._client.write_current_filter_fc(self._fc_spin.value()) + except TunerError as e: + self._status.setText(str(e)) + return + self._status.setText("") + + def set_actions_enabled(self, on: bool) -> None: + for w in self.findChildren(QPushButton): + w.setEnabled(on) + for w in self.findChildren(QDoubleSpinBox): + w.setEnabled(on) + + def _on_log_reader(self, seq: int, payload: bytes) -> None: + try: + self._logFromReader.emit(payload.decode("ascii", errors="replace")) + except Exception: + pass + + def _append_log(self, line: str) -> None: + if line: + self._log_view.appendPlainText(line.rstrip("\n")) + + def apply_nvs_shadow_floats( + self, + r: float, + l_h: float, + bw: float, + kp: float, + ki: float, + fc: float, + ) -> None: + for sp in (self._kp_spin, self._ki_spin, self._fc_spin): + sp.blockSignals(True) + try: + self._kp_spin.setValue(kp) + self._ki_spin.setValue(ki) + if fc > 0.0: + self._fc_spin.setValue(fc) + finally: + for sp in (self._kp_spin, self._ki_spin, self._fc_spin): + sp.blockSignals(False) + + def sync_motor_from_nvs_shadows(self) -> None: + if self._client is None: + return + try: + kp = self._client.read_kp() + ki = self._client.read_ki() + fc = self._client.read_current_filter_fc() + except TunerError: + return + self.apply_nvs_shadow_floats(0.0, 0.0, 0.0, kp, ki, fc) diff --git a/tools/espfoc_tool/gui/views/__init__.py b/tools/espfoc_tool/gui/views/__init__.py new file mode 100644 index 00000000..586a6335 --- /dev/null +++ b/tools/espfoc_tool/gui/views/__init__.py @@ -0,0 +1,6 @@ +"""espFoC Tool main views.""" + +from .dashboard_view import DashboardView +from .tune_view import TuneView + +__all__ = ["TuneView", "DashboardView"] diff --git a/tools/espfoc_tool/gui/views/dashboard_view.py b/tools/espfoc_tool/gui/views/dashboard_view.py new file mode 100644 index 00000000..6538bc26 --- /dev/null +++ b/tools/espfoc_tool/gui/views/dashboard_view.py @@ -0,0 +1,57 @@ +"""Dashboard view: motion control, SVM, and scope channels.""" + +from __future__ import annotations + +from PySide6.QtWidgets import QHBoxLayout, QSizePolicy, QWidget + +from ..control_rail import ControlRail +from ..states_panel import StatesPanel +from ..svm_panel import SvmPanel +from ..widgets import PageShell, horizontal_splitter, vertical_splitter + + +class DashboardView(QWidget): + def __init__( + self, + control: ControlRail, + svm: SvmPanel, + states: StatesPanel, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + + control.bind_svm_autoset(svm.autoset) + + control.setMinimumWidth(260) + control.setMaximumWidth(360) + control.setSizePolicy( + QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + + plots = vertical_splitter( + svm, + states, + stretches=(2, 3), + sizes=(340, 500), + ) + + split = horizontal_splitter( + control, + plots, + stretches=(0, 1), + sizes=(300, 960), + ) + + body = QWidget() + lay = QHBoxLayout(body) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(split) + + shell = PageShell( + "Dashboard", + "Setpoints and actions on the left; SVM vector view and scope on the right.", + body, + parent=self, + ) + outer = QHBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.addWidget(shell) diff --git a/tools/espfoc_tool/gui/views/tune_view.py b/tools/espfoc_tool/gui/views/tune_view.py new file mode 100644 index 00000000..a71a6c2a --- /dev/null +++ b/tools/espfoc_tool/gui/views/tune_view.py @@ -0,0 +1,86 @@ +"""Tune view: device setup, flash, motor model, MPZ analysis.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QHBoxLayout, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from ..analysis_panel import AnalysisPanel +from ..motor_model_rail import MotorModelRail +from ..nvs_diff_panel import NvsDiffPanel +from ..tuning_panel import TuningPanel +from ..widgets import PageShell, horizontal_splitter + + +class TuneView(QWidget): + def __init__( + self, + tuning: TuningPanel, + analysis: AnalysisPanel, + on_params_changed=None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + + self.nvs = NvsDiffPanel( + kp_spin=tuning._kp_spin, + ki_spin=tuning._ki_spin, + lim_spin=tuning._lim_spin, + fc_spin=tuning._fc_spin, + ) + self.nvs.setMinimumWidth(260) + self.nvs.setMaximumWidth(420) + self.nvs.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) + + self.motor = MotorModelRail(on_params_changed=on_params_changed) + + setup_scroll = QScrollArea() + setup_scroll.setWidgetResizable(True) + setup_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + setup_scroll.setFrameShape(QScrollArea.NoFrame) + setup_scroll.setWidget(tuning) + setup_scroll.setMinimumWidth(300) + setup_scroll.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) + + plots_col = QWidget() + plots_lay = QVBoxLayout(plots_col) + plots_lay.setContentsMargins(0, 0, 0, 0) + plots_lay.setSpacing(12) + plots_lay.addWidget(self.motor, 0) + plots_lay.addWidget(analysis, 1) + + split = horizontal_splitter( + setup_scroll, + self.nvs, + plots_col, + stretches=(2, 1, 3), + sizes=(400, 300, 720), + ) + + body = QWidget() + lay = QHBoxLayout(body) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(split) + + shell = PageShell( + "Tune", + "Status and gains on the left, flash diff in the center, " + "motor model and MPZ plots on the right.", + body, + parent=self, + ) + outer = QHBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.addWidget(shell) + + for sp in (tuning._kp_spin, tuning._ki_spin, + tuning._lim_spin, tuning._fc_spin): + sp.valueChanged.connect(self.nvs.refresh_from_editor) diff --git a/tools/espfoc_tool/gui/widgets.py b/tools/espfoc_tool/gui/widgets.py new file mode 100644 index 00000000..621e4434 --- /dev/null +++ b/tools/espfoc_tool/gui/widgets.py @@ -0,0 +1,186 @@ +"""Shared layout primitives: page chrome, elevated cards, live metrics.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDoubleSpinBox, + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QSplitter, + QVBoxLayout, + QWidget, +) + +from .theme import monospace_font + + +def spin_box( + minimum: float, + maximum: float, + decimals: int, + value: float, + *, + step: float = 0.1, + suffix: str = "", +) -> QDoubleSpinBox: + if minimum > maximum: + minimum, maximum = maximum, minimum + box = QDoubleSpinBox() + box.setRange(minimum, maximum) + box.setDecimals(decimals) + box.setSingleStep(step) + box.setValue(value) + if suffix: + box.setSuffix(suffix) + box.setMinimumWidth(120) + return box + + +def horizontal_splitter( + *panes: QWidget, + stretches: tuple[int, ...] | None = None, + sizes: tuple[int, ...] | None = None, +) -> QSplitter: + split = QSplitter(Qt.Horizontal) + split.setChildrenCollapsible(False) + split.setHandleWidth(6) + for i, pane in enumerate(panes): + split.addWidget(pane) + if stretches is not None and i < len(stretches): + split.setStretchFactor(i, stretches[i]) + if sizes is not None: + split.setSizes(list(sizes)) + return split + + +def vertical_splitter( + *panes: QWidget, + stretches: tuple[int, ...] | None = None, + sizes: tuple[int, ...] | None = None, +) -> QSplitter: + split = QSplitter(Qt.Vertical) + split.setChildrenCollapsible(False) + split.setHandleWidth(6) + for i, pane in enumerate(panes): + split.addWidget(pane) + if stretches is not None and i < len(stretches): + split.setStretchFactor(i, stretches[i]) + if sizes is not None: + split.setSizes(list(sizes)) + return split + + +class PageHeader(QWidget): + def __init__( + self, + title: str, + subtitle: str = "", + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(4) + t = QLabel(title) + t.setObjectName("PageTitle") + lay.addWidget(t) + if subtitle.strip(): + s = QLabel(subtitle) + s.setObjectName("PageSubtitle") + s.setWordWrap(True) + lay.addWidget(s) + + +class PageShell(QWidget): + """Top title band + stretchable body (typical view wrapper).""" + + def __init__( + self, + title: str, + subtitle: str, + body: QWidget, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + root = QVBoxLayout(self) + root.setContentsMargins(20, 18, 20, 16) + root.setSpacing(14) + root.addWidget(PageHeader(title, subtitle)) + root.addWidget(body, 1) + + +class SurfaceCard(QFrame): + def __init__( + self, + title: str = "", + subtitle: str = "", + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName("SurfaceCard") + outer = QVBoxLayout(self) + outer.setContentsMargins(16, 14, 16, 14) + outer.setSpacing(10) + if title: + t = QLabel(title) + t.setObjectName("CardTitle") + outer.addWidget(t) + if subtitle.strip(): + s = QLabel(subtitle) + s.setObjectName("CardSubtitle") + s.setWordWrap(True) + outer.addWidget(s) + self._body = QWidget() + self._body_layout = QVBoxLayout(self._body) + self._body_layout.setContentsMargins(0, 0, 0, 0) + self._body_layout.setSpacing(8) + outer.addWidget(self._body) + + @property + def body_layout(self) -> QVBoxLayout: + return self._body_layout + + +class LiveMetric(QWidget): + def __init__(self, label: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + lay = QVBoxLayout(self) + lay.setContentsMargins(10, 8, 10, 8) + lay.setSpacing(2) + self.setObjectName("LiveMetric") + cap = QLabel(label) + cap.setObjectName("MetricCaption") + self.value = QLabel("—") + self.value.setObjectName("MetricValue") + self.value.setFont(monospace_font(14)) + lay.addWidget(cap) + lay.addWidget(self.value) + + def set_value(self, text: str) -> None: + self.value.setText(text) + + +class LiveMetricGrid(QWidget): + def __init__(self, labels: list[str], parent: QWidget | None = None) -> None: + super().__init__(parent) + grid = QGridLayout(self) + grid.setContentsMargins(0, 0, 0, 0) + grid.setHorizontalSpacing(10) + grid.setVerticalSpacing(10) + self._metrics: list[LiveMetric] = [] + cols = 2 + for i, lab in enumerate(labels): + m = LiveMetric(lab) + self._metrics.append(m) + grid.addWidget(m, i // cols, i % cols) + + def metric(self, index: int) -> LiveMetric: + return self._metrics[index] + + def set_values(self, texts: list[str]) -> None: + for m, t in zip(self._metrics, texts): + m.set_value(t) + diff --git a/tools/espfoc_studio/link/__init__.py b/tools/espfoc_tool/link/__init__.py similarity index 91% rename from tools/espfoc_studio/link/__init__.py rename to tools/espfoc_tool/link/__init__.py index b739b8ab..8fb57f66 100644 --- a/tools/espfoc_studio/link/__init__.py +++ b/tools/espfoc_tool/link/__init__.py @@ -1,6 +1,6 @@ """Wire framing for the espFoC tuner protocol — Python mirror of source/motor_control/esp_foc_link.c. Cross-validated against the -firmware-side encoder/decoder via tools/espfoc_studio/tests/. +firmware-side encoder/decoder via tools/espfoc_tool/tests/. """ from .codec import ( diff --git a/tools/espfoc_studio/link/codec.py b/tools/espfoc_tool/link/codec.py similarity index 100% rename from tools/espfoc_studio/link/codec.py rename to tools/espfoc_tool/link/codec.py diff --git a/tools/espfoc_studio/link/reader.py b/tools/espfoc_tool/link/reader.py similarity index 100% rename from tools/espfoc_studio/link/reader.py rename to tools/espfoc_tool/link/reader.py diff --git a/tools/espfoc_studio/link/scope_sample.py b/tools/espfoc_tool/link/scope_sample.py similarity index 90% rename from tools/espfoc_studio/link/scope_sample.py rename to tools/espfoc_tool/link/scope_sample.py index 14347fd1..8b603603 100644 --- a/tools/espfoc_studio/link/scope_sample.py +++ b/tools/espfoc_tool/link/scope_sample.py @@ -2,7 +2,7 @@ If the target was built with ``CONFIG_ESP_FOC_SCOPE_LEGACY_CSV=y`` (or you are testing old code), set env ``ESP_FOC_STUDIO_SCOPE_CSV=1`` or use -``python -m espfoc_studio.gui --scope-csv`` so comma-separated float lines +``python -m espfoc_tool.gui --scope-csv`` so comma-separated float lines are still decoded. Otherwise only SCOPE v1 is parsed (no CSV branch).""" from __future__ import annotations @@ -13,7 +13,8 @@ # Match legacy firmware: enable CSV decode only when set to "1" (Kconfig # has no direct equivalent on the host). -_ENV_CSV = "ESP_FOC_STUDIO_SCOPE_CSV" +_ENV_CSV = "ESPFOC_TOOL_SCOPE_CSV" +_ENV_CSV_LEGACY = "ESP_FOC_STUDIO_SCOPE_CSV" # Wire: 0xFF + 'S','C', v1. First byte 0xFF avoids collision with CSV digits. SCOPE_WIRE_V1 = 0x01 @@ -35,7 +36,8 @@ def pack_scope_i32_to_payload(samples_i32: List[int]) -> bytes: def legacy_csv_decoding_enabled() -> bool: - return os.environ.get(_ENV_CSV, "0") == "1" + return (os.environ.get(_ENV_CSV, "0") == "1" + or os.environ.get(_ENV_CSV_LEGACY, "0") == "1") def decode_scope_payload_to_floats(payload: bytes) -> Optional[List[float]]: diff --git a/tools/espfoc_studio/link/transport.py b/tools/espfoc_tool/link/transport.py similarity index 100% rename from tools/espfoc_studio/link/transport.py rename to tools/espfoc_tool/link/transport.py diff --git a/tools/espfoc_studio/link/transport_serial.py b/tools/espfoc_tool/link/transport_serial.py similarity index 100% rename from tools/espfoc_studio/link/transport_serial.py rename to tools/espfoc_tool/link/transport_serial.py diff --git a/tools/espfoc_studio/model/__init__.py b/tools/espfoc_tool/model/__init__.py similarity index 90% rename from tools/espfoc_studio/model/__init__.py rename to tools/espfoc_tool/model/__init__.py index 013b73a1..4fb1de11 100644 --- a/tools/espfoc_studio/model/__init__.py +++ b/tools/espfoc_tool/model/__init__.py @@ -1,4 +1,4 @@ -"""Motor / control analysis library for TunerStudio. +"""Motor / control analysis library for espFoC Tool. Pure numpy; no Qt or GUI dependency. Reused by the GUI's Analysis tab, the CLI snapshot report, and the offline golden-vector tests. diff --git a/tools/espfoc_studio/model/analysis.py b/tools/espfoc_tool/model/analysis.py similarity index 100% rename from tools/espfoc_studio/model/analysis.py rename to tools/espfoc_tool/model/analysis.py diff --git a/tools/espfoc_studio/protocol/__init__.py b/tools/espfoc_tool/protocol/__init__.py similarity index 100% rename from tools/espfoc_studio/protocol/__init__.py rename to tools/espfoc_tool/protocol/__init__.py diff --git a/tools/espfoc_studio/protocol/tuner.py b/tools/espfoc_tool/protocol/tuner.py similarity index 100% rename from tools/espfoc_studio/protocol/tuner.py rename to tools/espfoc_tool/protocol/tuner.py diff --git a/tools/espfoc_studio/requirements.txt b/tools/espfoc_tool/requirements.txt similarity index 70% rename from tools/espfoc_studio/requirements.txt rename to tools/espfoc_tool/requirements.txt index 9fca75a1..32127b05 100644 --- a/tools/espfoc_studio/requirements.txt +++ b/tools/espfoc_tool/requirements.txt @@ -1,4 +1,4 @@ -# Runtime dependencies for espfoc_studio (CLI + GUI). +# Runtime dependencies for espfoc_tool (CLI + GUI). pyserial>=3.5 numpy>=1.23 pytest>=7.0.0 diff --git a/tools/espfoc_studio/tests/__init__.py b/tools/espfoc_tool/tests/__init__.py similarity index 100% rename from tools/espfoc_studio/tests/__init__.py rename to tools/espfoc_tool/tests/__init__.py diff --git a/tools/espfoc_studio/tests/test_analysis.py b/tools/espfoc_tool/tests/test_analysis.py similarity index 98% rename from tools/espfoc_studio/tests/test_analysis.py rename to tools/espfoc_tool/tests/test_analysis.py index dbcdd1d5..9a89fa33 100644 --- a/tools/espfoc_studio/tests/test_analysis.py +++ b/tools/espfoc_tool/tests/test_analysis.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Unit tests for tools/espfoc_studio/model/analysis.py. +"""Unit tests for tools/espfoc_tool/model/analysis.py. Pure math, no Qt; safe to run in CI or headless dev boxes. """ @@ -15,7 +15,7 @@ HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(os.path.dirname(HERE))) -from espfoc_studio.model import ( +from espfoc_tool.model import ( MotorParams, PiGains, bode, diff --git a/tools/espfoc_tool/tests/test_gui_smoke.py b/tools/espfoc_tool/tests/test_gui_smoke.py new file mode 100644 index 00000000..b1d59fa9 --- /dev/null +++ b/tools/espfoc_tool/tests/test_gui_smoke.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Headless smoke tests for espFoC Tool GUI.""" + +from __future__ import annotations + +import os +import sys + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ["ESPFOC_TOOL_NO_GL"] = "1" + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(os.path.dirname(HERE))) + +from PySide6.QtWidgets import QApplication + +from espfoc_tool.gui.app import create_application +from espfoc_tool.gui.connection_manager import ConnectionManager +from espfoc_tool.gui.main_window import MainWindow +from espfoc_tool.gui.scope_stream_timing import ( + LOW_SPEED_DOWNSAMPLING, + scope_uniform_dt_s, +) + + +def test_main_window_offline_smoke(): + app = create_application() + conn = ConnectionManager(fixed_port=None) + w = MainWindow(conn, title="espFoC Tool test") + assert w._stack.count() == 2 + from PySide6.QtWidgets import QLabel + titles = [lb.text() for lb in w.findChildren(QLabel) + if lb.objectName() == "PageTitle"] + assert "Tune" in titles + assert "Dashboard" in titles + w.close() + conn.stop() + + +def test_scope_stream_timing_hw(): + dt_hw = scope_uniform_dt_s(20000.0) + assert abs(dt_hw - LOW_SPEED_DOWNSAMPLING / 20000.0) < 1e-15 + + +def main() -> int: + tests = [ + test_main_window_offline_smoke, + test_scope_stream_timing_hw, + ] + failed = 0 + for t in tests: + try: + t() + print(f"OK {t.__name__}") + except Exception as e: + failed += 1 + print(f"FAIL {t.__name__}: {e}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/espfoc_studio/tests/test_link_codec.py b/tools/espfoc_tool/tests/test_link_codec.py similarity index 97% rename from tools/espfoc_studio/tests/test_link_codec.py rename to tools/espfoc_tool/tests/test_link_codec.py index 42444dad..f0c9552c 100644 --- a/tools/espfoc_studio/tests/test_link_codec.py +++ b/tools/espfoc_tool/tests/test_link_codec.py @@ -9,7 +9,7 @@ no longer talk to each other. Run from the component root: - PYTHONPATH=tools python3 tools/espfoc_studio/tests/test_link_codec.py + PYTHONPATH=tools python3 tools/espfoc_tool/tests/test_link_codec.py """ from __future__ import annotations @@ -20,7 +20,7 @@ HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(os.path.dirname(HERE))) -from espfoc_studio.link import ( +from espfoc_tool.link import ( Channel, Decoder, LinkError, @@ -56,7 +56,7 @@ def test_encode_roundtrip_small(): def test_encode_roundtrip_empty(): - frame = encode(Channel.LOG, 0) + frame = encode(Channel.TUNER, 0) dec = Decoder() for b in frame[:-1]: assert dec.push(b) == Status.NEED_MORE diff --git a/tools/espfoc_studio/tests/test_link_io_tuner.py b/tools/espfoc_tool/tests/test_link_io_tuner.py similarity index 90% rename from tools/espfoc_studio/tests/test_link_io_tuner.py rename to tools/espfoc_tool/tests/test_link_io_tuner.py index 0ce69780..a24ef2a4 100644 --- a/tools/espfoc_studio/tests/test_link_io_tuner.py +++ b/tools/espfoc_tool/tests/test_link_io_tuner.py @@ -9,8 +9,8 @@ import pytest # type: ignore -from espfoc_studio.link import LinkReader, Transport -from espfoc_studio.protocol import TunerClient, TunerError +from espfoc_tool.link import LinkReader, Transport +from espfoc_tool.protocol import TunerClient, TunerError def test_send_oserror_is_tuner_error() -> None: @@ -28,6 +28,7 @@ def close(self) -> None: r.start() try: c = TunerClient(r, axis=0) + c._connected = True with pytest.raises(TunerError) as einfo: c.read_kp() assert "link I/O" in str(einfo.value) @@ -55,6 +56,7 @@ def close(self) -> None: r.start() try: c = TunerClient(r, axis=0) + c._connected = True with pytest.raises(TunerError) as einfo: c.read_kp() assert "link I/O" in str(einfo.value) diff --git a/tools/espfoc_studio/tests/test_scope_sample.py b/tools/espfoc_tool/tests/test_scope_sample.py similarity index 94% rename from tools/espfoc_studio/tests/test_scope_sample.py rename to tools/espfoc_tool/tests/test_scope_sample.py index 3769a4e3..4ddf6e77 100644 --- a/tools/espfoc_studio/tests/test_scope_sample.py +++ b/tools/espfoc_tool/tests/test_scope_sample.py @@ -1,4 +1,4 @@ -from espfoc_studio.link.scope_sample import ( +from espfoc_tool.link.scope_sample import ( decode_scope_payload_to_floats, decode_scope_payload_to_floats_csv_first, pack_scope_i32_to_payload, diff --git a/tools/espfoc_studio/tests/test_tuner_protocol.py b/tools/espfoc_tool/tests/test_tuner_protocol.py similarity index 93% rename from tools/espfoc_studio/tests/test_tuner_protocol.py rename to tools/espfoc_tool/tests/test_tuner_protocol.py index 6cf2942f..2728a689 100644 --- a/tools/espfoc_studio/tests/test_tuner_protocol.py +++ b/tools/espfoc_tool/tests/test_tuner_protocol.py @@ -9,9 +9,9 @@ HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(os.path.dirname(HERE))) -from espfoc_studio.fake_tuner_loopback import FakeTunerLoopback -from espfoc_studio.link import LoopbackTransport -from espfoc_studio.protocol import ( +from espfoc_tool.fake_tuner_loopback import FakeTunerLoopback +from espfoc_tool.link import LoopbackTransport +from espfoc_tool.protocol import ( AxisStateFlag, TunerClient, TunerError,