autocal layout first design
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
# Orisys AutoCal
|
||||
|
||||
Internal control application for the **5-axis calibration platform**, driving a
|
||||
ZMotion ZMC bus controller (EtherCAT or RTEX) from the PC.
|
||||
|
||||
Replaces the vendor example program it was ported from:
|
||||
|
||||
D:\orisys_ws\zmotion\Python64\例程9-总线控制运动
|
||||
|
||||
> **Internal use only.** AutoCal is not part of the customer installer. See
|
||||
> "Do not ship this app" below.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Connect** over Ethernet (with an IP scan), serial, PCI, local/simulated, or
|
||||
a built-in **demo** transport that needs no hardware and no DLL.
|
||||
- **Initialise the bus** by downloading `ECAT.bas` or `RTEX.bas` to the
|
||||
controller, then follow the controller's own state machine to completion — or
|
||||
to a timeout, with the vendor's diagnostic hint.
|
||||
- **Read axis state** for all five drives at once: enable, axis type, pulse
|
||||
equivalent, speed, acceleration, commanded and measured position (in user
|
||||
units *and* millimetres), axis status word, motion and homing state.
|
||||
- **Move**: enable/disable, jog, absolute and relative moves, stop, stop-all,
|
||||
driver homing, clear a drive alarm. Every command is guarded — see below.
|
||||
- **Exchange raw bus data**: EtherCAT object-dictionary (SDO) reads and writes,
|
||||
and RTEX parameters. Each family is enabled only when the controller reports
|
||||
that bus type.
|
||||
|
||||
## Run it
|
||||
|
||||
PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no
|
||||
licence purchase. (`PyQt5`, which the studio app uses, is GPLv3-or-commercial.)
|
||||
|
||||
```powershell
|
||||
# with the environment that already has PySide6 (Anaconda py310, for example)
|
||||
$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\autocal\src"
|
||||
python D:\orisys_ws\orisys-app\apps\autocal\run.py
|
||||
```
|
||||
|
||||
Or with no controller attached at all:
|
||||
|
||||
```powershell
|
||||
python .\apps\autocal\run.py --demo
|
||||
```
|
||||
|
||||
`run.py` sets `ORISYS_APP_ID=autocal` before importing anything from
|
||||
`orisys_app_shared`, so this app's `config/`, `preference.json` and `outputs/`
|
||||
resolve to `apps/autocal/` rather than the studio's.
|
||||
|
||||
### Controller library
|
||||
|
||||
The ZMotion runtime is vendored at `shared/resources/zmotion/x64/` (see the
|
||||
README beside it for provenance). Point `ORISYS_ZMOTION_DLL_DIR` at another
|
||||
directory to use a different build — on a motion controller, silently loading
|
||||
the wrong runtime version is the kind of fault that looks like a working machine
|
||||
until it moves, so the override is authoritative: if it does not contain
|
||||
`zauxdll.dll`, the app refuses to connect instead of quietly falling back.
|
||||
|
||||
## Safety model
|
||||
|
||||
The guards live in `motion/service.py`, not in the UI, so they hold whatever
|
||||
calls them:
|
||||
|
||||
- **Motion is refused** while the bus is not initialised, while the axis is
|
||||
unknown, or on a non-finite target — with a reason, never a silent no-op.
|
||||
- **Homing needs a configured mode.** The vendor example pre-fills `17`, which
|
||||
is one specific drive's mode; AutoCal starts at `0` and refuses, so the
|
||||
operator chooses for the drive in front of them instead of inheriting a number
|
||||
from another machine.
|
||||
- **Homing is refused on RTEX**, as the vendor example itself does.
|
||||
- **Disconnect stops the axes first** (the vendor program closes the handle and
|
||||
leaves the platform coasting).
|
||||
- **SDO/RTEX are gated by the reported bus type**, so a write can never be aimed
|
||||
at the wrong family.
|
||||
- **A failed read is marked, not zeroed.** `ZmotionValue.value_or_none` returns
|
||||
`None` on failure; nothing silently treats a failed read's zero as a position.
|
||||
|
||||
The displayed millimetre value is *derived* from the pulse equivalent
|
||||
(bench-measured: `units=10` → 1000 user units = 2 mm). It is a display value,
|
||||
not a calibrated length, and the UI says so.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
apps/autocal/
|
||||
run.py # source-tree launcher
|
||||
config/
|
||||
preferences.default.json # shipped defaults
|
||||
locales/{zh_CN,en_US}.json
|
||||
bas/{ECAT,RTEX}.bas # copies: the vendor needs a non-Chinese path
|
||||
src/orisys_autocal/
|
||||
main.py # logging, locale, theme, window
|
||||
preferences.py # AutoCal's own preference schema
|
||||
motion/ # Qt-free except poller.py (the Qt worker bridge)
|
||||
models.py errors.py units.py service.py transport.py poller.py
|
||||
ui/ # window.py + one module per panel
|
||||
tools/ # panel_check.py, panel_snapshot.py
|
||||
tests/
|
||||
```
|
||||
|
||||
`motion/poller.py` is the only place a controller call can originate. It owns
|
||||
the DLL handle on its own thread, so the vendor library's blocking calls (a ROM
|
||||
write, a bus scan) never freeze the window and the ctypes handle is never shared
|
||||
between threads. The UI talks to it through signals only.
|
||||
|
||||
## Verify
|
||||
|
||||
Both work with no display, no controller and no DLL:
|
||||
|
||||
```powershell
|
||||
python .\apps\autocal\tools\panel_check.py # 120 layout/theme/locale/gating checks
|
||||
python .\apps\autocal\tools\panel_snapshot.py --demo --theme dark --out shot.png
|
||||
python .\apps\autocal\tools\panel_snapshot.py --compare --out tabs.png # one image per tab
|
||||
python .\apps\autocal\tools\layout_mockup.py --out layout.png # proposed layout, drawn
|
||||
python -m pytest apps\autocal\tests # offscreen suite
|
||||
```
|
||||
|
||||
`layout_mockup.py` is a **wireframe, not the application**: it paints the
|
||||
proposed single-page layout with the shared theme tokens and imports nothing
|
||||
from the app, so the layout can be reviewed and marked up without building the
|
||||
panels. It draws the full page plus previews of the other workspace tabs in one
|
||||
canvas.
|
||||
|
||||
`panel_check.py` builds the real window in both themes at 1280×720, 1600×900 and
|
||||
1920×1080, asserts that no widget escapes its parent, that motion is
|
||||
unreachable before the bus is initialised, that the SDO/RTEX panels follow the
|
||||
reported bus family, and then runs a full simulated session from `ECAT.bas`.
|
||||
|
||||
The suite is written for **PySide6**. Where PySide6 is missing, the test modules
|
||||
are skipped rather than failed: a broken environment should not look like a
|
||||
broken application. `tests/test_binding_purity.py` enforces the binding policy
|
||||
statically — no module may import PyQt5/PySide2/PyQt6, no module may use an
|
||||
unscoped Qt enumerator (removed in PySide6), `shared/` may import no binding at
|
||||
all, and the controller interface must stay importable headless.
|
||||
|
||||
## Do not ship this app
|
||||
|
||||
AutoCal is an internal tool. It is deliberately **not** packaged by
|
||||
`build/studio`, whose PyInstaller spec must not learn about the ZMotion runtime
|
||||
DLLs, and it is not in the customer installer. The ZMotion SDK is vendor-supplied
|
||||
hardware support software with its own terms; do not redistribute those DLLs
|
||||
with a customer product.
|
||||
@@ -0,0 +1,103 @@
|
||||
|
||||
'*******************************************************ECAT总线初始化
|
||||
'''''''''
|
||||
|
||||
global CONST BUS_TYPE = 0 '总线类型。用于上位机区分当前模式
|
||||
global CONST MAX_AXISNUM = 32 '最大轴数
|
||||
global CONST Bus_Slot = 0 '槽位号0
|
||||
global CONST Bus_AxisStart = 0 '总线轴起始轴号
|
||||
|
||||
global Bus_InitStatus '总线初始化完成状态
|
||||
Bus_InitStatus = -1
|
||||
global Bus_TotalAxisnum '检查扫描的总轴数
|
||||
|
||||
delay(3000) '延时3S等待驱动器上电
|
||||
'**********************初始化ECAT总线
|
||||
Ecat_Init()
|
||||
|
||||
end
|
||||
|
||||
global sub Ecat_Init()
|
||||
for i=0 to MAX_AXISNUM - 1 '初始化还原轴类型
|
||||
AXIS_ENABLE(i) = 0
|
||||
atype(i)=0
|
||||
next
|
||||
|
||||
Bus_InitStatus = -1
|
||||
Bus_TotalAxisnum = 0
|
||||
SLOT_STOP(Bus_Slot)
|
||||
delay(200)
|
||||
slot_scan(Bus_Slot) '开始扫描
|
||||
if return then
|
||||
|
||||
?"总线扫描成功","连接设备数:"NODE_COUNT(Bus_Slot)
|
||||
?
|
||||
?"开始映射轴号"
|
||||
for i=0 to NODE_COUNT(Bus_Slot)-1 '遍历总线下所有从站节点
|
||||
|
||||
if NODE_AXIS_COUNT(Bus_Slot,i) <>0 then '判断当前节点是否有电机
|
||||
for j=0 to NODE_AXIS_COUNT(Bus_Slot,i)-1
|
||||
AXIS_ADDRESS(Bus_AxisStart+i)=Bus_TotalAxisnum+1 '映射轴号
|
||||
ATYPE(Bus_AxisStart+i)=65 '设置控制模式 65-位置 66-速度 67-转矩
|
||||
DRIVE_PROFILE(Bus_AxisStart+i)= 4 '设置PROFILE功能
|
||||
disable_group(Bus_AxisStart+i) '每轴单独分组
|
||||
|
||||
DRIVE_IO(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16 '映射驱动器上的IO状态
|
||||
REV_IN(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16
|
||||
FWD_IN(Bus_AxisStart+i) = 129 + (Bus_AxisStart+i)*16
|
||||
DATUM_IN(Bus_AxisStart+i) = 130 + (Bus_AxisStart+i)*16
|
||||
INVERT_IN(128 + (Bus_AxisStart+i)*16,ON)
|
||||
INVERT_IN(129 + (Bus_AxisStart+i)*16,ON)
|
||||
INVERT_IN(130 + (Bus_AxisStart+i)*16,ON)
|
||||
|
||||
Bus_TotalAxisnum=Bus_TotalAxisnum+1 '总轴数+1
|
||||
next
|
||||
endif
|
||||
|
||||
next
|
||||
|
||||
?"轴号映射完成","连接总轴数:"Bus_TotalAxisnum
|
||||
wa 2000
|
||||
|
||||
SLOT_START(Bus_Slot) '启动总线
|
||||
if return then
|
||||
?"总线开启成功"
|
||||
|
||||
?"开始清除驱动器错误(根据驱动器数据字典设置)"
|
||||
for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1
|
||||
|
||||
DRIVE_CONTROLWORD(i)=128 '根据驱动器数据字典
|
||||
wa 100
|
||||
DRIVE_CONTROLWORD(i)=6
|
||||
wa 100
|
||||
DRIVE_CONTROLWORD(i)=15
|
||||
wa 100
|
||||
next
|
||||
|
||||
?"驱动器错误清除完成"
|
||||
wa 100
|
||||
|
||||
?"清除控制器错误"
|
||||
datum(0)
|
||||
DRIVE_CLEAR(0)
|
||||
?"控制器错误清除完成"
|
||||
wa 100
|
||||
|
||||
?"轴使能准备"
|
||||
for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1
|
||||
base(i)
|
||||
AXIS_ENABLE=1
|
||||
next
|
||||
wdog=1 '使能总开关
|
||||
Bus_InitStatus = 1
|
||||
?"轴使能完成"
|
||||
else
|
||||
?"总线开启失败"
|
||||
Bus_InitStatus = 0
|
||||
endif
|
||||
|
||||
else
|
||||
?"总线扫描失败"
|
||||
Bus_InitStatus = 0
|
||||
endif
|
||||
end sub
|
||||
@@ -0,0 +1,103 @@
|
||||
|
||||
'*******************************************************RTEX总线初始化
|
||||
'''''''''
|
||||
global CONST BUS_TYPE = 1 '总线类型。用于上位机区分当前模式
|
||||
global CONST MAX_AXISNUM = 32 '最大轴数
|
||||
global CONST Bus_Slot = 0 '槽位号0
|
||||
global CONST Bus_AxisStart = 0 '总线轴起始轴号
|
||||
|
||||
global Bus_InitStatus '总线初始化完成状态
|
||||
Bus_InitStatus = -1
|
||||
global Bus_TotalAxisnum '检查扫描的总轴数
|
||||
|
||||
delay(3000) '延时3S等待驱动器上电
|
||||
'**********************初始化RTEX总线
|
||||
Rtex_Init()
|
||||
|
||||
end
|
||||
|
||||
global sub Rtex_Init()
|
||||
|
||||
for i=0 to MAX_AXISNUM - 1 '初始化还原轴类型
|
||||
AXIS_ENABLE(i) = 0
|
||||
atype(i)=0
|
||||
next
|
||||
|
||||
Bus_InitStatus = -1
|
||||
Bus_TotalAxisnum = 0
|
||||
SLOT_STOP(Bus_Slot)
|
||||
delay(200)
|
||||
slot_scan(Bus_Slot) '开始扫描
|
||||
if return then
|
||||
|
||||
?"总线扫描成功","连接设备数:"NODE_COUNT(Bus_Slot)
|
||||
?
|
||||
?"开始映射轴号"
|
||||
for i=0 to NODE_COUNT(Bus_Slot)-1 '遍历总线下所有从站节点
|
||||
|
||||
if NODE_AXIS_COUNT(Bus_Slot,i) <>0 then '判断当前节点是否有电机
|
||||
for j=0 to NODE_AXIS_COUNT(Bus_Slot,i)-1
|
||||
AXIS_ADDRESS(Bus_AxisStart+i)=Bus_TotalAxisnum+1 '映射轴号
|
||||
ATYPE(Bus_AxisStart+i)=50 '设置控制模式 50-位置 51-速度 52-转矩
|
||||
DRIVE_PROFILE(Bus_AxisStart+i)= 4 '设置PROFILE功能
|
||||
disable_group(Bus_AxisStart+i) '每轴单独分组
|
||||
|
||||
DRIVE_IO(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16 '映射驱动器上的IO状态
|
||||
REV_IN(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16
|
||||
FWD_IN(Bus_AxisStart+i) = 129 + (Bus_AxisStart+i)*16
|
||||
DATUM_IN(Bus_AxisStart+i) = 130 + (Bus_AxisStart+i)*16
|
||||
INVERT_IN(128 + (Bus_AxisStart+i)*16,ON)
|
||||
INVERT_IN(129 + (Bus_AxisStart+i)*16,ON)
|
||||
INVERT_IN(130 + (Bus_AxisStart+i)*16,ON)
|
||||
|
||||
Bus_TotalAxisnum=Bus_TotalAxisnum+1 '总轴数+1
|
||||
next
|
||||
endif
|
||||
|
||||
next
|
||||
|
||||
?"轴号映射完成","连接总轴数:"Bus_TotalAxisnum
|
||||
wa 2000
|
||||
|
||||
SLOT_START(Bus_Slot) '启动总线
|
||||
if return then
|
||||
?"总线开启成功"
|
||||
|
||||
?"开始清除驱动器错误(根据驱动器数据字典设置)"
|
||||
for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1
|
||||
|
||||
DRIVE_CONTROLWORD(i)=128 '根据驱动器数据字典
|
||||
wa 100
|
||||
DRIVE_CONTROLWORD(i)=6
|
||||
wa 100
|
||||
DRIVE_CONTROLWORD(i)=15
|
||||
wa 100
|
||||
next
|
||||
|
||||
?"驱动器错误清除完成"
|
||||
wa 100
|
||||
|
||||
?"清除控制器错误"
|
||||
datum(0)
|
||||
DRIVE_CLEAR(0)
|
||||
?"控制器错误清除完成"
|
||||
wa 100
|
||||
|
||||
?"轴使能准备"
|
||||
for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1
|
||||
base(i)
|
||||
AXIS_ENABLE=1
|
||||
next
|
||||
wdog=1 '使能总开关
|
||||
Bus_InitStatus = 1
|
||||
?"轴使能完成"
|
||||
else
|
||||
?"总线开启失败"
|
||||
Bus_InitStatus = 0
|
||||
endif
|
||||
|
||||
else
|
||||
?"总线扫描失败"
|
||||
Bus_InitStatus = 0
|
||||
endif
|
||||
end sub
|
||||
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Orisys AutoCal Platform Control",
|
||||
"about_title": "About Orisys AutoCal",
|
||||
"about_body": "Orisys AutoCal — internal controller for the 5-axis calibration platform\nVersion: {app_version}\nShared library: {shared_version} (API {api_version})\n\nQt binding: {binding}\nController library: {dll_dir}\nPreferences: {prefs_path}\n\nInternal use only.",
|
||||
"internal_notice": "Internal tool: not shipped in the customer installer."
|
||||
},
|
||||
"brand": {
|
||||
"name": "ORISYS",
|
||||
"detail": "Orisys Technology"
|
||||
},
|
||||
"menu": {
|
||||
"file": "File",
|
||||
"open_bas": "Download BAS init program…",
|
||||
"reveal_config": "Show config in Explorer…",
|
||||
"quit": "Quit",
|
||||
"view": "View",
|
||||
"theme": "Theme",
|
||||
"theme_dark": "Dark",
|
||||
"theme_light": "Light",
|
||||
"language": "Language",
|
||||
"lang_zh": "简体中文",
|
||||
"lang_en": "English",
|
||||
"show_diagnostics": "Diagnostics",
|
||||
"tabs": "Workspace",
|
||||
"outputs": "Outputs folder",
|
||||
"help": "Help",
|
||||
"about": "About AutoCal"
|
||||
},
|
||||
"connection": {
|
||||
"title": "Controller connection",
|
||||
"mode": "Connection",
|
||||
"mode_eth": "Ethernet",
|
||||
"mode_com": "Serial",
|
||||
"mode_pci": "PCI card",
|
||||
"mode_local": "Local / simulated",
|
||||
"mode_demo": "Demo (no hardware)",
|
||||
"ip": "IP",
|
||||
"scan": "Scan IP",
|
||||
"com": "COM port",
|
||||
"card": "Card",
|
||||
"local_string": "Connect string",
|
||||
"open": "Connect",
|
||||
"close": "Disconnect",
|
||||
"status": "Status",
|
||||
"state_open": "Connected",
|
||||
"state_closed": "Not connected",
|
||||
"state_connecting": "Connecting…",
|
||||
"source": "Library",
|
||||
"no_more": "The 『{mode}』 connection is not available yet."
|
||||
},
|
||||
"bus": {
|
||||
"title": "Bus initialisation",
|
||||
"bas_path": "BAS file",
|
||||
"choose": "Choose BAS…",
|
||||
"download": "Download and initialise",
|
||||
"reinit": "Re-initialise",
|
||||
"type": "Bus type",
|
||||
"type_ecat": "EtherCAT",
|
||||
"type_rtex": "RTEX",
|
||||
"type_unknown": "Unknown",
|
||||
"state": "Init state",
|
||||
"state_pending": "Not started",
|
||||
"state_running": "Initialising…",
|
||||
"state_ok": "Initialised",
|
||||
"state_failed": "Initialisation failed",
|
||||
"state_timeout": "Initialisation timed out",
|
||||
"nodes": "Nodes",
|
||||
"axes": "Axes",
|
||||
"downloaded": "File downloaded; the controller will scan the bus after its delay",
|
||||
"dialog_title": "Choose a BAS file"
|
||||
},
|
||||
"axes": {
|
||||
"title": "Axis motion",
|
||||
"axis": "Axis",
|
||||
"enable": "Enable",
|
||||
"enable_on": "Enabled",
|
||||
"enable_off": "Disabled",
|
||||
"atype": "Type",
|
||||
"units": "Units",
|
||||
"speed": "Speed",
|
||||
"accel": "Accel",
|
||||
"dpos": "DPOS",
|
||||
"mpos": "MPOS",
|
||||
"dpos_mm": "DPOS (mm)",
|
||||
"mpos_mm": "MPOS (mm)",
|
||||
"status": "Status",
|
||||
"motion": "Motion",
|
||||
"motion_word": "Motion",
|
||||
"idle": "Stopped",
|
||||
"moving": "Moving",
|
||||
"position_pair": "{value} ({mm} mm)",
|
||||
"enable_all": "Enable all",
|
||||
"disable_all": "Disable all",
|
||||
"clear_alarm_all": "Clear all alarms",
|
||||
"detail": "Axis {axis}: type {atype} · accel {accel} · feedback {mpos} ({mpos_mm} mm) · status {status} · {home}",
|
||||
"detail_none": "Axis {axis}: no data yet (not connected or not initialised)",
|
||||
"home_status": "Homing",
|
||||
"home_ok": "Homed",
|
||||
"home_pending": "Not homed",
|
||||
"home_running": "Homing…",
|
||||
"jog_fwd": "Fwd",
|
||||
"jog_rev": "Rev",
|
||||
"stop_all": "Stop all",
|
||||
"stop": "Stop",
|
||||
"target": "Target",
|
||||
"move_abs": "Move abs",
|
||||
"move_rel": "Move rel",
|
||||
"home_mode": "Home mode",
|
||||
"home_high": "Home speed",
|
||||
"home_low": "Home creep",
|
||||
"home_offset": "Home offset",
|
||||
"clear_alarm": "Clear alarm",
|
||||
"apply": "Apply",
|
||||
"home": "Home",
|
||||
"controls": "Actions",
|
||||
"mm_derived_tip": "Millimetres are derived from the pulse equivalent: a display value, not a calibrated length.",
|
||||
"enabled_count": "{on}/{total} axes enabled"
|
||||
},
|
||||
"busdata": {
|
||||
"title": "Bus data exchange",
|
||||
"group": "Bus data (SDO / RTEX)",
|
||||
"ecat": "EtherCAT (SDO)",
|
||||
"rtex": "RTEX",
|
||||
"node": "Node",
|
||||
"index": "Index",
|
||||
"subindex": "Subindex",
|
||||
"type": "Type",
|
||||
"value": "Value",
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"axis": "Axis",
|
||||
"parameter": "Parameter",
|
||||
"last_result": "Last: {text}",
|
||||
"not_available": "The current bus does not support this",
|
||||
"read_ok": "Read: {value}",
|
||||
"write_ok": "Written"
|
||||
},
|
||||
"status": {
|
||||
"motion": "Motion",
|
||||
"ready": "Ready",
|
||||
"poll_ok": "State refresh healthy",
|
||||
"poll_paused": "State refresh paused",
|
||||
"poll_stalled": "State refresh stalled",
|
||||
"last_error": "Last error",
|
||||
"none": "None",
|
||||
"refused": "Refused",
|
||||
"done": "Done",
|
||||
"failed": "Failed",
|
||||
"pause": "Pause refresh",
|
||||
"resume": "Resume refresh",
|
||||
"paused": "State refresh paused",
|
||||
"alarm_short": "Alarm",
|
||||
"axes_enabled": "{on}/{total} axes enabled",
|
||||
"health_none": "Refresh —",
|
||||
"health_age": "Read {ms} ms ago",
|
||||
"health_paused": "Refresh paused",
|
||||
"poll_every": "Refresh every {ms} ms"
|
||||
},
|
||||
"run": {
|
||||
"title": "Run",
|
||||
"phase_idle": "Idle",
|
||||
"phase_dry_run": "Dry run",
|
||||
"phase_running": "Running",
|
||||
"phase_paused": "Paused",
|
||||
"phase_done": "Done",
|
||||
"phase_failed": "Failed",
|
||||
"step_of": "Point {done}/{total}",
|
||||
"no_steps": "No points generated",
|
||||
"elapsed_only": "Elapsed {elapsed}",
|
||||
"elapsed_remaining": "Elapsed {elapsed} · about {remaining} left",
|
||||
"run": "Run",
|
||||
"pause": "Pause",
|
||||
"abort": "Abort",
|
||||
"abort_stopped": "Run aborted and all axes stopped.",
|
||||
"not_implemented": "The runner belongs to phase P7 and is not implemented yet; abort is always available."
|
||||
},
|
||||
"readout": {
|
||||
"title": "Axis state"
|
||||
},
|
||||
"workspace": {
|
||||
"tab_axes": "Axis debug",
|
||||
"tab_sequence": "Test sequence",
|
||||
"tab_sensor": "Sensor",
|
||||
"tab_surface": "Surface map",
|
||||
"tab_points": "Calibration points",
|
||||
"not_built": "Not built yet",
|
||||
"reserved_sensor": "Sensor connection, live image and force curves will appear here: device and source selection, preview, and a compact version of the Studio curve view.",
|
||||
"reserved_surface": "Multi-point calibration sensitivity will be drawn as a colour map on the sensor's 3D surface here: each point's sensitivity interpolated and coloured, with a legend and range.",
|
||||
"reserved_points": "The calibration point table and capture controls will appear here: platform coordinates, reference load, sampling statistics and the resulting sensitivity."
|
||||
},
|
||||
"sequence": {
|
||||
"title": "Test sequence design",
|
||||
"profile_title": "Point generation",
|
||||
"preview_title": "Preview",
|
||||
"validation_title": "Checks",
|
||||
"points_title": "Calibration points",
|
||||
"field_mode": "Surface mode",
|
||||
"field_pitch": "Pitch (mm)",
|
||||
"field_margin": "Margin (mm)",
|
||||
"field_radius": "Radius (mm)",
|
||||
"field_clear_height": "Clear height (mm)",
|
||||
"field_approach_speed": "Approach speed (mm/s)",
|
||||
"field_press_speed": "Press speed (mm/s)",
|
||||
"field_press_depth": "Press depth (mm)",
|
||||
"field_dwell": "Dwell (s)",
|
||||
"field_settle": "Settle window (s)",
|
||||
"field_repeats": "Repeats",
|
||||
"field_order": "Point order",
|
||||
"mode_flat": "Flat",
|
||||
"mode_curved": "Curved",
|
||||
"order_row": "Row-wise",
|
||||
"order_serpentine": "Serpentine",
|
||||
"order_distance": "By distance",
|
||||
"preview_placeholder": "3D preview: sensor surface · calibration points · visiting order\n(implemented in the simulation phase)",
|
||||
"preview_hint": "After generation, check here that each point is on the surface, inside travel, and confirmable by contact.",
|
||||
"validation_pending": "No points generated yet.",
|
||||
"check_on_surface": "On the surface",
|
||||
"check_within_travel": "Within travel",
|
||||
"check_contact_confirmable": "Contact confirmable",
|
||||
"btn_generate": "Generate points",
|
||||
"btn_simulate": "Simulate",
|
||||
"btn_dry_run": "Dry run (no contact)",
|
||||
"btn_save": "Save sequence…",
|
||||
"btn_load": "Load sequence…",
|
||||
"pending_tooltip": "This belongs to phase {phase} and is not implemented yet.",
|
||||
"col_index": "#",
|
||||
"col_x": "X (mm)",
|
||||
"col_y": "Y (mm)",
|
||||
"col_z": "Z (mm)",
|
||||
"col_status": "Status",
|
||||
"col_sensitivity": "Sensitivity (last)",
|
||||
"col_samples": "Samples",
|
||||
"col_actions": "Actions",
|
||||
"not_generated": "—",
|
||||
"status_pending": "Pending",
|
||||
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked, and one wrong number ruins a run."
|
||||
},
|
||||
"action": {
|
||||
"ok": "Done",
|
||||
"refused": "Refused",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"diagnostics": {
|
||||
"title": "Diagnostics",
|
||||
"binding": "Qt binding",
|
||||
"dll_dir": "Controller library",
|
||||
"prefs": "Preferences",
|
||||
"outputs": "Outputs",
|
||||
"bas_loaded": "BAS loaded",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Orisys AutoCal 标定台控制",
|
||||
"about_title": "关于 Orisys AutoCal",
|
||||
"about_body": "Orisys AutoCal — 五轴标定台内部控制器\n版本: {app_version}\n共享库: {shared_version} (API {api_version})\n\nQt 绑定: {binding}\n控制器库: {dll_dir}\n偏好文件: {prefs_path}\n\n仅供内部使用。",
|
||||
"internal_notice": "内部工具:不随客户安装包发布。"
|
||||
},
|
||||
"brand": {
|
||||
"name": "ORISYS",
|
||||
"detail": "叠动科技"
|
||||
},
|
||||
"menu": {
|
||||
"file": "文件",
|
||||
"open_bas": "下载 BAS 初始化程序…",
|
||||
"reveal_config": "在资源管理器中显示配置…",
|
||||
"quit": "退出",
|
||||
"view": "视图",
|
||||
"theme": "主题",
|
||||
"theme_dark": "深色",
|
||||
"theme_light": "浅色",
|
||||
"language": "语言",
|
||||
"lang_zh": "简体中文",
|
||||
"lang_en": "English",
|
||||
"show_diagnostics": "诊断信息",
|
||||
"tabs": "工作区",
|
||||
"outputs": "输出目录",
|
||||
"help": "帮助",
|
||||
"about": "关于 AutoCal"
|
||||
},
|
||||
"connection": {
|
||||
"title": "连接控制器",
|
||||
"mode": "连接方式",
|
||||
"mode_eth": "以太网",
|
||||
"mode_com": "串口",
|
||||
"mode_pci": "PCI 卡",
|
||||
"mode_local": "本地/仿真",
|
||||
"mode_demo": "演示 (无硬件)",
|
||||
"ip": "IP",
|
||||
"scan": "IP 扫描",
|
||||
"com": "串口号",
|
||||
"card": "卡号",
|
||||
"local_string": "连接参数",
|
||||
"open": "连接",
|
||||
"close": "断开连接",
|
||||
"status": "状态",
|
||||
"state_open": "已连接",
|
||||
"state_closed": "未连接",
|
||||
"state_connecting": "连接中…",
|
||||
"source": "库路径",
|
||||
"no_more": "连接方式『{mode}』暂未提供。"
|
||||
},
|
||||
"bus": {
|
||||
"title": "总线初始化",
|
||||
"bas_path": "BAS 文件",
|
||||
"choose": "选择 BAS…",
|
||||
"download": "下载并初始化",
|
||||
"reinit": "重新初始化",
|
||||
"type": "总线类型",
|
||||
"type_ecat": "EtherCAT",
|
||||
"type_rtex": "RTEX",
|
||||
"type_unknown": "未知",
|
||||
"state": "初始化状态",
|
||||
"state_pending": "未完成",
|
||||
"state_running": "正在初始化…",
|
||||
"state_ok": "初始化成功",
|
||||
"state_failed": "初始化失败",
|
||||
"state_timeout": "初始化超时",
|
||||
"nodes": "节点数量",
|
||||
"axes": "轴数量",
|
||||
"downloaded": "文件已下载,控制器将延时扫描总线",
|
||||
"dialog_title": "选择 BAS 文件"
|
||||
},
|
||||
"axes": {
|
||||
"title": "轴运动",
|
||||
"axis": "轴号",
|
||||
"enable": "使能",
|
||||
"enable_on": "已使能",
|
||||
"enable_off": "未使能",
|
||||
"atype": "轴类型",
|
||||
"units": "脉冲当量",
|
||||
"speed": "速度",
|
||||
"accel": "加速度",
|
||||
"dpos": "命令位置",
|
||||
"mpos": "反馈位置",
|
||||
"dpos_mm": "命令位置 (mm)",
|
||||
"mpos_mm": "反馈位置 (mm)",
|
||||
"status": "轴状态",
|
||||
"motion": "运动状态",
|
||||
"motion_word": "运动",
|
||||
"idle": "停止",
|
||||
"moving": "运动中",
|
||||
"position_pair": "{value} ({mm} mm)",
|
||||
"enable_all": "全部使能",
|
||||
"disable_all": "全部关闭使能",
|
||||
"clear_alarm_all": "清除全部报警",
|
||||
"detail": "选中轴 {axis}: 轴类型 {atype} · 加速度 {accel} · 反馈 {mpos} ({mpos_mm} mm) · 轴状态 {status} · {home}",
|
||||
"detail_none": "选中轴 {axis}: 尚无数据(未连接或未初始化)",
|
||||
"home_status": "回零状态",
|
||||
"home_ok": "回零完成",
|
||||
"home_pending": "回零未完成",
|
||||
"home_running": "回零中",
|
||||
"jog_fwd": "正转",
|
||||
"jog_rev": "反转",
|
||||
"stop_all": "全部停止",
|
||||
"stop": "停止",
|
||||
"target": "目标位置",
|
||||
"move_abs": "绝对定位",
|
||||
"move_rel": "相对运动",
|
||||
"home_mode": "回零模式",
|
||||
"home_high": "回零高速",
|
||||
"home_low": "回零低速",
|
||||
"home_offset": "回零偏移",
|
||||
"clear_alarm": "清除报警",
|
||||
"apply": "应用参数",
|
||||
"home": "启动回零",
|
||||
"controls": "操作",
|
||||
"mm_derived_tip": "毫米值由脉冲当量换算得出,是显示值,不是标定长度。",
|
||||
"enabled_count": "已使能 {on}/{total} 轴"
|
||||
},
|
||||
"busdata": {
|
||||
"title": "总线数据交互",
|
||||
"group": "总线数据 (SDO / RTEX)",
|
||||
"ecat": "EtherCAT (SDO)",
|
||||
"rtex": "RTEX",
|
||||
"node": "节点号",
|
||||
"index": "对象字典",
|
||||
"subindex": "子编号",
|
||||
"type": "数据类型",
|
||||
"value": "数据",
|
||||
"read": "读",
|
||||
"write": "写",
|
||||
"axis": "轴号",
|
||||
"parameter": "参数地址",
|
||||
"last_result": "最近一次: {text}",
|
||||
"not_available": "当前总线不支持该操作",
|
||||
"read_ok": "读取成功: {value}",
|
||||
"write_ok": "写入成功"
|
||||
},
|
||||
"status": {
|
||||
"motion": "运动控制",
|
||||
"ready": "就绪",
|
||||
"poll_ok": "状态刷新正常",
|
||||
"poll_paused": "状态刷新已暂停",
|
||||
"poll_stalled": "状态刷新中断",
|
||||
"last_error": "最近错误",
|
||||
"none": "无",
|
||||
"refused": "操作被拒绝",
|
||||
"done": "完成",
|
||||
"failed": "失败",
|
||||
"pause": "暂停刷新",
|
||||
"resume": "继续刷新",
|
||||
"paused": "已暂停状态刷新",
|
||||
"alarm_short": "报警",
|
||||
"axes_enabled": "已使能 {on}/{total} 轴",
|
||||
"health_none": "刷新 —",
|
||||
"health_age": "刷新 {ms} ms 前",
|
||||
"health_paused": "刷新已暂停",
|
||||
"poll_every": "刷新间隔 {ms} ms"
|
||||
},
|
||||
"run": {
|
||||
"title": "运行",
|
||||
"phase_idle": "空闲",
|
||||
"phase_dry_run": "干跑中",
|
||||
"phase_running": "运行中",
|
||||
"phase_paused": "已暂停",
|
||||
"phase_done": "已完成",
|
||||
"phase_failed": "失败",
|
||||
"step_of": "第 {done}/{total} 点",
|
||||
"no_steps": "尚未生成标定点",
|
||||
"elapsed_only": "已用 {elapsed}",
|
||||
"elapsed_remaining": "已用 {elapsed} · 预计剩余 {remaining}",
|
||||
"run": "运行",
|
||||
"pause": "暂停",
|
||||
"abort": "中止",
|
||||
"abort_stopped": "已中止运行并停止全部轴。",
|
||||
"not_implemented": "运行器属于 P7 阶段,尚未实现;中止随时可用。"
|
||||
},
|
||||
"readout": {
|
||||
"title": "轴状态"
|
||||
},
|
||||
"workspace": {
|
||||
"tab_axes": "轴调试",
|
||||
"tab_sequence": "测试序列",
|
||||
"tab_sensor": "传感器",
|
||||
"tab_surface": "表面分布",
|
||||
"tab_points": "标定点",
|
||||
"not_built": "尚未实现",
|
||||
"reserved_sensor": "传感器连接、实时图像与力曲线将在此显示:设备与信号源选择、预览、以及和 Studio 类似的曲线视图(占用空间更小)。",
|
||||
"reserved_surface": "多点标定的灵敏度将在此以色图叠加在传感器三维表面上:每个标定点的灵敏度经插值着色,并给出色标与范围。",
|
||||
"reserved_points": "标定点列表与采集控制将在此显示:平台坐标、参考载荷、采样统计与灵敏度结果。"
|
||||
},
|
||||
"sequence": {
|
||||
"title": "测试序列设计",
|
||||
"profile_title": "标定点生成",
|
||||
"preview_title": "预览",
|
||||
"validation_title": "校验",
|
||||
"points_title": "标定点列表",
|
||||
"field_mode": "曲面模式",
|
||||
"field_pitch": "点距 (mm)",
|
||||
"field_margin": "边界余量 (mm)",
|
||||
"field_radius": "曲面半径 (mm)",
|
||||
"field_clear_height": "安全高度 (mm)",
|
||||
"field_approach_speed": "进给速度 (mm/s)",
|
||||
"field_press_speed": "下压速度 (mm/s)",
|
||||
"field_press_depth": "下压深度 (mm)",
|
||||
"field_dwell": "保压 (s)",
|
||||
"field_settle": "稳定窗口 (s)",
|
||||
"field_repeats": "重复次数",
|
||||
"field_order": "遍历顺序",
|
||||
"mode_flat": "平面",
|
||||
"mode_curved": "曲面",
|
||||
"order_row": "按行",
|
||||
"order_serpentine": "蛇形",
|
||||
"order_distance": "按距离",
|
||||
"preview_placeholder": "三维预览:传感器表面 · 标定点 · 遍历顺序\n(仿真阶段实现)",
|
||||
"preview_hint": "生成后可在此检查点是否落在曲面上、是否在行程内、接触能否被确认。",
|
||||
"validation_pending": "尚未生成标定点。",
|
||||
"check_on_surface": "在曲面范围内",
|
||||
"check_within_travel": "在行程内",
|
||||
"check_contact_confirmable": "接触可确认",
|
||||
"btn_generate": "生成标定点",
|
||||
"btn_simulate": "仿真",
|
||||
"btn_dry_run": "干跑(不接触)",
|
||||
"btn_save": "保存序列…",
|
||||
"btn_load": "载入序列…",
|
||||
"pending_tooltip": "该功能属于 {phase} 阶段,尚未实现。",
|
||||
"col_index": "#",
|
||||
"col_x": "X (mm)",
|
||||
"col_y": "Y (mm)",
|
||||
"col_z": "Z (mm)",
|
||||
"col_status": "状态",
|
||||
"col_sensitivity": "灵敏度(上次)",
|
||||
"col_samples": "样本",
|
||||
"col_actions": "操作",
|
||||
"not_generated": "—",
|
||||
"status_pending": "待测",
|
||||
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。"
|
||||
},
|
||||
"action": {
|
||||
"ok": "完成",
|
||||
"refused": "已拒绝",
|
||||
"failed": "失败"
|
||||
},
|
||||
"diagnostics": {
|
||||
"title": "诊断信息",
|
||||
"binding": "Qt 绑定",
|
||||
"dll_dir": "控制器库",
|
||||
"prefs": "偏好文件",
|
||||
"outputs": "输出目录",
|
||||
"bas_loaded": "BAS 已加载",
|
||||
"yes": "是",
|
||||
"no": "否"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"language": "zh_CN",
|
||||
"theme": "light",
|
||||
"window_geometry": null,
|
||||
"connection": {
|
||||
"mode": "eth",
|
||||
"ip": "192.168.0.11",
|
||||
"com_id": 0,
|
||||
"card_num": 0,
|
||||
"local_string": "0"
|
||||
},
|
||||
"bas_path": "",
|
||||
"units_per_mm": 10.0,
|
||||
"state_poll_ms": 100,
|
||||
"sdo_last": { "node": 0, "index": 24704, "subindex": 0, "type": 0 },
|
||||
"rtex_last": { "axis": 0, "parameter": 0 },
|
||||
"show_diagnostics": false,
|
||||
"safety_limits": {
|
||||
"max_travel_mm": 100.0,
|
||||
"max_descent_mm": 10.0,
|
||||
"max_press_depth_mm": 1.0,
|
||||
"follow_error_mm": 0.5,
|
||||
"contact_search_mm": 3.0
|
||||
},
|
||||
"sensor_setup": {
|
||||
"sensor_type": "box",
|
||||
"config_ref": "",
|
||||
"geometry_id": "",
|
||||
"press_axis": 2,
|
||||
"probe_tip_offset_mm": [0.0, 0.0, 0.0],
|
||||
"probe_tip_set": false,
|
||||
"sensor_to_platform": [],
|
||||
"transform_residual_mm": 0.0,
|
||||
"transform_feature_count": 0
|
||||
},
|
||||
"sequence_profile": {
|
||||
"mode": "flat",
|
||||
"pitch_mm": 2.0,
|
||||
"margin_mm": 1.0,
|
||||
"radius_mm": 0.0,
|
||||
"clear_height_mm": 5.0,
|
||||
"approach_speed_mm_s": 2.0,
|
||||
"press_speed_mm_s": 0.5,
|
||||
"press_depth_mm": 0.2,
|
||||
"dwell_s": 0.5,
|
||||
"settle_window_s": 0.5,
|
||||
"repeats": 3,
|
||||
"order": "serpentine"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
# AutoCal workflow — platform bring-up to calibration data
|
||||
|
||||
This is the operating workflow the application is built around. It is written to
|
||||
be argued with: each phase names what it needs, what it produces, why it exists,
|
||||
and what must be true before the next one starts.
|
||||
|
||||
A **gate** is a checkpoint. The app's job at a gate is to make the operator's
|
||||
answer trustworthy, not to ask "are you sure?".
|
||||
|
||||
```text
|
||||
P0 Session & safety baseline [gate]
|
||||
P1 ECAT setup [gate]
|
||||
P2 Homing + test motion [gate]
|
||||
P3 Sensor & probe setup [gate]
|
||||
P4 Coordinate setup: sensor <-> platform [gate]
|
||||
P5 Sequence design [gate]
|
||||
P6 Simulate & dry run [gate]
|
||||
P7 Run + acquire [gate]
|
||||
P8 Store raw
|
||||
P9 Post-process [gate]
|
||||
P10 Calibration export
|
||||
P11 Verify [gate]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P0 — Session & safety baseline
|
||||
|
||||
**Why:** the machine can move from P2 onward. Everything that limits that motion
|
||||
should be decided while nothing is moving.
|
||||
|
||||
**Inputs:** operator identity, machine/bench identifier, units per axis,
|
||||
`units_per_mm`.
|
||||
|
||||
**Safety limits, set here, enforced from P2:**
|
||||
|
||||
| Limit | Meaning |
|
||||
|---|---|
|
||||
| maximum travel per axis | never commanded past this, in user units |
|
||||
| maximum Z descent per step | a single approach may not exceed this |
|
||||
| press-depth ceiling | the press depth a sequence profile is allowed to request |
|
||||
| no-contact abort | if contact is not detected within the descent budget, stop the run |
|
||||
| following-error bound | how far a drive may lag before the move counts as stalled |
|
||||
|
||||
**Outputs:** a session record — who, when, which machine, and the limits in
|
||||
force. Stamped onto everything the session produces.
|
||||
|
||||
**Gate:** every limit has a value; no limit is "unset" (an unset limit is an
|
||||
unlimited machine).
|
||||
|
||||
**Currently missing from the workflow:** this phase did not exist. Without it,
|
||||
step 1 went from bus setup straight to jogging five axes.
|
||||
|
||||
---
|
||||
|
||||
## P1 — ECAT setup
|
||||
|
||||
**Why:** nothing else can be trusted until the controller has actually found its
|
||||
slaves. A missing drive discovered at P7 wastes the whole run.
|
||||
|
||||
**Inputs:** controller address, the matching `.bas` program
|
||||
(`ECAT.bas` / `RTEX.bas`).
|
||||
|
||||
**Steps:** connect → download and run the BAS program → watch
|
||||
`Bus_InitStatus` to completion or timeout → read `BUS_TYPE`,
|
||||
`Bus_TotalAxisnum`, and the node count.
|
||||
|
||||
**Outputs:** bus type, node count, axis count, init status.
|
||||
|
||||
**Gate:** init status is success, **and** the node/axis counts match what this
|
||||
bench is expected to have. A mismatch is a finding, not a warning.
|
||||
|
||||
**Existing code:** `MotionService.download_bas` / `poll` already implement the
|
||||
state machine and the 25 s timeout.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Homing + test motion
|
||||
|
||||
**Why:** a drive that homes and jogs cleanly at low speed is the precondition for
|
||||
letting a sequence drive it.
|
||||
|
||||
**Inputs:** per-axis homing mode, speeds, offset; a test-motion profile.
|
||||
|
||||
**Steps:** enable → home each axis → low-speed jog each axis in both directions
|
||||
→ confirm movement and return.
|
||||
|
||||
**Outputs:** per-axis pass/fail with the evidence (distance moved, following
|
||||
error, alarm state).
|
||||
|
||||
**Gate — the test motion needs a criterion.** This was missing. Proposed:
|
||||
|
||||
- the axis moved at least a configured minimum distance;
|
||||
- it returned within a tolerance of its start position;
|
||||
- following error stayed under the bound from P0;
|
||||
- no alarm before, during or after.
|
||||
|
||||
**Existing code:** the axis panel does the jog/homing; the criterion is new.
|
||||
|
||||
---
|
||||
|
||||
## P3 — Sensor & probe setup
|
||||
|
||||
**Why:** which sensor is under test, what touches it, and how far the touching
|
||||
point is from the axis reference. Contact depth is meaningless without the tool.
|
||||
|
||||
**Inputs:**
|
||||
|
||||
- sensor type and config reference (the same `config/sensors/<type>/sensor.json`
|
||||
the studio uses);
|
||||
- the sensor's 3D scene package and its `geometry_id`;
|
||||
- the **probe tip** (tool): which axis presses, and the tip offset from the
|
||||
platform's reference point;
|
||||
- the mounting pose: how the sensor is fixtured on the platform.
|
||||
|
||||
**Outputs:** a saved setup block.
|
||||
|
||||
**Gate:**
|
||||
|
||||
- the calibration's `geometry_id` matches the scene's, or the app says which is
|
||||
which rather than colouring the wrong geometry;
|
||||
- the probe tip offset is set (a zero offset that was never entered is not the
|
||||
same as a probe at the origin).
|
||||
|
||||
---
|
||||
|
||||
## P4 — Coordinate setup: sensor ↔ platform
|
||||
|
||||
**Why this is the load-bearing phase.** "Which points on the sensor should be
|
||||
tested" is a question in the *sensor's* frame. The platform moves in *platform*
|
||||
coordinates. Without the transform between them:
|
||||
|
||||
- a generated grid cannot be checked against the sensor's surface;
|
||||
- a measured point cannot be placed on that surface for the sensitivity map;
|
||||
- a calibration cannot be re-applied to a re-fixtured sensor.
|
||||
|
||||
**Inputs:** the probe tip from P3, and a set of **at least four non-collinear
|
||||
known features** on the sensor or its fixture, probed in platform coordinates.
|
||||
|
||||
**Steps:** probe each feature → solve for the rigid transform (rotation and
|
||||
translation; scale if the units are not yet trusted) → report the residual.
|
||||
|
||||
**Outputs:** a 4×4 transform from sensor frame to platform frame, with its
|
||||
residual and the feature set that produced it.
|
||||
|
||||
**Gate:** residual under a configured tolerance, and at least four non-collinear
|
||||
points (four collinear points determine a rotation about that line, not a
|
||||
transform — the app should refuse that set rather than return a number).
|
||||
|
||||
**Not built in this step:** the solver. Only the workflow step, the stored
|
||||
transform, and the place its residual is displayed.
|
||||
|
||||
---
|
||||
|
||||
## P5 — Sequence design
|
||||
|
||||
**Why:** this is the decision the whole run exists to carry out.
|
||||
|
||||
**Inputs (the profile, persisted and reusable):**
|
||||
|
||||
| Parameter | Meaning |
|
||||
|---|---|
|
||||
| mode | flat surface or curved (a cylindrical/spherical sensor area) |
|
||||
| grid pitch | spacing between points, in mm |
|
||||
| margin / radius | how far in from the active edge the grid stays; radius for curved |
|
||||
| clear height | standoff above the surface to travel at |
|
||||
| approach speed | descent speed before contact |
|
||||
| press speed | descent speed after contact |
|
||||
| press depth | how far past contact to press |
|
||||
| dwell | how long to hold the press |
|
||||
| settle window | how long to wait before the measurement is considered settled |
|
||||
| repeats | how many times each point is measured |
|
||||
| point order | row-wise, serpentine, or by distance |
|
||||
|
||||
**Steps:** generate the grid → validate **every point** three ways:
|
||||
|
||||
1. **on the sensor** — its sensor-frame position falls on the modelled surface,
|
||||
not off the edge and not inside a hole;
|
||||
2. **within travel** — the platform can reach it inside the P0 limits;
|
||||
3. **confirmable** — the sensor's sensing area is under the point, so a contact
|
||||
there can actually be seen by the sensor.
|
||||
|
||||
**Outputs:** a point list with a sensor-frame position and a platform-frame
|
||||
target for each, plus a validation verdict per point.
|
||||
|
||||
**Gate:** no point may be unusable without being marked as such. A run may
|
||||
proceed with skipped points if the operator says so explicitly; it may not
|
||||
proceed with unvalidated ones.
|
||||
|
||||
**The interface:** three zones in the Sequence tab — profile parameters, a 3D
|
||||
preview of the surface with the points and their visiting order, and the
|
||||
validation list. See `apps/autocal/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## P6 — Simulate & dry run
|
||||
|
||||
**Why:** a wrong Z or a wrong grid is much cheaper to find now.
|
||||
|
||||
**Simulate:** play the full toolpath in the 3D view — a moving marker with a
|
||||
phase label (standoff → descend → press → dwell → retract), the estimated
|
||||
duration, and per-point reachability. No motion.
|
||||
|
||||
**Dry run:** execute the real XY path, descending only to the clear height,
|
||||
never pressing. This proves the geometry, the ordering, and the timing.
|
||||
|
||||
**Outputs:** estimated duration, per-point reachability, and (dry run) the
|
||||
measured time per point.
|
||||
|
||||
**Gate:** the operator has seen the path and a dry run has completed without a
|
||||
guard violation.
|
||||
|
||||
---
|
||||
|
||||
## P7 — Run + acquire
|
||||
|
||||
**Why:** the only phase that touches the sensor.
|
||||
|
||||
**Per step, in order:**
|
||||
|
||||
1. move to the point's XY at clear height;
|
||||
2. descend at approach speed;
|
||||
3. **detect contact by position** — the axis stalls / following error rises
|
||||
while descending;
|
||||
4. **confirm with the sensor under test** — its reading must change when the
|
||||
probe presses. This is the second opinion: position says "I stopped moving",
|
||||
the sensor says "something real is happening";
|
||||
5. press to the configured depth at press speed;
|
||||
6. dwell, then acquire the settle window;
|
||||
7. retract, move to the next point.
|
||||
|
||||
**Failure semantics (per point, because resume needs them):**
|
||||
|
||||
| Outcome | Run behaviour |
|
||||
|---|---|
|
||||
| contact confirmed | record the point as measured |
|
||||
| contact detected, sensor shows no change | mark **suspect**, continue; a suspect point is visible, not silently averaged in |
|
||||
| no contact within the descent budget | **abort** the run (P0 limit) and leave the point unfinished |
|
||||
| guard violation | abort the run, name the step and the limit |
|
||||
|
||||
**Writes are incremental**: each point is written as it completes, with its
|
||||
status, so an interrupted run resumes at the first unfinished point and partial
|
||||
data stays valid.
|
||||
|
||||
**Existing code:** the motion layer has the guards and the poller; the step
|
||||
machine, the contact detection and the acquisition binding are new.
|
||||
|
||||
---
|
||||
|
||||
## P8 — Store raw
|
||||
|
||||
**Why:** post-processing will change. The raw record must not.
|
||||
|
||||
**Contents:**
|
||||
|
||||
- the sequence profile and the instantiated point list (positions and order);
|
||||
- per point: the position trace, the raw sensor series, the settle window used,
|
||||
timings, the contact position, and the status;
|
||||
- the setup: sensor identity, geometry id, probe tip, the P4 transform and its
|
||||
residual;
|
||||
- the session record and app/version stamps from P0.
|
||||
|
||||
Nothing derived is authoritative. The requirement that makes this reproducible:
|
||||
**a dataset can be regenerated from the profile, the setup and the raw series**,
|
||||
with no dependency on a previously computed result.
|
||||
|
||||
---
|
||||
|
||||
## P9 — Post-process
|
||||
|
||||
**Why:** turning samples into a number is a decision, and it has to be visible.
|
||||
|
||||
**Steps:** choose the settle window per point → stability check (is the point
|
||||
actually settled, or drifting?) → sensitivity per point per axis → outlier
|
||||
detection → residual per point → fit the surface across points.
|
||||
|
||||
**Outputs:** per-point sensitivity with a quality verdict, and a surface field
|
||||
over the sensor's geometry ready for the 3D colour map.
|
||||
|
||||
**Gate:** every point is either valid or explicitly excluded, with a reason.
|
||||
A point with too few samples or a drifting window is not a coefficient.
|
||||
|
||||
---
|
||||
|
||||
## P10 — Calibration export
|
||||
|
||||
**Per-point results and the fitted surface, explicitly linked.** They are one
|
||||
calibration; exporting them as unrelated artefacts is how a map ends up in a
|
||||
report that does not match the table beside it.
|
||||
|
||||
---
|
||||
|
||||
## P11 — Verify
|
||||
|
||||
**Why this exists:** P9 and P10 produce numbers. Nothing yet checks they are
|
||||
right. "Post process → calibration data" had no acceptance test.
|
||||
|
||||
**Steps:** apply the calibration → re-measure a small subset of the points →
|
||||
compare the measured sensitivity against what the calibration predicts →
|
||||
report the discrepancy per point.
|
||||
|
||||
**Gate:** the discrepancy is within tolerance, or the verification is recorded
|
||||
as failed. A calibration that was never verified is labelled as such wherever it
|
||||
is used.
|
||||
|
||||
---
|
||||
|
||||
## Gaps this document closes
|
||||
|
||||
| # | Gap | Phase |
|
||||
|---|---|---|
|
||||
| 1 | no limits before motion | P0 |
|
||||
| 2 | no session identity (who/when/which machine) | P0 |
|
||||
| 3 | the sensor↔platform transform was absent | P4 |
|
||||
| 4 | the probe tip (tool) was never defined | P3 |
|
||||
| 5 | the test motion had no pass/fail criterion | P2 |
|
||||
| 6 | grid validity was not checked per point | P5 |
|
||||
| 7 | the settle window and stability criterion were undefined | P9 |
|
||||
| 8 | a point's failure had no defined behaviour | P7 |
|
||||
| 9 | the sequence was transient, not a saved, versioned artefact | P8 |
|
||||
| 10 | no verification pass | P11 |
|
||||
|
||||
## Data flow
|
||||
|
||||
```text
|
||||
P0 limits ─┬───────────────────────────────> enforced at P2, P6, P7
|
||||
P1 bus ────┴─> axis availability ─────────> P2
|
||||
P3 sensor setup ─┬─ geometry id ──────────> P5 validation, P10 surface
|
||||
└─ probe tip ────────────> P4, P7 press depth
|
||||
P4 transform ─────> sensor <-> platform ──> P5 point generation, P9 map
|
||||
(residual) P11 re-fixture check
|
||||
P5 profile + points ──> P6 simulate/dry run ──> P7 run
|
||||
P7 raw series ─────> P8 dataset ──> P9 post-process ──> P10 export
|
||||
└─> P11 verify
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "orisys-autocal"
|
||||
version = "0.1.0"
|
||||
description = "Orisys AutoCal — internal 5-axis calibration platform control"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Orisys-Tech" }]
|
||||
dependencies = [
|
||||
# PySide6 is the SQL-free, LGPLv3 Qt binding. It is deliberate, not a
|
||||
# preference: this is an internal tool, and PyQt5's free option is GPLv3
|
||||
# (see README.md and src/orisys_autocal/qtcompat.py).
|
||||
"PySide6>=6.6",
|
||||
"orisys-app-shared>=0.1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
orisys-autocal = "orisys_autocal.main:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = { "" = "src" }
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launch Orisys AutoCal from the source tree."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
SRC = ROOT / "src"
|
||||
SHARED_SRC = ROOT.parents[1] / "shared" / "src"
|
||||
for path in (SRC, SHARED_SRC):
|
||||
if path.is_dir() and str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
# Resolve config/outputs to this app, and set the app id before any shared
|
||||
# import can read it (see orisys_app_shared.paths).
|
||||
os.environ.setdefault("ORISYS_APP_ID", "autocal")
|
||||
os.chdir(ROOT)
|
||||
|
||||
from orisys_autocal.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Orisys AutoCal — internal 5-axis calibration platform control app."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
REQUIRES_SHARED_API = "0.1"
|
||||
|
||||
#: Window / QApplication name.
|
||||
APP_DISPLAY_NAME = "Orisys AutoCal"
|
||||
|
||||
#: The Qt binding this app uses. It is PySide6 and only PySide6: LGPLv3, so an
|
||||
#: internal closed-source tool needs no licence purchase, and it is the
|
||||
#: maintained Qt-for-Python binding (PySide2 is discontinued upstream).
|
||||
QT_BINDING = "PySide6"
|
||||
@@ -0,0 +1,126 @@
|
||||
"""AutoCal entrypoint: logging, locale, theme, then the window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# The app id must be set before anything imports orisys_app_shared.paths, because
|
||||
# the config/output roots are resolved on first use and cached by callers.
|
||||
os.environ.setdefault("ORISYS_APP_ID", "autocal")
|
||||
|
||||
|
||||
def _force_utf8_streams() -> None:
|
||||
"""Make stdout/stderr UTF-8 where the platform lets us.
|
||||
|
||||
The controller's error strings and every panel label are Chinese, and a
|
||||
Windows console defaults to a legacy code page that cannot encode them.
|
||||
A failed reconfigure is not fatal — the log file is UTF-8 regardless.
|
||||
"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if reconfigure is None:
|
||||
continue
|
||||
try:
|
||||
reconfigure(encoding="utf-8", errors="replace")
|
||||
except (ValueError, OSError): # pragma: no cover - stream already detached
|
||||
pass
|
||||
|
||||
|
||||
def _pyside_version() -> str:
|
||||
import PySide6
|
||||
|
||||
return getattr(PySide6, "__version__", "unknown")
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(prog="orisys-autocal", description=__doc__)
|
||||
parser.add_argument(
|
||||
"--demo",
|
||||
action="store_true",
|
||||
help="start on the simulated platform (no hardware, no controller DLL)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
default="INFO",
|
||||
choices=("DEBUG", "INFO", "WARNING", "ERROR"),
|
||||
help="console and file log level",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
|
||||
_force_utf8_streams()
|
||||
|
||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||
from orisys_app_shared.compat import check_shared_compat
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.logging import init_logging, install_exception_hooks
|
||||
from orisys_app_shared.theme import build_qss
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from . import REQUIRES_SHARED_API, __version__ as app_version
|
||||
from .motion import ConnectionSpec, DemoTransport
|
||||
from .preferences import load_preferences
|
||||
from .ui.window import AutoCalMainWindow
|
||||
|
||||
init_logging("autocal", level=getattr(logging, args.log_level))
|
||||
install_exception_hooks()
|
||||
check_shared_compat(REQUIRES_SHARED_API)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
prefs = load_preferences()
|
||||
language = str(prefs.get("language") or "zh_CN")
|
||||
theme = str(prefs.get("theme") or "dark")
|
||||
set_locale(language)
|
||||
|
||||
app = QApplication(sys.argv[:1])
|
||||
app.setApplicationName("Orisys AutoCal")
|
||||
app.setApplicationVersion(app_version)
|
||||
app.setStyleSheet(build_qss(theme))
|
||||
|
||||
transport = None
|
||||
if args.demo:
|
||||
transport = DemoTransport()
|
||||
connection = dict(prefs.get("connection") or {})
|
||||
connection["mode"] = "demo"
|
||||
prefs["connection"] = connection
|
||||
|
||||
window = AutoCalMainWindow(prefs=prefs)
|
||||
if transport is not None:
|
||||
window.poller.service._transport = transport # noqa: SLF001 - deliberate swap
|
||||
window.show()
|
||||
|
||||
logger.info(
|
||||
"AutoCal started (version=%s shared=%s api=%s binding=PySide6 %s "
|
||||
"theme=%s language=%s demo=%s)",
|
||||
app_version,
|
||||
shared_version,
|
||||
API_VERSION,
|
||||
_pyside_version(),
|
||||
theme,
|
||||
language,
|
||||
args.demo,
|
||||
)
|
||||
if args.demo:
|
||||
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
|
||||
|
||||
code = int(app.exec())
|
||||
|
||||
# Leave the process immediately. The ZMotion runtime keeps native threads of
|
||||
# its own, so a normal interpreter shutdown can hang after the window closes;
|
||||
# everything durable was already flushed by closeEvent and the log handlers.
|
||||
logging.shutdown()
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.flush()
|
||||
except Exception: # noqa: BLE001 - a closed stream must not block exit
|
||||
pass
|
||||
os._exit(code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Platform control: models, guards, polling, and the Qt worker bridge."""
|
||||
|
||||
from .models import (
|
||||
INIT_FAILED,
|
||||
INIT_OK,
|
||||
INIT_RUNNING,
|
||||
INIT_TIMEOUT_S,
|
||||
AxisParams,
|
||||
AxisState,
|
||||
AxisStatusBits,
|
||||
BusStatus,
|
||||
BusType,
|
||||
ConnectionMode,
|
||||
ConnectionSpec,
|
||||
MotionResult,
|
||||
Snapshot,
|
||||
)
|
||||
from .poller import MotionPoller
|
||||
from .service import PLATFORM_AXES, MotionService
|
||||
from .transport import DemoTransport, NativeTransport, ZmcTransport
|
||||
from .units import (
|
||||
DEFAULT_ACCEL,
|
||||
DEFAULT_SPEED,
|
||||
DEFAULT_UNITS,
|
||||
PULSES_PER_MM,
|
||||
mm_to_user_units,
|
||||
safe_float,
|
||||
user_units_to_mm,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_ACCEL",
|
||||
"DEFAULT_SPEED",
|
||||
"DEFAULT_UNITS",
|
||||
"INIT_FAILED",
|
||||
"INIT_OK",
|
||||
"INIT_RUNNING",
|
||||
"INIT_TIMEOUT_S",
|
||||
"PLATFORM_AXES",
|
||||
"PULSES_PER_MM",
|
||||
"AxisParams",
|
||||
"AxisState",
|
||||
"AxisStatusBits",
|
||||
"BusStatus",
|
||||
"BusType",
|
||||
"ConnectionMode",
|
||||
"ConnectionSpec",
|
||||
"DemoTransport",
|
||||
"MotionPoller",
|
||||
"MotionResult",
|
||||
"MotionService",
|
||||
"NativeTransport",
|
||||
"Snapshot",
|
||||
"ZmcTransport",
|
||||
"mm_to_user_units",
|
||||
"safe_float",
|
||||
"user_units_to_mm",
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Translate controller return codes into something an operator can act on.
|
||||
|
||||
Only the codes the vendor actually documents are given a meaning here. The
|
||||
`sdk header <../../resources/zmotion/x64/zauxdll2.h>`_ defines the auxiliary
|
||||
error block at offset 30000:
|
||||
|
||||
#define ERR_NOACK ERR_AUX_OFFSET //无应答
|
||||
#define ERR_ACKERROR (ERR_AUX_OFFSET+1) //应答错误
|
||||
#define ERR_AUX_PARAERR (ERR_AUX_OFFSET+2) //参数错误
|
||||
#define ERR_AUX_NOTSUPPORT (ERR_AUX_OFFSET+3) //不支持
|
||||
#define ERR_AUX_FILE_ERROR (ERR_AUX_OFFSET+4) //参数错误
|
||||
#define ERR_AUX_OS_ERR (ERR_AUX_OFFSET+5) //
|
||||
|
||||
Everything else is the controller's own number. :func:`describe` deliberately
|
||||
does **not** invent a meaning for those: it names the number and points at
|
||||
ZDevelop, which is where the firmware's code table lives. A guessed message on
|
||||
a motion controller is worse than "unknown".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Auxiliary (host-side) error block offset, from ``zauxdll2.h``.
|
||||
ERR_AUX_OFFSET = 30000
|
||||
|
||||
#: Meaning per code, as ``(english, chinese)``.
|
||||
MESSAGES: dict[int, tuple[str, str]] = {
|
||||
ERR_AUX_OFFSET: ("No response from the controller", "控制器无应答"),
|
||||
ERR_AUX_OFFSET + 1: ("The controller rejected the command", "应答错误"),
|
||||
ERR_AUX_OFFSET + 2: ("Wrong parameter", "参数错误"),
|
||||
ERR_AUX_OFFSET + 3: ("Not supported by this model or firmware", "不支持该功能"),
|
||||
ERR_AUX_OFFSET + 4: ("File error", "文件错误"),
|
||||
ERR_AUX_OFFSET + 5: ("Operating-system error in the library", "库内操作系统错误"),
|
||||
}
|
||||
|
||||
#: The last-resort advice appended to every unknown code.
|
||||
UNKNOWN_HINT = (
|
||||
"Look this number up in ZDevelop's error table (控制器错误码表), "
|
||||
"or run the same command from ZDevelop to see the decoded fault."
|
||||
)
|
||||
|
||||
|
||||
def describe(code: int, *, language: str = "zh_CN") -> str:
|
||||
"""A human-readable message for a controller return code.
|
||||
|
||||
Args:
|
||||
code: the value returned by a ``ZAux_*`` call.
|
||||
language: ``zh_CN`` for Chinese, anything else for English.
|
||||
|
||||
Returns:
|
||||
``"0"`` is reported as success; known codes get a translated message;
|
||||
unknown codes keep their number visible rather than being guessed at.
|
||||
"""
|
||||
code = int(code)
|
||||
if code == 0:
|
||||
return "成功" if language == "zh_CN" else "OK"
|
||||
|
||||
index = 1 if language == "zh_CN" else 0
|
||||
known = MESSAGES.get(code)
|
||||
if known is None:
|
||||
# An unknown code that still resolves to an auxiliary slot is more
|
||||
# useful than a bare integer, but only when it is documented.
|
||||
logger.debug("Undocumented ZMotion return code %s", code)
|
||||
prefix = "错误码" if language == "zh_CN" else "Error code"
|
||||
return f"{prefix} {code}"
|
||||
return known[index]
|
||||
|
||||
|
||||
def describe_with_hint(code: int, *, language: str = "zh_CN") -> str:
|
||||
"""As :func:`describe`, plus the ZDevelop pointer for unknown codes."""
|
||||
message = describe(code, language=language)
|
||||
if int(code) == 0 or int(code) in MESSAGES:
|
||||
return message
|
||||
return f"{message} — {UNKNOWN_HINT}"
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Motion-control domain model.
|
||||
|
||||
Plain frozen dataclasses and no Qt: the service layer and the tests both use
|
||||
these, and neither needs a GUI to do it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
#: Which physical transport the controller is reached over.
|
||||
ConnectionMode = Literal["eth", "com", "pci", "local", "demo"]
|
||||
|
||||
#: Bus families the ``.bas`` programs report through ``BUS_TYPE``.
|
||||
BusType = Literal["unknown", "ecat", "rtex"]
|
||||
|
||||
#: The vendor reference's window between "BAS downloaded" and a final status.
|
||||
INIT_TIMEOUT_S = 25.0
|
||||
|
||||
#: ``Bus_InitStatus`` values, from ``ECAT.bas``.
|
||||
INIT_RUNNING = -1
|
||||
INIT_FAILED = 0
|
||||
INIT_OK = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConnectionSpec:
|
||||
"""How to reach the controller."""
|
||||
|
||||
mode: ConnectionMode
|
||||
ip: str = ""
|
||||
com_id: int = 0
|
||||
card_num: int = 0
|
||||
local_string: str = "0"
|
||||
|
||||
def describe(self) -> str:
|
||||
if self.mode == "eth":
|
||||
return self.ip or "(no IP)"
|
||||
if self.mode == "com":
|
||||
return f"COM{self.com_id}"
|
||||
if self.mode == "pci":
|
||||
return f"PCI card {self.card_num}"
|
||||
if self.mode == "local":
|
||||
return f"local {self.local_string}"
|
||||
return "demo (no hardware)"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AxisParams:
|
||||
"""Per-axis parameters as entered by the operator."""
|
||||
|
||||
units: float = 10.0
|
||||
speed: float = 1000.0
|
||||
accel: float = 10000.0
|
||||
creep: float = 10.0
|
||||
home_mode: int = 0
|
||||
home_high: float = 50.0
|
||||
home_low: float = 10.0
|
||||
home_offset: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AxisStatusBits:
|
||||
"""Decoded ``ZAux_Direct_GetAxisStatus`` word.
|
||||
|
||||
Only the bits the AutoCal panels display are named. Bit 6 is the one the
|
||||
vendor reference tests (``status & 64``) to decide that homing is running.
|
||||
"""
|
||||
|
||||
raw: int = 0
|
||||
|
||||
@property
|
||||
def home_in_progress(self) -> bool:
|
||||
"""Bit 6 — set while the drive is executing a homing move."""
|
||||
return bool(self.raw & 64)
|
||||
|
||||
@property
|
||||
def moving(self) -> bool:
|
||||
"""Bit 1 — the axis is producing motion."""
|
||||
return bool(self.raw & 2)
|
||||
|
||||
@property
|
||||
def alarm(self) -> bool:
|
||||
"""Bit 9 — the drive is reporting a fault."""
|
||||
return bool(self.raw & 512)
|
||||
|
||||
def active_bits(self) -> list[int]:
|
||||
"""Every set bit position, for the status column's tooltip."""
|
||||
return [bit for bit in range(32) if self.raw & (1 << bit)]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AxisState:
|
||||
"""One axis as read back from the controller."""
|
||||
|
||||
axis: int
|
||||
enabled: bool = False
|
||||
atype: int = 0
|
||||
units: float = 0.0
|
||||
speed: float = 0.0
|
||||
accel: float = 0.0
|
||||
dpos: float = 0.0
|
||||
mpos: float = 0.0
|
||||
status: AxisStatusBits = field(default_factory=AxisStatusBits)
|
||||
idle: bool = True
|
||||
home_status: int = 0
|
||||
read_ok: bool = True
|
||||
error: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BusStatus:
|
||||
"""Bus-level state, from the ``.bas`` program's globals."""
|
||||
|
||||
bus_type: BusType = "unknown"
|
||||
init_status: int = INIT_RUNNING
|
||||
node_count: int = 0
|
||||
total_axes: int = 0
|
||||
bas_path: str = ""
|
||||
bas_loaded: bool = False
|
||||
init_started_at: float | None = None
|
||||
message: str = ""
|
||||
|
||||
@property
|
||||
def initialized(self) -> bool:
|
||||
return self.init_status == INIT_OK
|
||||
|
||||
@property
|
||||
def supports_sdo(self) -> bool:
|
||||
return self.bus_type == "ecat"
|
||||
|
||||
@property
|
||||
def supports_rtex(self) -> bool:
|
||||
return self.bus_type == "rtex"
|
||||
|
||||
def timed_out(self, now: float) -> bool:
|
||||
"""True while a BAS run started long ago has still not reported."""
|
||||
if self.init_status != INIT_RUNNING or self.init_started_at is None:
|
||||
return False
|
||||
return (now - self.init_started_at) > INIT_TIMEOUT_S
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Snapshot:
|
||||
"""Everything the UI needs for one refresh."""
|
||||
|
||||
connected: bool = False
|
||||
spec: ConnectionSpec | None = None
|
||||
bus: BusStatus = field(default_factory=BusStatus)
|
||||
axes: tuple[AxisState, ...] = ()
|
||||
error: str = ""
|
||||
|
||||
def axis(self, index: int) -> AxisState | None:
|
||||
for state in self.axes:
|
||||
if state.axis == index:
|
||||
return state
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MotionResult:
|
||||
"""Outcome of a command, with a reason when it was refused or failed."""
|
||||
|
||||
ok: bool
|
||||
code: int = 0
|
||||
message: str = ""
|
||||
|
||||
@classmethod
|
||||
def success(cls, message: str = "") -> "MotionResult":
|
||||
return cls(ok=True, code=0, message=message)
|
||||
|
||||
@classmethod
|
||||
def refused(cls, message: str) -> "MotionResult":
|
||||
"""The command was not sent: a guard rejected it before any motion."""
|
||||
return cls(ok=False, code=0, message=message)
|
||||
|
||||
@classmethod
|
||||
def failed(cls, code: int, message: str) -> "MotionResult":
|
||||
return cls(ok=False, code=int(code), message=message)
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Owns the controller connection on a worker thread.
|
||||
|
||||
Why a thread and not a ``QTimer`` in the GUI thread: ``ZAux_BasDown`` (a ROM
|
||||
write) and a bus scan block for seconds, and the bus-init sequence blocks for
|
||||
three; a synchronous call on the GUI thread freezes the window for that whole
|
||||
time. The Qt signal boundary here is the *only* way the UI reaches the
|
||||
controller, which also means there is exactly one thread making DLL calls —
|
||||
the ZMotion handle is not safe to share.
|
||||
|
||||
Contract:
|
||||
|
||||
* Creating the poller touches no hardware and loads no DLL.
|
||||
* :meth:`MotionPoller.submit` queues a service call; the result comes back on
|
||||
``commandFinished``/``commandFailed``, and commands run in submission order.
|
||||
* Only one service call is in flight at once, because the worker is a single
|
||||
thread and each due step performs one control action.
|
||||
* A call that raises is reported, not fatal: the timer keeps running and the
|
||||
next snapshot still arrives.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from PySide6.QtCore import (
|
||||
QMetaObject,
|
||||
QObject,
|
||||
Qt,
|
||||
QThread,
|
||||
QTimer,
|
||||
Signal,
|
||||
Slot,
|
||||
)
|
||||
|
||||
from .models import Snapshot
|
||||
from .service import PLATFORM_AXES, MotionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Default UI refresh interval, matching the reference program's 100 ms timer.
|
||||
DEFAULT_INTERVAL_MS = 100
|
||||
|
||||
|
||||
class _Worker(QObject):
|
||||
"""Lives on the poller thread. Every controller call happens here."""
|
||||
|
||||
snapshotReady = Signal(object)
|
||||
commandFinished = Signal(int, object)
|
||||
commandFailed = Signal(int, str)
|
||||
connected = Signal(object)
|
||||
disconnected = Signal()
|
||||
|
||||
def __init__(self, service: MotionService, axes: Iterable[int] | None) -> None:
|
||||
super().__init__()
|
||||
self._service = service
|
||||
self._axes = list(axes) if axes is not None else None
|
||||
# Both are created in ``warm_up``, which runs on the worker thread: a
|
||||
# QTimer must be created, started and stopped from the thread that owns
|
||||
# it, and this object is constructed on the GUI thread.
|
||||
self._timer: QTimer | None = None
|
||||
self._timer_loop: QTimer | None = None
|
||||
self._interval_ms = DEFAULT_INTERVAL_MS
|
||||
self._pending: list[tuple[int, Callable[[], Any]]] = []
|
||||
self._counter = 0
|
||||
self._last_snapshot_at = 0.0
|
||||
|
||||
# ------------------------------------------------------------ thread setup
|
||||
|
||||
@Slot()
|
||||
def warm_up(self) -> None:
|
||||
"""Create the timers and start polling.
|
||||
|
||||
Invoked on the worker thread, once, at thread start-up — and taking no
|
||||
arguments precisely so it can be, because marshalling an argument across
|
||||
the meta-object system differs between the Qt bindings.
|
||||
"""
|
||||
if self._timer is None:
|
||||
self._timer = QTimer(self)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
|
||||
# A zero-interval timer that always stays active. Its only job is to
|
||||
# keep the thread's event loop turning (``QThread.exec`` returns as
|
||||
# soon as no events are left, even though the thread stays alive), so
|
||||
# a queued ``submit`` is delivered even while polling is stopped.
|
||||
self._timer_loop = QTimer(self)
|
||||
self._timer_loop.timeout.connect(lambda: None)
|
||||
self._timer_loop.start(0)
|
||||
|
||||
self._timer.setInterval(self._interval_ms)
|
||||
if not self._timer.isActive():
|
||||
self._timer.start()
|
||||
|
||||
def start_polling(self, interval_ms: int = DEFAULT_INTERVAL_MS) -> None:
|
||||
"""Start (or retune) the poll timer. Must run on the worker thread."""
|
||||
self._interval_ms = max(20, int(interval_ms))
|
||||
if self._timer is None:
|
||||
self.warm_up()
|
||||
assert self._timer is not None
|
||||
self._timer.setInterval(self._interval_ms)
|
||||
if not self._timer.isActive():
|
||||
self._timer.start()
|
||||
|
||||
@Slot()
|
||||
def stop_polling(self) -> None:
|
||||
if self._timer is not None:
|
||||
self._timer.stop()
|
||||
if self._timer_loop is not None:
|
||||
self._timer_loop.stop()
|
||||
|
||||
@Slot()
|
||||
def cleanup(self) -> None:
|
||||
"""Destroy the timers on the thread that owns them.
|
||||
|
||||
A QTimer stopped or deleted from another thread warns ("Timers cannot be
|
||||
stopped from another thread") and leaves the timestamp bookkeeping in an
|
||||
undefined state. Since these are parented to the worker, they would
|
||||
otherwise be destroyed with it, from whichever thread drops the last
|
||||
reference to the worker.
|
||||
"""
|
||||
for timer in (self._timer, self._timer_loop):
|
||||
if timer is not None:
|
||||
timer.stop()
|
||||
timer.deleteLater()
|
||||
self._timer = None
|
||||
self._timer_loop = None
|
||||
|
||||
def set_axes(self, axes: Iterable[int]) -> None:
|
||||
self._axes = list(axes)
|
||||
|
||||
def set_language(self, language: str) -> None:
|
||||
self._service.set_language(language)
|
||||
|
||||
# ------------------------------------------------------------------ queue
|
||||
|
||||
def submit(self, action: Callable[[MotionService], Any]) -> int:
|
||||
"""Queue ``action``; returns the id its result will be reported under.
|
||||
|
||||
Queuing is thread-safe (list append under the GIL); the *work* is not
|
||||
done here. ``invokeMethod`` with a queued connection is what runs
|
||||
:meth:`_tick` on the worker thread, so no controller call ever happens
|
||||
on the caller's thread — which matters twice over, because the DLL is
|
||||
single-threaded and a blocking call would freeze the window.
|
||||
"""
|
||||
self._counter += 1
|
||||
token = self._counter
|
||||
self._pending.append((token, action))
|
||||
QMetaObject.invokeMethod(self, "_tick", Qt.ConnectionType.QueuedConnection)
|
||||
return token
|
||||
|
||||
@Slot()
|
||||
def _tick(self) -> None:
|
||||
"""One poll cycle: run at most one queued command, then publish state.
|
||||
|
||||
One control action per step, then a snapshot. Draining the whole queue
|
||||
here would make a slow BAS download delay every later command with no
|
||||
feedback in between.
|
||||
|
||||
Qt looks this slot up by name (see ``submit``), so it keeps both the
|
||||
decorator and the leading underscore.
|
||||
"""
|
||||
if self._pending:
|
||||
token, action = self._pending.pop(0)
|
||||
try:
|
||||
result = action(self._service)
|
||||
except Exception as exc: # noqa: BLE001 - report, never kill the thread
|
||||
logger.exception("Controller command %s failed", token)
|
||||
self.commandFailed.emit(token, self._describe(exc))
|
||||
else:
|
||||
self.commandFinished.emit(token, result)
|
||||
|
||||
self._emit_snapshot()
|
||||
|
||||
def _emit_snapshot(self) -> None:
|
||||
try:
|
||||
snapshot = self._service.poll(self._axes)
|
||||
except Exception as exc: # noqa: BLE001 - a poll must never stop the timer
|
||||
logger.exception("Polling the controller failed")
|
||||
snapshot = Snapshot(
|
||||
connected=self._service.connected,
|
||||
spec=self._service.spec,
|
||||
bus=self._service.bus,
|
||||
error=self._describe(exc),
|
||||
)
|
||||
self.snapshotReady.emit(snapshot)
|
||||
|
||||
def _describe(self, exc: Exception) -> str:
|
||||
return self._service.describe_error(exc)
|
||||
|
||||
|
||||
class MotionPoller(QObject):
|
||||
"""GUI-thread facade over the worker."""
|
||||
|
||||
snapshotReady = Signal(object)
|
||||
commandFinished = Signal(int, object)
|
||||
commandFailed = Signal(int, str)
|
||||
connectionChanged = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: MotionService | None = None,
|
||||
*,
|
||||
axes: Iterable[int] | None = None,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.service = service or MotionService()
|
||||
self._axes = list(axes) if axes is not None else list(
|
||||
range(self.service.axis_count or PLATFORM_AXES)
|
||||
)
|
||||
self._thread = QThread()
|
||||
self._worker = _Worker(self.service, self._axes)
|
||||
self._worker.moveToThread(self._thread)
|
||||
self._worker.snapshotReady.connect(self.snapshotReady)
|
||||
self._worker.commandFinished.connect(self.commandFinished)
|
||||
self._worker.commandFailed.connect(self.commandFailed)
|
||||
self._worker.connected.connect(lambda _spec: self.connectionChanged.emit(True))
|
||||
self._worker.disconnected.connect(lambda: self.connectionChanged.emit(False))
|
||||
# Kicks the worker's event loop into life on the new thread.
|
||||
self._thread.started.connect(self._on_thread_started)
|
||||
self._thread.start()
|
||||
self._interval_ms = DEFAULT_INTERVAL_MS
|
||||
|
||||
def _on_thread_started(self) -> None:
|
||||
"""Ask the worker to build its timers, on its own thread.
|
||||
|
||||
``QMetaObject.invokeMethod`` with a queued connection is used rather
|
||||
than calling :meth:`_Worker.warm_up` here: this slot runs on the GUI
|
||||
thread (the receiver is ``self``), and a QTimer created here would then
|
||||
belong to the GUI thread while being started and stopped from the
|
||||
worker — which Qt refuses at run time.
|
||||
"""
|
||||
QMetaObject.invokeMethod(
|
||||
self._worker, "warm_up", Qt.ConnectionType.QueuedConnection
|
||||
)
|
||||
|
||||
@property
|
||||
def interval_ms(self) -> int:
|
||||
return self._interval_ms
|
||||
|
||||
def set_interval_ms(self, value: int) -> None:
|
||||
"""Change the refresh rate. Callable from the GUI thread."""
|
||||
self._interval_ms = max(20, int(value))
|
||||
self._submit_void(lambda worker: worker.start_polling(self._interval_ms))
|
||||
|
||||
def set_axes(self, axes: Iterable[int]) -> None:
|
||||
self._axes = list(axes)
|
||||
self._submit_void(lambda worker: worker.set_axes(self._axes))
|
||||
|
||||
def set_language(self, language: str) -> None:
|
||||
self._submit_void(lambda worker: worker.set_language(language))
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Stop the periodic state poll.
|
||||
|
||||
The connection is untouched: pausing only stops asking. The event loop
|
||||
keeps turning, so queued commands and shutdown still work while paused.
|
||||
"""
|
||||
self._paused = True
|
||||
self._submit_void(lambda worker: worker.stop_polling())
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume the periodic state poll after :meth:`pause`."""
|
||||
self._paused = False
|
||||
self._submit_void(lambda worker: worker.start_polling(self._interval_ms))
|
||||
|
||||
@property
|
||||
def paused(self) -> bool:
|
||||
return self._paused
|
||||
|
||||
#: Whether the caller has paused the poll (see :meth:`pause`).
|
||||
_paused = False
|
||||
|
||||
def _submit_void(self, action: Callable[[_Worker], None]) -> None:
|
||||
"""Hand a fire-and-forget call to the worker through its event loop."""
|
||||
self._worker.submit(lambda _service: action(self._worker))
|
||||
|
||||
def submit(self, action: Callable[[MotionService], Any]) -> int:
|
||||
"""Queue a service call. Returns the id it will be reported under."""
|
||||
return self._worker.submit(action)
|
||||
|
||||
def connect_controller(self, spec) -> int:
|
||||
return self.submit(lambda service: service.connect(spec))
|
||||
|
||||
def disconnect_controller(self) -> int:
|
||||
def _disconnect(service: MotionService):
|
||||
# Stop the axes before closing the handle: the reference program
|
||||
# closes immediately and leaves the platform coasting.
|
||||
service.stop_all()
|
||||
service.disconnect()
|
||||
return None
|
||||
|
||||
return self.submit(_disconnect)
|
||||
|
||||
def shutdown(self, timeout_ms: int = 5000) -> None:
|
||||
"""Stop polling and join the thread. Safe to call more than once."""
|
||||
if not self._thread.isRunning():
|
||||
return
|
||||
# Both hops must run on the worker thread, because that is where the
|
||||
# timers were created. ``cleanup`` is blocking on purpose: it has to
|
||||
# complete *before* ``quit`` can take the event loop down, and waiting
|
||||
# for it also proves the worker has stopped touching the controller.
|
||||
QMetaObject.invokeMethod(
|
||||
self._worker, "cleanup", Qt.ConnectionType.BlockingQueuedConnection
|
||||
)
|
||||
self._thread.quit()
|
||||
if not self._thread.wait(timeout_ms):
|
||||
logger.warning(
|
||||
"Poller thread did not stop within %sms; it will be left to the "
|
||||
"process exit path",
|
||||
timeout_ms,
|
||||
)
|
||||
@@ -0,0 +1,670 @@
|
||||
"""Platform control logic: connection, bus init, axis commands.
|
||||
|
||||
Pure Python and Qt-free, so the whole guard/state-machine layer is testable
|
||||
without a GUI and without hardware. :mod:`~orisys_autocal.motion.poller` drives
|
||||
it from a worker thread; the UI only ever calls these methods.
|
||||
|
||||
Guards live here rather than in the UI on purpose. A motion command is refused
|
||||
for a stated reason — bus not initialised, axis not idle, non-numeric input —
|
||||
and :meth:`MotionService.jog` and friends return that reason as a
|
||||
``MotionResult`` instead of silently doing nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
import os
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from orisys_app_shared.zmotion import ZAuxError
|
||||
|
||||
from . import errors
|
||||
from .models import (
|
||||
INIT_FAILED,
|
||||
INIT_RUNNING,
|
||||
INIT_TIMEOUT_S,
|
||||
AxisParams,
|
||||
AxisState,
|
||||
AxisStatusBits,
|
||||
BusStatus,
|
||||
BusType,
|
||||
ConnectionSpec,
|
||||
MotionResult,
|
||||
Snapshot,
|
||||
)
|
||||
from .transport import DemoTransport, ZmcTransport, NativeTransport
|
||||
from .units import (
|
||||
DEFAULT_ACCEL,
|
||||
DEFAULT_SPEED,
|
||||
DEFAULT_UNITS,
|
||||
PULSES_PER_MM,
|
||||
safe_float,
|
||||
user_units_to_mm,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: The platform has five axes; the bus may report fewer if a drive is missing.
|
||||
PLATFORM_AXES = 5
|
||||
|
||||
#: ``BUS_TYPE`` values written by the vendor ``.bas`` programs.
|
||||
_BUS_TYPE_NAMES: dict[int, BusType] = {0: "ecat", 1: "rtex"}
|
||||
|
||||
|
||||
class MotionService:
|
||||
"""The controller session: one connection, five axes, one state machine."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transport: ZmcTransport | None = None,
|
||||
*,
|
||||
axis_count: int = PLATFORM_AXES,
|
||||
units_per_mm: float = DEFAULT_UNITS,
|
||||
pulses_per_mm: float = PULSES_PER_MM,
|
||||
language: str = "zh_CN",
|
||||
) -> None:
|
||||
self._transport: ZmcTransport = transport or NativeTransport()
|
||||
self.axis_count = int(axis_count)
|
||||
self._units_per_mm = safe_float(units_per_mm, DEFAULT_UNITS)
|
||||
self._pulses_per_mm = pulses_per_mm
|
||||
self.language = language
|
||||
|
||||
self._spec: ConnectionSpec | None = None
|
||||
self._bus = BusStatus()
|
||||
self._params: dict[int, AxisParams] = {
|
||||
axis: AxisParams() for axis in range(self.axis_count)
|
||||
}
|
||||
self._bas_loaded = False
|
||||
self._init_started_at: float | None = None
|
||||
self._init_timeout = INIT_TIMEOUT_S
|
||||
self._home_started: dict[int, float] = {}
|
||||
self._home_done: dict[int, bool] = {}
|
||||
|
||||
# ------------------------------------------------------------- properties
|
||||
|
||||
@property
|
||||
def transport(self) -> ZmcTransport:
|
||||
return self._transport
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._spec is not None
|
||||
|
||||
@property
|
||||
def spec(self) -> ConnectionSpec | None:
|
||||
return self._spec
|
||||
|
||||
@property
|
||||
def bus(self) -> BusStatus:
|
||||
return self._bus
|
||||
|
||||
@property
|
||||
def bas_loaded(self) -> bool:
|
||||
return self._bas_loaded
|
||||
|
||||
def params(self, axis: int) -> AxisParams:
|
||||
return self._params.get(axis, AxisParams())
|
||||
|
||||
def set_language(self, language: str) -> None:
|
||||
self.language = language
|
||||
|
||||
def _msg(self, code: int) -> str:
|
||||
return errors.describe_with_hint(code, language=self.language)
|
||||
|
||||
def describe_error(self, exc: Exception) -> str:
|
||||
"""Localised text for a failed call, for the UI and the poller.
|
||||
|
||||
A ``ZAuxError`` carries a controller code that :mod:`.errors` can
|
||||
translate; anything else (a missing DLL, a socket error) is reported
|
||||
as-is, which is where the "searched these paths" detail arrives.
|
||||
"""
|
||||
code = getattr(exc, "code", 0)
|
||||
return self._msg(code) if code else str(exc)
|
||||
|
||||
# ------------------------------------------------------------ connect/init
|
||||
|
||||
def connect(self, spec: ConnectionSpec) -> MotionResult:
|
||||
"""Open the controller.
|
||||
|
||||
A previous connection is closed first: switching transport type without
|
||||
closing leaks the DLL handle, which the reference program does.
|
||||
"""
|
||||
if self._spec is not None:
|
||||
self.disconnect()
|
||||
try:
|
||||
self._transport.open(spec)
|
||||
except (ZAuxError, OSError, ValueError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
self._spec = None
|
||||
message = self._msg(code) if code else str(exc)
|
||||
logger.warning("Connect to %s failed: %s", spec.describe(), exc)
|
||||
return MotionResult.failed(code, message)
|
||||
|
||||
self._spec = spec
|
||||
logger.info("Connected: %s via %s", spec.describe(), self._transport.source)
|
||||
# A controller may already have a BAS program running from a previous
|
||||
# session; adopt it rather than claiming the bus is uninitialised.
|
||||
self._adopt_existing_bas()
|
||||
return MotionResult.success(f"已连接 {spec.describe()}" if self._is_zh else f"Connected {spec.describe()}")
|
||||
|
||||
@property
|
||||
def _is_zh(self) -> bool:
|
||||
return self.language == "zh_CN"
|
||||
|
||||
def _adopt_existing_bas(self) -> None:
|
||||
try:
|
||||
bus_type = self._transport.user_var("BUS_TYPE")
|
||||
except (ZAuxError, OSError):
|
||||
self._bas_loaded = False
|
||||
self._bus = BusStatus(bus_type="unknown", init_status=INIT_RUNNING)
|
||||
return
|
||||
self._bas_loaded = True
|
||||
self._bus = BusStatus(
|
||||
bus_type=_BUS_TYPE_NAMES.get(int(bus_type), "unknown"),
|
||||
init_status=INIT_RUNNING,
|
||||
bas_loaded=True,
|
||||
)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
try:
|
||||
self._transport.close()
|
||||
except (ZAuxError, OSError): # pragma: no cover - close is best-effort
|
||||
logger.exception("Closing the controller connection failed")
|
||||
self._spec = None
|
||||
self._bas_loaded = False
|
||||
self._init_started_at = None
|
||||
self._home_started.clear()
|
||||
self._home_done.clear()
|
||||
self._bus = BusStatus()
|
||||
|
||||
def download_bas(self, filename: str, run_mode: int = 1) -> MotionResult:
|
||||
"""Download a ``.bas`` program to the controller and start its scan.
|
||||
|
||||
``run_mode=1`` writes to ROM, so the controller re-runs the program on
|
||||
power-up as well; the vendor ``ECAT.bas`` delays 3 s, scans the slot,
|
||||
then sets ``Bus_InitStatus``. The UI follows that through
|
||||
:func:`poll`.
|
||||
"""
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not filename or not os.path.isfile(filename):
|
||||
return MotionResult.refused(
|
||||
self._text(f"文件不存在: {filename}", f"File not found: {filename}")
|
||||
)
|
||||
try:
|
||||
self._transport.bas_download(filename, run_mode)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
self._bas_loaded = False
|
||||
self._init_started_at = None
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
|
||||
self._bas_loaded = True
|
||||
self._init_started_at = time.monotonic()
|
||||
self._bus = BusStatus(
|
||||
bus_type=self._bus.bus_type,
|
||||
init_status=INIT_RUNNING,
|
||||
bas_path=filename,
|
||||
bas_loaded=True,
|
||||
init_started_at=self._init_started_at,
|
||||
)
|
||||
# Read BUS_TYPE so the SDO/RTEX panels enable for the right family.
|
||||
try:
|
||||
bus_type = self._transport.user_var("BUS_TYPE")
|
||||
self._bus = _replace_bus_type(self._bus, _BUS_TYPE_NAMES.get(int(bus_type), "unknown"))
|
||||
except (ZAuxError, OSError):
|
||||
pass
|
||||
return MotionResult.success(
|
||||
self._text(
|
||||
"BAS 已下载,控制器将延时扫描总线",
|
||||
"BAS downloaded; the controller will scan the bus after its delay",
|
||||
)
|
||||
)
|
||||
|
||||
def reinit_bus(self) -> MotionResult:
|
||||
"""Re-run the BAS program's init task without re-downloading it."""
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._bas_loaded or not self._bus.bas_path:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
"Bas文件未加载,请先下载 ECAT.bas 或 RTEX.bas",
|
||||
"No BAS program loaded; download ECAT.bas or RTEX.bas first",
|
||||
)
|
||||
)
|
||||
if self._bus.init_status == INIT_RUNNING and not self._bus.timed_out(time.monotonic()):
|
||||
return MotionResult.refused(
|
||||
self._text("总线正在初始化,请稍候", "Bus initialisation is already running")
|
||||
)
|
||||
|
||||
script = os.path.basename(self._bus.bas_path)
|
||||
try:
|
||||
self._transport.execute("STOPTASK 2")
|
||||
self._transport.execute(f'RUN "{script}",4')
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
|
||||
self._init_started_at = time.monotonic()
|
||||
self._bus = BusStatus(
|
||||
bus_type=self._bus.bus_type,
|
||||
init_status=INIT_RUNNING,
|
||||
bas_path=self._bus.bas_path,
|
||||
bas_loaded=True,
|
||||
init_started_at=self._init_started_at,
|
||||
)
|
||||
return MotionResult.success(
|
||||
self._text("正在重新初始化总线", "Re-initialising the bus")
|
||||
)
|
||||
|
||||
def _text(self, zh: str, en: str) -> str:
|
||||
return zh if self._is_zh else en
|
||||
|
||||
# ----------------------------------------------------------------- polling
|
||||
|
||||
def poll(self, axes: Iterable[int] | None = None) -> Snapshot:
|
||||
"""Read bus and axis state for one UI refresh.
|
||||
|
||||
Never raises: a controller that has gone away produces a snapshot with
|
||||
``error`` set and the last known values, because a poll runs on a timer
|
||||
and an exception there would either kill the timer or spam it.
|
||||
"""
|
||||
if not self.connected:
|
||||
return Snapshot(connected=False, spec=self._spec, bus=self._bus)
|
||||
|
||||
wanted = list(axes) if axes is not None else list(range(self.axis_count))
|
||||
error = ""
|
||||
try:
|
||||
self._refresh_bus()
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
error = self._msg(code) if code else str(exc)
|
||||
logger.warning("Bus poll failed: %s", exc)
|
||||
|
||||
states: list[AxisState] = []
|
||||
for axis in wanted:
|
||||
try:
|
||||
states.append(self._read_axis(axis))
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
states.append(
|
||||
AxisState(
|
||||
axis=axis,
|
||||
read_ok=False,
|
||||
error=self._msg(code) if code else str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
return Snapshot(
|
||||
connected=True,
|
||||
spec=self._spec,
|
||||
bus=self._bus,
|
||||
axes=tuple(states),
|
||||
error=error,
|
||||
)
|
||||
|
||||
def _refresh_bus(self) -> None:
|
||||
now = time.monotonic()
|
||||
bus = self._bus
|
||||
init_started = self._init_started_at
|
||||
|
||||
# BUS_TYPE is present as soon as a BAS program has run at all.
|
||||
bus_type = bus.bus_type
|
||||
try:
|
||||
raw_type = self._transport.user_var("BUS_TYPE")
|
||||
bus_type = _BUS_TYPE_NAMES.get(int(raw_type), "unknown")
|
||||
self._bas_loaded = True
|
||||
except (ZAuxError, OSError):
|
||||
# Between "downloaded" and the program's first instruction the
|
||||
# globals do not exist yet, and they may never appear if the
|
||||
# program failed to start. That is the timeout case, not a reason
|
||||
# to forget that a download happened.
|
||||
if init_started is None:
|
||||
self._bas_loaded = False
|
||||
|
||||
if not self._bas_loaded:
|
||||
self._bus = BusStatus(
|
||||
bus_type=bus_type, init_status=INIT_RUNNING, bas_loaded=False
|
||||
)
|
||||
return
|
||||
|
||||
init_status = bus.init_status
|
||||
node_count = bus.node_count
|
||||
total_axes = bus.total_axes
|
||||
message = ""
|
||||
|
||||
try:
|
||||
init_status = int(self._transport.user_var("Bus_InitStatus"))
|
||||
except (ZAuxError, OSError) as exc:
|
||||
if init_started is not None and (now - init_started) > self._init_timeout:
|
||||
# The vendor's own 25 s window has elapsed without the program
|
||||
# publishing its globals: report a timeout, with the diagnostic
|
||||
# the reference program prints, rather than raising on a timer.
|
||||
init_status = INIT_FAILED
|
||||
message = self._text(
|
||||
"初始化超时:读不到 BUS_TYPE/Bus_InitStatus,"
|
||||
"请用 ZDevelop 查看 ?*ETHERCAT",
|
||||
"Initialisation timed out: BUS_TYPE/Bus_InitStatus unreadable. "
|
||||
"Check ?*ETHERCAT in ZDevelop.",
|
||||
)
|
||||
logger.warning("Bus init timed out after %.0fs: %s", self._init_timeout, exc)
|
||||
self._init_started_at = None
|
||||
self._bas_loaded = False
|
||||
else:
|
||||
self._bus = BusStatus(
|
||||
bus_type=bus_type,
|
||||
init_status=INIT_RUNNING,
|
||||
bas_path=bus.bas_path,
|
||||
bas_loaded=True,
|
||||
init_started_at=init_started,
|
||||
)
|
||||
return
|
||||
|
||||
if init_status != INIT_RUNNING:
|
||||
self._init_started_at = None
|
||||
try:
|
||||
node_count = self._transport.bus_node_count(0)
|
||||
total_axes = int(round(self._transport.user_var("Bus_TotalAxisnum")))
|
||||
except (ZAuxError, OSError):
|
||||
pass
|
||||
|
||||
self._bus = BusStatus(
|
||||
bus_type=bus_type,
|
||||
init_status=init_status,
|
||||
node_count=node_count,
|
||||
total_axes=total_axes,
|
||||
bas_path=bus.bas_path,
|
||||
bas_loaded=True,
|
||||
init_started_at=self._init_started_at,
|
||||
message=message,
|
||||
)
|
||||
|
||||
def _read_axis(self, axis: int) -> AxisState:
|
||||
transport = self._transport
|
||||
status_raw = transport.get_axis_status(axis)
|
||||
idle = transport.get_if_idle(axis) == -1
|
||||
status = AxisStatusBits(status_raw)
|
||||
|
||||
# Track the homing edge the same way the reference program does: bit 6
|
||||
# set means homing is running, and the falling edge means ask the drive
|
||||
# whether it actually completed. Between those two, report the last
|
||||
# known answer rather than claiming homing either way.
|
||||
home_status = 1 if self._home_done.get(axis, False) else 0
|
||||
if status.home_in_progress:
|
||||
self._home_started[axis] = self._home_started.get(axis, time.monotonic())
|
||||
self._home_done[axis] = False
|
||||
home_status = 0
|
||||
elif axis in self._home_started:
|
||||
del self._home_started[axis]
|
||||
try:
|
||||
home_status = int(transport.bus_home_status(axis))
|
||||
except (ZAuxError, OSError):
|
||||
home_status = 0
|
||||
self._home_done[axis] = home_status == 1
|
||||
|
||||
params = self.params(axis)
|
||||
return AxisState(
|
||||
axis=axis,
|
||||
enabled=transport.get_axis_enable(axis) != 0,
|
||||
atype=transport.get_atype(axis),
|
||||
units=transport.get_units(axis),
|
||||
speed=transport.get_speed(axis),
|
||||
accel=transport.get_accel(axis),
|
||||
dpos=transport.get_dpos(axis),
|
||||
mpos=transport.get_mpos(axis),
|
||||
status=status,
|
||||
idle=idle,
|
||||
home_status=home_status,
|
||||
read_ok=True,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------- axis setup
|
||||
|
||||
def set_axis_params(self, axis: int, params: AxisParams) -> MotionResult:
|
||||
"""Apply units/speed/accel to the controller and remember them."""
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._known_axis(axis):
|
||||
return MotionResult.refused(self._axis_range_message(axis))
|
||||
try:
|
||||
self._transport.set_units(axis, params.units)
|
||||
self._transport.set_speed(axis, params.speed)
|
||||
self._transport.set_accel(axis, params.accel)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
self._params[axis] = params
|
||||
return MotionResult.success()
|
||||
|
||||
def set_axis_enable(self, axis: int, enabled: bool) -> MotionResult:
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._known_axis(axis):
|
||||
return MotionResult.refused(self._axis_range_message(axis))
|
||||
try:
|
||||
self._transport.set_axis_enable(axis, enabled)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
return MotionResult.success(
|
||||
self._text(f"轴{axis} 已{'使能' if enabled else '关闭'}",
|
||||
f"Axis {axis} {'enabled' if enabled else 'disabled'}")
|
||||
)
|
||||
|
||||
def clear_alarm(self, axis: int) -> MotionResult:
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._known_axis(axis):
|
||||
return MotionResult.refused(self._axis_range_message(axis))
|
||||
try:
|
||||
self._transport.bus_drive_clear(axis, 0)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
return MotionResult.success(self._text("已清除报警", "Alarm cleared"))
|
||||
|
||||
# ------------------------------------------------------------------ motion
|
||||
|
||||
def _motion_guard(self, axis: int) -> MotionResult | None:
|
||||
"""Return the refusal for a motion command, or ``None`` if it may run."""
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._known_axis(axis):
|
||||
return MotionResult.refused(self._axis_range_message(axis))
|
||||
if not self._bus.initialized:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
"总线未初始化完成,禁止运动",
|
||||
"Bus is not initialised; motion is refused",
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
def jog(self, axis: int, forward: bool) -> MotionResult:
|
||||
refusal = self._motion_guard(axis)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
try:
|
||||
self._transport.jog(axis, forward)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
return MotionResult.success(
|
||||
self._text("正转" if forward else "反转", "Forward" if forward else "Reverse")
|
||||
)
|
||||
|
||||
def move_abs(self, axis: int, target: float) -> MotionResult:
|
||||
return self._move(axis, target, absolute=True)
|
||||
|
||||
def move_rel(self, axis: int, distance: float) -> MotionResult:
|
||||
return self._move(axis, distance, absolute=False)
|
||||
|
||||
def _move(self, axis: int, value: float, *, absolute: bool) -> MotionResult:
|
||||
refusal = self._motion_guard(axis)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
||||
return MotionResult.refused(
|
||||
self._text("目标位置请输入数字", "Enter a numeric position")
|
||||
)
|
||||
try:
|
||||
if absolute:
|
||||
self._transport.move_abs(axis, float(value))
|
||||
else:
|
||||
self._transport.move_rel(axis, float(value))
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
return MotionResult.success()
|
||||
|
||||
def stop(self, axis: int) -> MotionResult:
|
||||
"""Stop one axis (decelerating stop, mode 2)."""
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._known_axis(axis):
|
||||
return MotionResult.refused(self._axis_range_message(axis))
|
||||
try:
|
||||
self._transport.stop(axis, 2)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
return MotionResult.success(self._text("已停止", "Stopped"))
|
||||
|
||||
def stop_all(self) -> list[MotionResult]:
|
||||
"""Stop every axis. Used before disconnecting."""
|
||||
return [self.stop(axis) for axis in range(self.axis_count)]
|
||||
|
||||
def home(self, axis: int) -> MotionResult:
|
||||
"""Start driver homing (EtherCAT only, as the vendor example notes).
|
||||
|
||||
Speeds and offset are written before the mode: the controller latches
|
||||
them when homing starts, so setting them afterwards has no effect on
|
||||
the move already in progress.
|
||||
"""
|
||||
refusal = self._motion_guard(axis)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
if not self._bus.supports_sdo:
|
||||
return MotionResult.refused(
|
||||
self._text("rtex不支持回零", "Homing is not supported over RTEX")
|
||||
)
|
||||
params = self.params(axis)
|
||||
if params.home_mode <= 0:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
f"轴{axis} 回零模式未设置(0 不是有效模式)",
|
||||
f"Axis {axis} has no homing mode set (0 is not a valid mode)",
|
||||
)
|
||||
)
|
||||
try:
|
||||
self._transport.set_speed(axis, params.home_high)
|
||||
self._transport.set_creep(axis, params.home_low)
|
||||
self._transport.bus_set_datum_offset(axis, params.home_offset)
|
||||
self._transport.bus_datum(axis, params.home_mode)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
self._home_started[axis] = time.monotonic()
|
||||
self._home_done[axis] = False
|
||||
return MotionResult.success(self._text("回零已启动", "Homing started"))
|
||||
|
||||
def _known_axis(self, axis: int) -> bool:
|
||||
return 0 <= int(axis) < self.axis_count
|
||||
|
||||
def _axis_range_message(self, axis: int) -> str:
|
||||
return self._text(
|
||||
f"轴号超出范围 (0-{self.axis_count - 1})",
|
||||
f"Axis out of range (0-{self.axis_count - 1})",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- bus data
|
||||
|
||||
def sdo_read(
|
||||
self, node: int, index: int, subindex: int, data_type: int
|
||||
) -> tuple[MotionResult, int]:
|
||||
"""Read an EtherCAT object; returns the result and the value read."""
|
||||
refusal = self._bus_data_guard("ecat")
|
||||
if refusal is not None:
|
||||
return refusal, 0
|
||||
try:
|
||||
value = self._transport.sdo_read(node, index, subindex, data_type)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc)), 0
|
||||
return MotionResult.success(), int(value)
|
||||
|
||||
def sdo_write(
|
||||
self, node: int, index: int, subindex: int, data_type: int, value: int
|
||||
) -> MotionResult:
|
||||
return self._bus_write(
|
||||
"ecat",
|
||||
lambda: self._transport.sdo_write(node, index, subindex, data_type, int(value)),
|
||||
)
|
||||
|
||||
def rtex_read(self, axis: int, parameter: int) -> tuple[MotionResult, float]:
|
||||
refusal = self._bus_data_guard("rtex")
|
||||
if refusal is not None:
|
||||
return refusal, 0.0
|
||||
try:
|
||||
value = self._transport.rtex_read(axis, parameter)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc)), 0.0
|
||||
return MotionResult.success(), float(value)
|
||||
|
||||
def rtex_write(self, axis: int, parameter: int, value: float) -> MotionResult:
|
||||
return self._bus_write(
|
||||
"rtex", lambda: self._transport.rtex_write(axis, parameter, float(value))
|
||||
)
|
||||
|
||||
def _bus_data_guard(self, family: BusType) -> MotionResult | None:
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if self._bus.bus_type == "unknown":
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
"总线类型未知,请先初始化总线",
|
||||
"Bus type is unknown; initialise the bus first",
|
||||
)
|
||||
)
|
||||
if self._bus.bus_type != family:
|
||||
other = "ETHERCAT" if family == "rtex" else "RTEX"
|
||||
return MotionResult.refused(
|
||||
self._text(f"非{other}模块", f"This is not a {other} module")
|
||||
)
|
||||
return None
|
||||
|
||||
def _bus_write(self, family: BusType, action: Callable[[], None]) -> MotionResult:
|
||||
refusal = self._bus_data_guard(family)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
try:
|
||||
action()
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
return MotionResult.success()
|
||||
|
||||
# ------------------------------------------------------------------ display
|
||||
|
||||
def to_mm(self, axis: int, user_units: float) -> float:
|
||||
"""Displayed millimetres for a reading. Derived, never a measurement."""
|
||||
units = self.params(axis).units or self._units_per_mm
|
||||
return user_units_to_mm(
|
||||
user_units, units=units, pulses_per_mm=self._pulses_per_mm
|
||||
)
|
||||
|
||||
|
||||
def _replace_bus_type(bus: BusStatus, bus_type: BusType) -> BusStatus:
|
||||
return BusStatus(
|
||||
bus_type=bus_type,
|
||||
init_status=bus.init_status,
|
||||
node_count=bus.node_count,
|
||||
total_axes=bus.total_axes,
|
||||
bas_path=bus.bas_path,
|
||||
bas_loaded=bus.bas_loaded,
|
||||
init_started_at=bus.init_started_at,
|
||||
message=bus.message,
|
||||
)
|
||||
@@ -0,0 +1,467 @@
|
||||
"""The seam between the platform-control logic and the controller DLL.
|
||||
|
||||
:class:`ZmcTransport` is what :mod:`~orisys_autocal.motion.service` talks to.
|
||||
Contract: every method **raises** :class:`~orisys_app_shared.zmotion.ZAuxError`
|
||||
when the controller returns non-zero, and returns plain Python values (never
|
||||
ctypes objects) when it succeeds. That lets the service have one error path and
|
||||
lets the tests inject a fake that fails on demand.
|
||||
|
||||
Three implementations:
|
||||
|
||||
* :class:`NativeTransport` — the real controller, via the vendored DLL.
|
||||
* :class:`DemoTransport` — a self-consistent simulated platform, so the whole
|
||||
UI can be exercised with no hardware and no DLL.
|
||||
* ``FakeTransport`` in the app's test suite — the same protocol, driven by the
|
||||
test rather than by a simulation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from orisys_app_shared.zmotion import ZAuxError, ZmotionClient
|
||||
|
||||
from .errors import ERR_AUX_OFFSET
|
||||
from .models import BusType, ConnectionSpec
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ZmcTransport(Protocol):
|
||||
"""What the service needs from a controller connection."""
|
||||
|
||||
#: Human-readable source, e.g. the DLL directory or ``"demo"``.
|
||||
source: str
|
||||
|
||||
def open(self, spec: ConnectionSpec) -> None: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
def search_eth(self) -> list[str]: ...
|
||||
|
||||
def bas_download(self, filename: str, run_mode: int = 1) -> None: ...
|
||||
|
||||
def execute(self, command: str) -> None: ...
|
||||
|
||||
def user_var(self, name: str) -> float: ...
|
||||
|
||||
def bus_node_count(self, slot: int = 0) -> int: ...
|
||||
|
||||
def bus_home_status(self, axis: int) -> int: ...
|
||||
|
||||
def bus_datum(self, axis: int, home_mode: int) -> None: ...
|
||||
|
||||
def bus_set_datum_offset(self, axis: int, value: float) -> None: ...
|
||||
|
||||
def bus_drive_clear(self, axis: int, mode: int = 0) -> None: ...
|
||||
|
||||
def sdo_read(
|
||||
self, node: int, index: int, subindex: int, data_type: int, slot: int = 0
|
||||
) -> int: ...
|
||||
|
||||
def sdo_write(
|
||||
self,
|
||||
node: int,
|
||||
index: int,
|
||||
subindex: int,
|
||||
data_type: int,
|
||||
value: int,
|
||||
slot: int = 0,
|
||||
) -> None: ...
|
||||
|
||||
def rtex_read(self, axis: int, parameter: int) -> float: ...
|
||||
|
||||
def rtex_write(self, axis: int, parameter: int, value: float) -> None: ...
|
||||
|
||||
def set_axis_enable(self, axis: int, enabled: bool) -> None: ...
|
||||
|
||||
def set_units(self, axis: int, value: float) -> None: ...
|
||||
|
||||
def get_units(self, axis: int) -> float: ...
|
||||
|
||||
def set_speed(self, axis: int, value: float) -> None: ...
|
||||
|
||||
def get_speed(self, axis: int) -> float: ...
|
||||
|
||||
def set_accel(self, axis: int, value: float) -> None: ...
|
||||
|
||||
def get_accel(self, axis: int) -> float: ...
|
||||
|
||||
def set_creep(self, axis: int, value: float) -> None: ...
|
||||
|
||||
def get_atype(self, axis: int) -> int: ...
|
||||
|
||||
def get_axis_enable(self, axis: int) -> int: ...
|
||||
|
||||
def get_dpos(self, axis: int) -> float: ...
|
||||
|
||||
def get_mpos(self, axis: int) -> float: ...
|
||||
|
||||
def get_axis_status(self, axis: int) -> int: ...
|
||||
|
||||
def get_if_idle(self, axis: int) -> int: ...
|
||||
|
||||
def jog(self, axis: int, forward: bool) -> None: ...
|
||||
|
||||
def move_abs(self, axis: int, position: float) -> None: ...
|
||||
|
||||
def move_rel(self, axis: int, distance: float) -> None: ...
|
||||
|
||||
def stop(self, axis: int, mode: int = 2) -> None: ...
|
||||
|
||||
|
||||
class NativeTransport:
|
||||
"""The real controller, through the vendored ZMotion runtime."""
|
||||
|
||||
def __init__(self, client: ZmotionClient | None = None) -> None:
|
||||
self._client = client or ZmotionClient()
|
||||
|
||||
@property
|
||||
def client(self) -> ZmotionClient:
|
||||
return self._client
|
||||
|
||||
@property
|
||||
def source(self) -> str:
|
||||
return self._client.dll_dir or "zauxdll.dll (not loaded)"
|
||||
|
||||
def open(self, spec: ConnectionSpec) -> None:
|
||||
if spec.mode == "eth":
|
||||
result = self._client.open_eth(spec.ip)
|
||||
elif spec.mode == "com":
|
||||
result = self._client.open_com(spec.com_id)
|
||||
elif spec.mode == "pci":
|
||||
result = self._client.open_pci(spec.card_num)
|
||||
elif spec.mode == "local":
|
||||
result = self._client.fast_open(5, spec.local_string, 1000)
|
||||
else:
|
||||
raise ValueError(f"{spec.mode!r} is not a native connection mode")
|
||||
result.raise_for_status(f"open {spec.describe()}")
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def search_eth(self) -> list[str]:
|
||||
value = self._client.search_eth()
|
||||
# A scan that finds nothing is not an error: the reference program
|
||||
# lists the configured defaults and moves on.
|
||||
if not value.ok:
|
||||
return []
|
||||
return [item for item in str(value.value).split() if item]
|
||||
|
||||
def bas_download(self, filename: str, run_mode: int = 1) -> None:
|
||||
self._client.bas_download(filename, run_mode).raise_for_status("BAS download")
|
||||
|
||||
def execute(self, command: str) -> None:
|
||||
self._client.execute(command).raise_for_status(command)
|
||||
|
||||
def user_var(self, name: str) -> float:
|
||||
return float(self._client.get_user_var(name).raise_for_status(name).value)
|
||||
|
||||
def bus_node_count(self, slot: int = 0) -> int:
|
||||
return int(self._client.bus_node_count(slot).raise_for_status().value)
|
||||
|
||||
def bus_home_status(self, axis: int) -> int:
|
||||
return int(self._client.bus_home_status(axis).raise_for_status().value)
|
||||
|
||||
def bus_datum(self, axis: int, home_mode: int) -> None:
|
||||
self._client.bus_datum(axis, home_mode).raise_for_status("datum")
|
||||
|
||||
def bus_set_datum_offset(self, axis: int, value: float) -> None:
|
||||
self._client.bus_set_datum_offset(axis, value).raise_for_status()
|
||||
|
||||
def bus_drive_clear(self, axis: int, mode: int = 0) -> None:
|
||||
self._client.bus_drive_clear(axis, mode).raise_for_status()
|
||||
|
||||
def sdo_read(
|
||||
self, node: int, index: int, subindex: int, data_type: int, slot: int = 0
|
||||
) -> int:
|
||||
return int(
|
||||
self._client.sdo_read(slot, node, index, subindex, data_type)
|
||||
.raise_for_status("SDO read")
|
||||
.value
|
||||
)
|
||||
|
||||
def sdo_write(
|
||||
self,
|
||||
node: int,
|
||||
index: int,
|
||||
subindex: int,
|
||||
data_type: int,
|
||||
value: int,
|
||||
slot: int = 0,
|
||||
) -> None:
|
||||
self._client.sdo_write(slot, node, index, subindex, data_type, value).raise_for_status(
|
||||
"SDO write"
|
||||
)
|
||||
|
||||
def rtex_read(self, axis: int, parameter: int) -> float:
|
||||
return float(self._client.rtex_read(axis, parameter).raise_for_status().value)
|
||||
|
||||
def rtex_write(self, axis: int, parameter: int, value: float) -> None:
|
||||
self._client.rtex_write(axis, parameter, value).raise_for_status()
|
||||
|
||||
def set_axis_enable(self, axis: int, enabled: bool) -> None:
|
||||
self._client.set_axis_enable(axis, 1 if enabled else 0).raise_for_status()
|
||||
|
||||
def set_units(self, axis: int, value: float) -> None:
|
||||
self._client.set_units(axis, value).raise_for_status()
|
||||
|
||||
def get_units(self, axis: int) -> float:
|
||||
return float(self._client.get_units(axis).raise_for_status().value)
|
||||
|
||||
def set_speed(self, axis: int, value: float) -> None:
|
||||
self._client.set_speed(axis, value).raise_for_status()
|
||||
|
||||
def get_speed(self, axis: int) -> float:
|
||||
return float(self._client.get_speed(axis).raise_for_status().value)
|
||||
|
||||
def set_accel(self, axis: int, value: float) -> None:
|
||||
self._client.set_accel(axis, value).raise_for_status()
|
||||
|
||||
def get_accel(self, axis: int) -> float:
|
||||
return float(self._client.get_accel(axis).raise_for_status().value)
|
||||
|
||||
def set_creep(self, axis: int, value: float) -> None:
|
||||
self._client.set_creep(axis, value).raise_for_status()
|
||||
|
||||
def get_atype(self, axis: int) -> int:
|
||||
return int(self._client.get_atype(axis).raise_for_status().value)
|
||||
|
||||
def get_axis_enable(self, axis: int) -> int:
|
||||
return int(self._client.get_axis_enable(axis).raise_for_status().value)
|
||||
|
||||
def get_dpos(self, axis: int) -> float:
|
||||
return float(self._client.get_dpos(axis).raise_for_status().value)
|
||||
|
||||
def get_mpos(self, axis: int) -> float:
|
||||
return float(self._client.get_mpos(axis).raise_for_status().value)
|
||||
|
||||
def get_axis_status(self, axis: int) -> int:
|
||||
return int(self._client.get_axis_status(axis).raise_for_status().value)
|
||||
|
||||
def get_if_idle(self, axis: int) -> int:
|
||||
return int(self._client.get_if_idle(axis).raise_for_status().value)
|
||||
|
||||
def jog(self, axis: int, forward: bool) -> None:
|
||||
self._client.single_vmove(axis, 1 if forward else -1).raise_for_status()
|
||||
|
||||
def move_abs(self, axis: int, position: float) -> None:
|
||||
self._client.single_move_abs(axis, position).raise_for_status()
|
||||
|
||||
def move_rel(self, axis: int, distance: float) -> None:
|
||||
self._client.single_move(axis, distance).raise_for_status()
|
||||
|
||||
def stop(self, axis: int, mode: int = 2) -> None:
|
||||
self._client.single_cancel(axis, mode).raise_for_status()
|
||||
|
||||
|
||||
class DemoTransport:
|
||||
"""A self-consistent simulated 5-axis platform.
|
||||
|
||||
Not a mock: it keeps position, enable state and motion state so the UI's
|
||||
polling, jogging and stop behaviour are all exercised without hardware.
|
||||
``BUS_TYPE`` reports EtherCAT so the SDO panel is live too.
|
||||
"""
|
||||
|
||||
#: Where jogging stops, in user units — keeps a demo jog bounded.
|
||||
TRAVEL_LIMIT = 5000.0
|
||||
|
||||
def __init__(self, *, axes: int = 5) -> None:
|
||||
self._axes = int(axes)
|
||||
self._enabled = {axis: True for axis in range(self._axes)}
|
||||
self._units = {axis: 10.0 for axis in range(self._axes)}
|
||||
self._speed = {axis: 1000.0 for axis in range(self._axes)}
|
||||
self._accel = {axis: 10000.0 for axis in range(self._axes)}
|
||||
self._dpos = {axis: 0.0 for axis in range(self._axes)}
|
||||
self._moving_until = {axis: 0.0 for axis in range(self._axes)}
|
||||
self._homing_until = {axis: 0.0 for axis in range(self._axes)}
|
||||
self._home_done = {axis: False for axis in range(self._axes)}
|
||||
self._bas_loaded = False
|
||||
self._init_started: float | None = None
|
||||
self._opened = False
|
||||
self._sdo: dict[tuple[int, int, int], int] = {}
|
||||
|
||||
@property
|
||||
def source(self) -> str:
|
||||
return "demo (simulated platform, no hardware)"
|
||||
|
||||
def open(self, spec: ConnectionSpec) -> None:
|
||||
if spec.mode != "demo":
|
||||
raise ZAuxError(ERR_DEMO_UNSUPPORTED, "DemoTransport only serves demo mode")
|
||||
self._opened = True
|
||||
|
||||
def close(self) -> None:
|
||||
self._opened = False
|
||||
|
||||
def search_eth(self) -> list[str]:
|
||||
return ["127.0.0.1"]
|
||||
|
||||
def bas_download(self, filename: str, run_mode: int = 1) -> None:
|
||||
if not self._opened:
|
||||
raise ZAuxError(ERR_NOT_OPEN, "not connected")
|
||||
self._bas_loaded = True
|
||||
# ECAT.bas delays 3 s before scanning; mimic the delay so the UI's
|
||||
# "initialising" state is visible rather than instantaneous.
|
||||
self._init_started = time.monotonic() + DEMO_INIT_DELAY_S
|
||||
|
||||
def execute(self, command: str) -> None:
|
||||
if not self._opened:
|
||||
raise ZAuxError(ERR_NOT_OPEN, "not connected")
|
||||
if "RUN" in command.upper() and self._bas_loaded:
|
||||
self._init_started = time.monotonic() + DEMO_INIT_DELAY_S
|
||||
|
||||
def user_var(self, name: str) -> float:
|
||||
if not self._bas_loaded:
|
||||
# Mirrors the real controller: unknown/absent variable.
|
||||
raise ZAuxError(ERR_AUX_OFFSET + 2, f"{name} is not defined")
|
||||
if name == "BUS_TYPE":
|
||||
return 0.0
|
||||
if name == "Bus_TotalAxisnum":
|
||||
return float(self._axes)
|
||||
if name == "Bus_InitStatus":
|
||||
if self._init_started is not None and time.monotonic() >= self._init_started:
|
||||
return 1.0
|
||||
return -1.0
|
||||
raise ZAuxError(ERR_AUX_OFFSET + 2, f"{name} is not defined")
|
||||
|
||||
def _require_axis(self, axis: int) -> None:
|
||||
if not (0 <= axis < self._axes):
|
||||
raise ZAuxError(ERR_AUX_OFFSET + 2, f"axis {axis} does not exist")
|
||||
|
||||
def bus_node_count(self, slot: int = 0) -> int:
|
||||
return self._axes
|
||||
|
||||
def bus_home_status(self, axis: int) -> int:
|
||||
self._require_axis(axis)
|
||||
return 1 if self._home_done[axis] else 0
|
||||
|
||||
def bus_datum(self, axis: int, home_mode: int) -> None:
|
||||
self._require_axis(axis)
|
||||
self._homing_until[axis] = time.monotonic() + DEMO_HOME_S
|
||||
|
||||
def bus_set_datum_offset(self, axis: int, value: float) -> None:
|
||||
self._require_axis(axis)
|
||||
|
||||
def bus_drive_clear(self, axis: int, mode: int = 0) -> None:
|
||||
self._require_axis(axis)
|
||||
|
||||
def sdo_read(
|
||||
self, node: int, index: int, subindex: int, data_type: int, slot: int = 0
|
||||
) -> int:
|
||||
return self._sdo.get((node, index, subindex), 0)
|
||||
|
||||
def sdo_write(
|
||||
self,
|
||||
node: int,
|
||||
index: int,
|
||||
subindex: int,
|
||||
data_type: int,
|
||||
value: int,
|
||||
slot: int = 0,
|
||||
) -> None:
|
||||
self._sdo[(node, index, subindex)] = int(value)
|
||||
|
||||
def rtex_read(self, axis: int, parameter: int) -> float:
|
||||
raise ZAuxError(ERR_AUX_OFFSET + 3, "RTEX is not available in demo mode")
|
||||
|
||||
def rtex_write(self, axis: int, parameter: int, value: float) -> None:
|
||||
raise ZAuxError(ERR_AUX_OFFSET + 3, "RTEX is not available in demo mode")
|
||||
|
||||
def set_axis_enable(self, axis: int, enabled: bool) -> None:
|
||||
self._require_axis(axis)
|
||||
self._enabled[axis] = bool(enabled)
|
||||
|
||||
def set_units(self, axis: int, value: float) -> None:
|
||||
self._require_axis(axis)
|
||||
self._units[axis] = float(value)
|
||||
|
||||
def get_units(self, axis: int) -> float:
|
||||
self._require_axis(axis)
|
||||
return self._units[axis]
|
||||
|
||||
def set_speed(self, axis: int, value: float) -> None:
|
||||
self._require_axis(axis)
|
||||
self._speed[axis] = float(value)
|
||||
|
||||
def get_speed(self, axis: int) -> float:
|
||||
self._require_axis(axis)
|
||||
return self._speed[axis]
|
||||
|
||||
def set_accel(self, axis: int, value: float) -> None:
|
||||
self._require_axis(axis)
|
||||
self._accel[axis] = float(value)
|
||||
|
||||
def get_accel(self, axis: int) -> float:
|
||||
self._require_axis(axis)
|
||||
return self._accel[axis]
|
||||
|
||||
def set_creep(self, axis: int, value: float) -> None:
|
||||
self._require_axis(axis)
|
||||
|
||||
def get_atype(self, axis: int) -> int:
|
||||
self._require_axis(axis)
|
||||
return 65 # position mode, as ECAT.bas configures
|
||||
|
||||
def get_axis_enable(self, axis: int) -> int:
|
||||
self._require_axis(axis)
|
||||
return 1 if self._enabled[axis] else 0
|
||||
|
||||
def get_dpos(self, axis: int) -> float:
|
||||
self._require_axis(axis)
|
||||
return self._dpos[axis]
|
||||
|
||||
def get_mpos(self, axis: int) -> float:
|
||||
self._require_axis(axis)
|
||||
# Follow the command position with a small lag while moving.
|
||||
return self._dpos[axis] * 0.999
|
||||
|
||||
def get_axis_status(self, axis: int) -> int:
|
||||
self._require_axis(axis)
|
||||
status = 0
|
||||
if self._homing_until[axis] > time.monotonic():
|
||||
status |= 64 # homing in progress
|
||||
if self._moving_until[axis] > time.monotonic():
|
||||
status |= 2 # moving
|
||||
return status
|
||||
|
||||
def get_if_idle(self, axis: int) -> int:
|
||||
self._require_axis(axis)
|
||||
moving = self._moving_until[axis] > time.monotonic()
|
||||
homing = self._homing_until[axis] > time.monotonic()
|
||||
return 0 if (moving or homing) else -1
|
||||
|
||||
def jog(self, axis: int, forward: bool) -> None:
|
||||
self._require_axis(axis)
|
||||
self._moving_until[axis] = time.monotonic() + DEMO_JOG_S
|
||||
|
||||
def move_abs(self, axis: int, position: float) -> None:
|
||||
self._require_axis(axis)
|
||||
if not math.isfinite(position):
|
||||
raise ZAuxError(ERR_AUX_OFFSET + 2, "position is not finite")
|
||||
self._dpos[axis] = float(position)
|
||||
self._moving_until[axis] = time.monotonic() + DEMO_MOVE_S
|
||||
|
||||
def move_rel(self, axis: int, distance: float) -> None:
|
||||
self._require_axis(axis)
|
||||
self.move_abs(axis, self._dpos[axis] + float(distance))
|
||||
|
||||
def stop(self, axis: int, mode: int = 2) -> None:
|
||||
self._require_axis(axis)
|
||||
self._moving_until[axis] = 0.0
|
||||
self._homing_until[axis] = 0.0
|
||||
self._home_done[axis] = True
|
||||
|
||||
def bus_type(self) -> BusType:
|
||||
return "ecat"
|
||||
|
||||
|
||||
#: Demo timing constants (seconds), chosen so a human sees each state.
|
||||
DEMO_INIT_DELAY_S = 1.5
|
||||
DEMO_JOG_S = 1.0
|
||||
DEMO_MOVE_S = 0.8
|
||||
DEMO_HOME_S = 2.0
|
||||
|
||||
#: Demo-only failure codes, kept outside the documented vendor range on purpose.
|
||||
ERR_NOT_OPEN = 30500
|
||||
ERR_DEMO_UNSUPPORTED = 30501
|
||||
@@ -0,0 +1,72 @@
|
||||
"""User-unit to millimetre conversion.
|
||||
|
||||
The numbers come from bench measurement on the platform, transcribed from the
|
||||
vendor reference program's comments::
|
||||
|
||||
# 轴0(X)/轴1(Y) 实测: UNITS=10 时 1000用户单位 = 2mm → 每毫米 5000 脉冲
|
||||
# 当量5 → 1000单位=1mm,不是100单位=1mm
|
||||
|
||||
so ``PULSES_PER_MM = 5000`` is the calibration constant and ``UNITS = 10`` is
|
||||
the pulse equivalent the platform is configured with. The displayed millimetre
|
||||
value is therefore *derived*, not measured: the UI labels it as such, and no
|
||||
code path may treat it as a calibrated length.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
#: Measured: 5000 pulses per millimetre (see the module docstring).
|
||||
PULSES_PER_MM = 5000.0
|
||||
|
||||
#: Pulse equivalent the platform is configured with by default.
|
||||
DEFAULT_UNITS = 10.0
|
||||
|
||||
#: Move-parameter defaults, matching the reference program's UI initialisation.
|
||||
DEFAULT_SPEED = 1000.0
|
||||
DEFAULT_ACCEL = 10000.0
|
||||
|
||||
|
||||
def safe_float(text: object, fallback: float) -> float:
|
||||
"""Parse ``text`` as a finite float, falling back on anything unusable.
|
||||
|
||||
A blank, non-numeric, zero or non-finite entry must not silently become a
|
||||
pulse equivalent of zero — on a motion controller that is a divide-by-zero
|
||||
waiting to happen, or an unbounded move.
|
||||
"""
|
||||
try:
|
||||
value = float(str(text).strip())
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
if not math.isfinite(value) or value == 0.0:
|
||||
return fallback
|
||||
return value
|
||||
|
||||
|
||||
def user_units_to_mm(
|
||||
value: float, *, units: float = DEFAULT_UNITS, pulses_per_mm: float = PULSES_PER_MM
|
||||
) -> float:
|
||||
"""Convert a controller user-unit value to millimetres.
|
||||
|
||||
Args:
|
||||
value: a ``DPOS``/``MPOS`` reading or move distance in user units.
|
||||
units: pulse equivalent in force on the axis.
|
||||
pulses_per_mm: the platform's calibration constant.
|
||||
|
||||
Returns:
|
||||
Millimetres. Not finite when the inputs are not usable, so a caller
|
||||
that forgets to validate sees an obvious NaN rather than a plausible
|
||||
wrong number.
|
||||
"""
|
||||
if pulses_per_mm <= 0:
|
||||
return math.nan
|
||||
return float(value) * float(units) / float(pulses_per_mm)
|
||||
|
||||
|
||||
def mm_to_user_units(
|
||||
value: float, *, units: float = DEFAULT_UNITS, pulses_per_mm: float = PULSES_PER_MM
|
||||
) -> float:
|
||||
"""Inverse of :func:`user_units_to_mm` (for an mm-entered move)."""
|
||||
if units <= 0:
|
||||
return math.nan
|
||||
return float(value) * float(pulses_per_mm) / float(units)
|
||||
@@ -0,0 +1,179 @@
|
||||
"""AutoCal's preference schema and storage.
|
||||
|
||||
The shared store keys off whatever schema it is handed, so AutoCal gets its own
|
||||
key set rather than inheriting the studio's camera/plot/3D keys. The values here
|
||||
are the ones an operator would otherwise retype at every start-up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared import prefs as _prefs
|
||||
from orisys_app_shared.paths import preference_path
|
||||
|
||||
from .motion.service import PLATFORM_AXES
|
||||
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
|
||||
|
||||
#: Connection modes, in the order the combo offers them.
|
||||
CONNECTION_MODES = ("eth", "com", "pci", "local", "demo")
|
||||
|
||||
#: Addresses the controller is normally reached on, tried first by a scan.
|
||||
DEFAULT_CONTROLLER_IPS = ("192.168.0.11", "127.0.0.1")
|
||||
|
||||
#: Grid modes the sequence profile offers (workflow P5).
|
||||
SURFACE_MODES = ("flat", "curved")
|
||||
|
||||
#: Point visiting orders (workflow P5).
|
||||
POINT_ORDERS = ("row", "serpentine", "distance")
|
||||
|
||||
#: The design target is a well-lit bench, so light is the default; dark stays
|
||||
#: selectable for a dim room.
|
||||
DEFAULT_THEME = "light"
|
||||
|
||||
|
||||
def _default_sequence_profile() -> dict[str, Any]:
|
||||
"""The point-generation and run parameters of workflow P5.
|
||||
|
||||
One profile is global for now; named, saved sequences come with the runner.
|
||||
The numbers are starting points an operator confirms at the bench, not
|
||||
measured values — in particular ``home_mode``-style unknowns are left at 0 so
|
||||
nothing is silently inherited from another machine.
|
||||
"""
|
||||
return {
|
||||
# --- grid shape
|
||||
"mode": "flat",
|
||||
"pitch_mm": 2.0,
|
||||
"margin_mm": 1.0,
|
||||
"radius_mm": 0.0,
|
||||
# --- motion
|
||||
"clear_height_mm": 5.0,
|
||||
"approach_speed_mm_s": 2.0,
|
||||
"press_speed_mm_s": 0.5,
|
||||
"press_depth_mm": 0.2,
|
||||
"dwell_s": 0.5,
|
||||
# --- measurement
|
||||
"settle_window_s": 0.5,
|
||||
"repeats": 3,
|
||||
# --- order
|
||||
"order": "serpentine",
|
||||
}
|
||||
|
||||
|
||||
def _default_safety_limits() -> dict[str, float]:
|
||||
"""Workflow P0: the limits that must have a value before anything moves.
|
||||
|
||||
``0`` means "not set" for the ones where a real value is required, and the
|
||||
app reports that at the P0 gate rather than treating it as unlimited.
|
||||
"""
|
||||
return {
|
||||
"max_travel_mm": 100.0,
|
||||
"max_descent_mm": 10.0,
|
||||
"max_press_depth_mm": 1.0,
|
||||
"follow_error_mm": 0.5,
|
||||
#: How far the axis may descend looking for contact before the run is
|
||||
#: aborted (workflow P7, no-contact abort).
|
||||
"contact_search_mm": 3.0,
|
||||
}
|
||||
|
||||
|
||||
def _default_sensor_setup() -> dict[str, Any]:
|
||||
"""Workflow P3/P4: what is under test, what touches it, and the transform.
|
||||
|
||||
``sensor_to_platform`` stays empty until the transform is established by
|
||||
probing known features; the residual travels with it so a re-fixtured sensor
|
||||
cannot silently reuse a stale transform.
|
||||
"""
|
||||
return {
|
||||
"sensor_type": "box",
|
||||
"config_ref": "",
|
||||
"geometry_id": "",
|
||||
#: Which axis presses, and the probe tip offset from the platform's
|
||||
#: reference point. Empty offset means "not set", which blocks P5.
|
||||
"press_axis": 2,
|
||||
"probe_tip_offset_mm": [0.0, 0.0, 0.0],
|
||||
"probe_tip_set": False,
|
||||
#: 4x4 row-major sensor -> platform transform, plus how well it fitted.
|
||||
"sensor_to_platform": [],
|
||||
"transform_residual_mm": 0.0,
|
||||
"transform_feature_count": 0,
|
||||
}
|
||||
|
||||
|
||||
def _default_axis_params() -> dict[str, dict[str, float | int]]:
|
||||
"""One parameter block per platform axis.
|
||||
|
||||
``home_mode`` starts at 0, which is **not** a valid CiA-402 homing mode:
|
||||
the operator must choose one for the drive in front of them rather than
|
||||
inherit a number from another machine. The reference program pre-filled 17,
|
||||
which is a specific drive's mode, so it is deliberately not copied here.
|
||||
"""
|
||||
return {
|
||||
str(axis): {
|
||||
"units": DEFAULT_UNITS,
|
||||
"speed": DEFAULT_SPEED,
|
||||
"accel": DEFAULT_ACCEL,
|
||||
"creep": 10.0,
|
||||
"home_mode": 0,
|
||||
"home_high": 50.0,
|
||||
"home_low": 10.0,
|
||||
"home_offset": 0.0,
|
||||
}
|
||||
for axis in range(PLATFORM_AXES)
|
||||
}
|
||||
|
||||
|
||||
def default_preferences() -> dict[str, Any]:
|
||||
"""AutoCal's default preference document."""
|
||||
return {
|
||||
"language": "zh_CN",
|
||||
"theme": DEFAULT_THEME,
|
||||
"window_geometry": None,
|
||||
"connection": {
|
||||
"mode": "eth",
|
||||
"ip": DEFAULT_CONTROLLER_IPS[0],
|
||||
"com_id": 0,
|
||||
"card_num": 0,
|
||||
"local_string": "0",
|
||||
},
|
||||
# Remembered so "download the BAS program" is one click after the first
|
||||
# time, instead of re-picking the file every session.
|
||||
"bas_path": "",
|
||||
#: Displayed millimetres are derived from this; it is a bench-measured
|
||||
#: constant, not a calibrated length (see motion/units.py).
|
||||
"units_per_mm": DEFAULT_UNITS,
|
||||
"state_poll_ms": 100,
|
||||
"axis_params": _default_axis_params(),
|
||||
#: {node, index, subindex, type} of the last SDO read/written, so the
|
||||
#: operator can repeat a drive-setting check quickly.
|
||||
"sdo_last": {"node": 0, "index": 24704, "subindex": 0, "type": 0},
|
||||
"rtex_last": {"axis": 0, "parameter": 0},
|
||||
"show_diagnostics": False,
|
||||
# --- workflow blocks (see apps/autocal/docs/workflow.md) --------------
|
||||
#: P0: limits enforced from P2 onward.
|
||||
"safety_limits": _default_safety_limits(),
|
||||
#: P3/P4: sensor under test, probe tip, sensor<->platform transform.
|
||||
"sensor_setup": _default_sensor_setup(),
|
||||
#: P5: grid generation and run parameters.
|
||||
"sequence_profile": _default_sequence_profile(),
|
||||
}
|
||||
|
||||
|
||||
def load_preferences() -> dict[str, Any]:
|
||||
"""Stored preferences with AutoCal's defaults filling the gaps."""
|
||||
prefs = _prefs.load_preferences(default_preferences())
|
||||
# A stored document from an older build may miss a newly added axis block.
|
||||
axis_params = prefs.setdefault("axis_params", {})
|
||||
for axis, values in _default_axis_params().items():
|
||||
axis_params.setdefault(axis, values)
|
||||
return prefs
|
||||
|
||||
|
||||
def save_preferences(prefs: dict[str, Any]) -> Path:
|
||||
return _prefs.save_preferences(prefs, default_preferences())
|
||||
|
||||
|
||||
def path() -> Path:
|
||||
"""Where AutoCal's ``preference.json`` lives (for the About box)."""
|
||||
return preference_path()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""AutoCal's windowed user interface."""
|
||||
|
||||
from .axis_panel import AxisPanel
|
||||
from .brand_bar import BrandBar
|
||||
from .bus_data_panel import BusDataPanel
|
||||
from .bus_panel import BusPanel
|
||||
from .connection_panel import ConnectionPanel
|
||||
from .window import AutoCalMainWindow
|
||||
|
||||
__all__ = [
|
||||
"AutoCalMainWindow",
|
||||
"AxisPanel",
|
||||
"BrandBar",
|
||||
"BusDataPanel",
|
||||
"BusPanel",
|
||||
"ConnectionPanel",
|
||||
]
|
||||
@@ -0,0 +1,611 @@
|
||||
"""Axis parameters, live state, and motion commands for all five axes.
|
||||
|
||||
Two halves, both always visible because they answer different questions:
|
||||
|
||||
* the **parameter form** edits one axis at a time (units, speed, accel, homing)
|
||||
and applies it to the controller;
|
||||
* the **state table** shows every axis at once — enable, position in user units
|
||||
and millimetres, motion, homing — with that axis's actions in the last column.
|
||||
|
||||
Keeping the whole platform in one table is a deliberate change from the vendor
|
||||
program, which acts on whichever axis number happens to be typed in a box. On a
|
||||
five-axis platform, acting on the wrong axis is the expensive mistake.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion import (
|
||||
DEFAULT_ACCEL,
|
||||
DEFAULT_SPEED,
|
||||
DEFAULT_UNITS,
|
||||
AxisParams,
|
||||
AxisState,
|
||||
Snapshot,
|
||||
safe_float,
|
||||
user_units_to_mm,
|
||||
)
|
||||
|
||||
#: Data columns in the state table, in display order.
|
||||
#:
|
||||
#: Seven, not thirteen. Matching the full frame of a drive here made the middle
|
||||
#: columns illegible at a real window width, and a table nobody can read protects
|
||||
#: nobody. What does not fit is shown for the *selected* axis on the detail line
|
||||
#: below the table — see :meth:`AxisPanel._detail_text`.
|
||||
COLUMNS = (
|
||||
"axes.axis",
|
||||
"axes.enable",
|
||||
"axes.units",
|
||||
"axes.speed",
|
||||
"axes.dpos",
|
||||
"axes.dpos_mm",
|
||||
"axes.status",
|
||||
)
|
||||
|
||||
#: The actions column follows the data columns.
|
||||
ACTIONS_COLUMN = len(COLUMNS)
|
||||
|
||||
#: Row height in pixels. Must fit the per-row buttons: a QTableWidget does not
|
||||
#: derive its row height from a cell widget, and the shared stylesheet gives a
|
||||
#: button 28 px of minimum height plus 16 px of padding (44 px total) unless it
|
||||
#: carries the ``rail`` property, which the theme defines as the compact variant.
|
||||
ROW_HEIGHT = 36
|
||||
|
||||
#: How many axis rows the panel insists on showing without scrolling. Set to all
|
||||
#: of them: on a five-axis platform a clipped fifth row reads as a missing drive.
|
||||
#: This is also what makes the splitter give the panel the height it needs — Qt
|
||||
#: distributes a splitter from the children's minimum sizes, so a smaller value
|
||||
#: here means a scrollbar instead of five visible axes.
|
||||
MIN_VISIBLE_ROWS = 5
|
||||
|
||||
#: Height of the small parameter fields. Pinned so the parameter form keeps to
|
||||
#: two rows: the fields' natural height pushes that form to ~136 px, which costs
|
||||
#: one and a half axis rows in the table below it.
|
||||
FIELD_HEIGHT = 28
|
||||
|
||||
#: Column widths by index. With the project's thirteen columns the widths had to
|
||||
#: shrink below the headers; seven can each be comfortable.
|
||||
_COLUMN_WIDTHS = {
|
||||
0: 46,
|
||||
1: 96,
|
||||
2: 100,
|
||||
3: 100,
|
||||
4: 130,
|
||||
5: 130,
|
||||
6: 130,
|
||||
ACTIONS_COLUMN: 230,
|
||||
}
|
||||
|
||||
|
||||
def parse_optional_float(text: str, fallback: float) -> float:
|
||||
"""A numeric field's value, or the fallback when it is blank/unusable."""
|
||||
if str(text).strip() == "":
|
||||
return fallback
|
||||
return safe_float(text, fallback)
|
||||
|
||||
|
||||
class AxisPanel(QGroupBox):
|
||||
"""Parameters plus live state for the platform's axes."""
|
||||
|
||||
paramsApplied = Signal(int, object)
|
||||
enableToggled = Signal(int, bool)
|
||||
jogRequested = Signal(int, bool)
|
||||
stopRequested = Signal(int)
|
||||
stopAllRequested = Signal()
|
||||
moveAbsRequested = Signal(int, float)
|
||||
moveRelRequested = Signal(int, float)
|
||||
homeRequested = Signal(int)
|
||||
clearAlarmRequested = Signal(int)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
axis_count: int,
|
||||
params: dict[int, AxisParams] | None = None,
|
||||
*,
|
||||
units_per_mm: float = DEFAULT_UNITS,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("groupBox")
|
||||
self.axis_count = int(axis_count)
|
||||
self._params = dict(params or {})
|
||||
self._units_per_mm = units_per_mm
|
||||
self._connected = False
|
||||
self._bus_ready = False
|
||||
self._available_axes = 0
|
||||
self._selected_axis = 0
|
||||
self._snapshot = Snapshot()
|
||||
self._rows: list[dict] = []
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
outer.setSpacing(6)
|
||||
|
||||
outer.addWidget(self._build_params())
|
||||
outer.addWidget(self._build_target_row())
|
||||
# Stretch factor 1: the table is the only part of this panel that may
|
||||
# grow, so every spare pixel goes to a further axis row rather than to
|
||||
# padding around the parameter form.
|
||||
outer.addWidget(self._build_table(), 1)
|
||||
outer.addWidget(self._build_actions())
|
||||
|
||||
self.retranslate()
|
||||
self._reread_params()
|
||||
# Controls start disabled and a snapshot enables them. Qt's default for
|
||||
# a new widget is enabled, and whether a poll happens to arrive before
|
||||
# the window is shown is a race — so the safe state is set explicitly
|
||||
# here rather than inferred from the first snapshot.
|
||||
self._refresh_availability()
|
||||
|
||||
# ------------------------------------------------------------------ widgets
|
||||
|
||||
def _build_params(self) -> QWidget:
|
||||
box = QWidget(self)
|
||||
grid = QGridLayout(box)
|
||||
grid.setContentsMargins(0, 0, 0, 0)
|
||||
grid.setHorizontalSpacing(8)
|
||||
grid.setVerticalSpacing(4)
|
||||
|
||||
def label(key: str) -> QLabel:
|
||||
widget = QLabel(box)
|
||||
widget.setObjectName("hintLabel")
|
||||
return widget
|
||||
|
||||
def edit(width: int) -> QLineEdit:
|
||||
widget = QLineEdit(box)
|
||||
widget.setFixedWidth(width)
|
||||
widget.setFixedHeight(FIELD_HEIGHT)
|
||||
return widget
|
||||
|
||||
self.axis_label = label("axes.axis")
|
||||
self.axis_combo = QWidget(box)
|
||||
self.axis_index_label = QLabel("0", box)
|
||||
self.axis_index_label.setObjectName("statusValue")
|
||||
|
||||
self.units_label = label("axes.units")
|
||||
self.units_edit = edit(80)
|
||||
self.speed_label = label("axes.speed")
|
||||
self.speed_edit = edit(80)
|
||||
self.accel_label = label("axes.accel")
|
||||
self.accel_edit = edit(90)
|
||||
|
||||
self.home_mode_label = label("axes.home_mode")
|
||||
self.home_mode_edit = edit(60)
|
||||
self.home_high_label = label("axes.home_high")
|
||||
self.home_high_edit = edit(60)
|
||||
self.home_low_label = label("axes.home_low")
|
||||
self.home_low_edit = edit(60)
|
||||
self.home_offset_label = label("axes.home_offset")
|
||||
self.home_offset_edit = edit(60)
|
||||
|
||||
self.apply_button = QPushButton(box)
|
||||
self.apply_button.setObjectName("primaryButton")
|
||||
self.apply_button.setProperty("rail", True)
|
||||
self.apply_button.setFixedHeight(FIELD_HEIGHT)
|
||||
self.apply_button.clicked.connect(self._on_apply)
|
||||
self.home_button = QPushButton(box)
|
||||
self.home_button.setProperty("rail", True)
|
||||
self.home_button.setFixedHeight(FIELD_HEIGHT)
|
||||
self.home_button.clicked.connect(lambda: self.homeRequested.emit(self.selected_axis))
|
||||
self.clear_alarm_button = QPushButton(box)
|
||||
self.clear_alarm_button.setProperty("rail", True)
|
||||
self.clear_alarm_button.setFixedHeight(FIELD_HEIGHT)
|
||||
self.clear_alarm_button.clicked.connect(
|
||||
lambda: self.clearAlarmRequested.emit(self.selected_axis)
|
||||
)
|
||||
|
||||
grid.addWidget(self.axis_label, 0, 0)
|
||||
grid.addWidget(self.axis_index_label, 0, 1)
|
||||
grid.addWidget(self.units_label, 0, 2)
|
||||
grid.addWidget(self.units_edit, 0, 3)
|
||||
grid.addWidget(self.speed_label, 0, 4)
|
||||
grid.addWidget(self.speed_edit, 0, 5)
|
||||
grid.addWidget(self.accel_label, 0, 6)
|
||||
grid.addWidget(self.accel_edit, 0, 7)
|
||||
|
||||
grid.addWidget(self.home_mode_label, 1, 0)
|
||||
grid.addWidget(self.home_mode_edit, 1, 1)
|
||||
grid.addWidget(self.home_high_label, 1, 2)
|
||||
grid.addWidget(self.home_high_edit, 1, 3)
|
||||
grid.addWidget(self.home_low_label, 1, 4)
|
||||
grid.addWidget(self.home_low_edit, 1, 5)
|
||||
grid.addWidget(self.home_offset_label, 1, 6)
|
||||
grid.addWidget(self.home_offset_edit, 1, 7)
|
||||
|
||||
grid.addWidget(self.apply_button, 2, 0, 1, 2)
|
||||
grid.addWidget(self.home_button, 2, 2, 1, 2)
|
||||
grid.addWidget(self.clear_alarm_button, 2, 4, 1, 2)
|
||||
return box
|
||||
|
||||
def _build_target_row(self) -> QWidget:
|
||||
box = QWidget(self)
|
||||
row = QHBoxLayout(box)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(8)
|
||||
self.target_label = QLabel(box)
|
||||
self.target_edit = QLineEdit(box)
|
||||
self.target_edit.setFixedWidth(120)
|
||||
self.move_abs_button = QPushButton(box)
|
||||
self.move_abs_button.clicked.connect(self._on_move_abs)
|
||||
self.move_rel_button = QPushButton(box)
|
||||
self.move_rel_button.clicked.connect(self._on_move_rel)
|
||||
self.mm_hint = QLabel(box)
|
||||
self.mm_hint.setObjectName("hintLabel")
|
||||
self.mm_hint.setWordWrap(True)
|
||||
row.addWidget(self.target_label)
|
||||
row.addWidget(self.target_edit)
|
||||
row.addWidget(self.move_abs_button)
|
||||
row.addWidget(self.move_rel_button)
|
||||
row.addStretch(1)
|
||||
row.addWidget(self.mm_hint)
|
||||
return box
|
||||
|
||||
def _build_table(self) -> QWidget:
|
||||
self.table = QTableWidget(self.axis_count, len(COLUMNS) + 1, self)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
||||
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self.table.setAlternatingRowColors(True)
|
||||
for column in range(len(COLUMNS)):
|
||||
self.table.setColumnWidth(column, _COLUMN_WIDTHS.get(column, 84))
|
||||
self.table.setColumnWidth(ACTIONS_COLUMN, _COLUMN_WIDTHS[ACTIONS_COLUMN])
|
||||
# The per-row buttons live in a cell widget, and Qt does not size a row
|
||||
# to its cell widgets. Without an explicit section height the row stays
|
||||
# at the font height and the buttons are clipped.
|
||||
self.table.verticalHeader().setDefaultSectionSize(ROW_HEIGHT)
|
||||
# A minimum rather than the full five rows: demanding 5*ROW_HEIGHT plus a
|
||||
# header forces the panel to be taller than its share of a 900 px window,
|
||||
# and the splitter then clips the last drive — which reads as a missing
|
||||
# axis. The table scrolls if it is ever squeezed below this.
|
||||
self.table.setMinimumHeight(40 + ROW_HEIGHT * MIN_VISIBLE_ROWS)
|
||||
self.table.cellClicked.connect(self._on_cell_clicked)
|
||||
|
||||
for row in range(self.axis_count):
|
||||
self._rows.append(self._build_row(row))
|
||||
return self.table
|
||||
|
||||
def _build_row(self, row: int) -> dict:
|
||||
for column in range(len(COLUMNS)):
|
||||
item = QTableWidgetItem("")
|
||||
if column >= 2:
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.table.setItem(row, column, item)
|
||||
|
||||
enable_button = QPushButton(t("axes.enable_off"), self.table)
|
||||
enable_button.setCheckable(True)
|
||||
enable_button.setProperty("rail", True)
|
||||
enable_button.setMinimumWidth(84)
|
||||
enable_button.setFixedHeight(28)
|
||||
enable_button.clicked.connect(
|
||||
lambda checked, axis=row: self.enableToggled.emit(axis, bool(checked))
|
||||
)
|
||||
self.table.setCellWidget(row, 1, enable_button)
|
||||
|
||||
controls = QWidget(self.table)
|
||||
layout = QHBoxLayout(controls)
|
||||
layout.setContentsMargins(2, 2, 2, 2)
|
||||
layout.setSpacing(4)
|
||||
|
||||
def make(key: str, slot) -> QPushButton:
|
||||
button = QPushButton(t(key), controls)
|
||||
# "rail" is the theme's compact button: without it the shared
|
||||
# stylesheet forces 44 px, which no longer fits the row.
|
||||
button.setProperty("rail", True)
|
||||
button.setMinimumWidth(56)
|
||||
button.setFixedHeight(28)
|
||||
button.clicked.connect(lambda _checked=False, a=row: slot(a))
|
||||
layout.addWidget(button)
|
||||
return button
|
||||
|
||||
self.table.setCellWidget(row, ACTIONS_COLUMN, controls)
|
||||
return {
|
||||
"enable_button": enable_button,
|
||||
"fwd_button": make("axes.jog_fwd", lambda axis: self.jogRequested.emit(axis, True)),
|
||||
"rev_button": make("axes.jog_rev", lambda axis: self.jogRequested.emit(axis, False)),
|
||||
"stop_button": make("axes.stop", self.stopRequested.emit),
|
||||
}
|
||||
|
||||
def _build_actions(self) -> QWidget:
|
||||
box = QWidget(self)
|
||||
column = QVBoxLayout(box)
|
||||
column.setContentsMargins(0, 0, 0, 0)
|
||||
column.setSpacing(4)
|
||||
|
||||
self._detail_label = QLabel(t("axes.detail_none", axis=0), box)
|
||||
self._detail_label.setObjectName("hintLabel")
|
||||
self._detail_label.setWordWrap(True)
|
||||
column.addWidget(self._detail_label)
|
||||
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(8)
|
||||
self.stop_all_button = QPushButton(box)
|
||||
self.stop_all_button.setObjectName("dangerButton")
|
||||
self.stop_all_button.clicked.connect(self.stopAllRequested)
|
||||
self.enabled_count_label = QLabel(box)
|
||||
self.enabled_count_label.setObjectName("hintLabel")
|
||||
row.addWidget(self.stop_all_button)
|
||||
row.addStretch(1)
|
||||
row.addWidget(self.enabled_count_label)
|
||||
column.addLayout(row)
|
||||
return box
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
@property
|
||||
def selected_axis(self) -> int:
|
||||
"""The axis the parameter form applies to."""
|
||||
return self._selected_axis
|
||||
|
||||
def set_selected_axis(self, axis: int) -> None:
|
||||
self._selected_axis = max(0, min(int(axis), self.axis_count - 1))
|
||||
self.axis_index_label.setText(str(self._selected_axis))
|
||||
self._reread_params()
|
||||
if hasattr(self, "_detail_label"):
|
||||
self._detail_label.setText(self._detail_text())
|
||||
|
||||
def set_connected(self, connected: bool) -> None:
|
||||
self._connected = bool(connected)
|
||||
self._refresh_availability()
|
||||
|
||||
def set_axis_count(self, count: int) -> None:
|
||||
"""Dim rows for axes the bus did not report (a missing drive)."""
|
||||
self._available_axes = max(1, min(int(count), self.axis_count))
|
||||
self._refresh_availability()
|
||||
self._recolour_rows()
|
||||
|
||||
def _axis_present(self, row: int) -> bool:
|
||||
return not self._available_axes or row < self._available_axes
|
||||
|
||||
def _refresh_availability(self) -> None:
|
||||
"""Recompute every control's enabled state from connection + bus."""
|
||||
connected = self._connected
|
||||
for widget in (
|
||||
self.units_edit,
|
||||
self.speed_edit,
|
||||
self.accel_edit,
|
||||
self.home_mode_edit,
|
||||
self.home_high_edit,
|
||||
self.home_low_edit,
|
||||
self.home_offset_edit,
|
||||
self.target_edit,
|
||||
self.apply_button,
|
||||
self.home_button,
|
||||
self.clear_alarm_button,
|
||||
self.move_abs_button,
|
||||
self.move_rel_button,
|
||||
self.stop_all_button,
|
||||
):
|
||||
widget.setEnabled(connected)
|
||||
|
||||
for row, widgets in enumerate(self._rows):
|
||||
present = self._axis_present(row)
|
||||
widgets["enable_button"].setEnabled(connected and present)
|
||||
widgets["stop_button"].setEnabled(connected and present)
|
||||
# Jogging additionally requires an initialised bus.
|
||||
for key in ("fwd_button", "rev_button"):
|
||||
widgets[key].setEnabled(connected and present and self._bus_ready)
|
||||
|
||||
def _recolour_rows(self) -> None:
|
||||
"""Dim the data cells of axes the bus did not report."""
|
||||
for row in range(self.axis_count):
|
||||
present = self._axis_present(row)
|
||||
for column in range(len(COLUMNS)):
|
||||
item = self.table.item(row, column)
|
||||
if item is not None:
|
||||
item.setForeground(_row_brush(present))
|
||||
|
||||
def update_snapshot(self, snapshot: Snapshot) -> None:
|
||||
"""Render one poll: values, motion state, and the enable buttons."""
|
||||
self._connected = snapshot.connected
|
||||
bus_axes = int(snapshot.bus.total_axes)
|
||||
if bus_axes:
|
||||
self._available_axes = max(1, min(bus_axes, self.axis_count))
|
||||
self._bus_ready = snapshot.bus.initialized
|
||||
|
||||
for row in range(self.axis_count):
|
||||
state = snapshot.axis(row)
|
||||
if state is None:
|
||||
continue
|
||||
values = {
|
||||
0: str(row),
|
||||
1: t("axes.enable_on") if state.enabled else t("axes.enable_off"),
|
||||
2: _number(state.units, 3),
|
||||
3: _number(state.speed, 1),
|
||||
4: _number(state.dpos, 3),
|
||||
5: _millimetres(state.units, state.dpos),
|
||||
6: _status_text(state),
|
||||
}
|
||||
for column, text in values.items():
|
||||
item = self.table.item(row, column)
|
||||
if item is not None:
|
||||
item.setText(text)
|
||||
button = self._rows[row]["enable_button"]
|
||||
button.setChecked(bool(state.enabled))
|
||||
button.setText(t("axes.enable_on") if state.enabled else t("axes.enable_off"))
|
||||
if state.status.home_in_progress:
|
||||
button.setToolTip(t("axes.home_running"))
|
||||
elif state.status.alarm:
|
||||
button.setToolTip(t("action.failed"))
|
||||
else:
|
||||
button.setToolTip("")
|
||||
|
||||
self._snapshot = snapshot
|
||||
detail = self._detail_label
|
||||
detail.setText(self._detail_text())
|
||||
enabled = sum(
|
||||
1
|
||||
for row in range(self.axis_count)
|
||||
if (snapshot.axis(row) is not None and snapshot.axis(row).enabled)
|
||||
)
|
||||
self.enabled_count_label.setText(
|
||||
t("axes.enabled_count", on=enabled, total=self.axis_count)
|
||||
)
|
||||
self._refresh_availability()
|
||||
self._recolour_rows()
|
||||
|
||||
def _detail_text(self) -> str:
|
||||
"""Everything about the selected axis that the seven columns cannot hold.
|
||||
|
||||
The table is deliberately narrow enough to read; this line is where the
|
||||
drive's full frame goes — axis type, acceleration, feedback position, the
|
||||
raw status word, and homing.
|
||||
"""
|
||||
state = self._snapshot.axis(self._selected_axis)
|
||||
if state is None:
|
||||
return t("axes.detail_none", axis=self._selected_axis)
|
||||
return t(
|
||||
"axes.detail",
|
||||
axis=self._selected_axis,
|
||||
atype=state.atype,
|
||||
accel=_number(state.accel, 1),
|
||||
mpos=_number(state.mpos, 3),
|
||||
mpos_mm=_millimetres(state.units, state.mpos),
|
||||
status=state.status.raw,
|
||||
home=_home_text(state),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- actions
|
||||
|
||||
def _on_cell_clicked(self, row: int, _column: int) -> None:
|
||||
self.set_selected_axis(row)
|
||||
|
||||
def _reread_params(self) -> None:
|
||||
params = self._params.get(self._selected_axis, AxisParams())
|
||||
self.units_edit.setText(_number(params.units, 3))
|
||||
self.speed_edit.setText(_number(params.speed, 1))
|
||||
self.accel_edit.setText(_number(params.accel, 1))
|
||||
self.home_mode_edit.setText(str(int(params.home_mode)))
|
||||
self.home_high_edit.setText(_number(params.home_high, 1))
|
||||
self.home_low_edit.setText(_number(params.home_low, 1))
|
||||
self.home_offset_edit.setText(_number(params.home_offset, 3))
|
||||
|
||||
def current_params(self) -> AxisParams:
|
||||
"""The form's values, with unusable entries replaced by defaults."""
|
||||
return AxisParams(
|
||||
units=parse_optional_float(self.units_edit.text(), DEFAULT_UNITS),
|
||||
speed=parse_optional_float(self.speed_edit.text(), DEFAULT_SPEED),
|
||||
accel=parse_optional_float(self.accel_edit.text(), DEFAULT_ACCEL),
|
||||
creep=parse_optional_float(self.home_low_edit.text(), 10.0),
|
||||
home_mode=int(parse_optional_float(self.home_mode_edit.text(), 0)),
|
||||
home_high=parse_optional_float(self.home_high_edit.text(), 50.0),
|
||||
home_low=parse_optional_float(self.home_low_edit.text(), 10.0),
|
||||
home_offset=parse_optional_float(self.home_offset_edit.text(), 0.0),
|
||||
)
|
||||
|
||||
def stored_params(self) -> dict[int, AxisParams]:
|
||||
"""Every axis's parameters, for persistence."""
|
||||
return dict(self._params)
|
||||
|
||||
def _on_apply(self) -> None:
|
||||
params = self.current_params()
|
||||
self._params[self._selected_axis] = params
|
||||
self.paramsApplied.emit(self._selected_axis, params)
|
||||
|
||||
def _on_move_abs(self) -> None:
|
||||
value = self._target_value()
|
||||
if value is not None:
|
||||
self.moveAbsRequested.emit(self.selected_axis, value)
|
||||
|
||||
def _on_move_rel(self) -> None:
|
||||
value = self._target_value()
|
||||
if value is not None:
|
||||
self.moveRelRequested.emit(self.selected_axis, value)
|
||||
|
||||
def _target_value(self) -> float | None:
|
||||
text = self.target_edit.text().strip()
|
||||
if text == "":
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(t("axes.title"))
|
||||
self.axis_label.setText(t("axes.axis"))
|
||||
self.units_label.setText(t("axes.units"))
|
||||
self.speed_label.setText(t("axes.speed"))
|
||||
self.accel_label.setText(t("axes.accel"))
|
||||
self.home_mode_label.setText(t("axes.home_mode"))
|
||||
self.home_high_label.setText(t("axes.home_high"))
|
||||
self.home_low_label.setText(t("axes.home_low"))
|
||||
self.home_offset_label.setText(t("axes.home_offset"))
|
||||
self.apply_button.setText(t("axes.apply"))
|
||||
self.home_button.setText(t("axes.home"))
|
||||
self.clear_alarm_button.setText(t("axes.clear_alarm"))
|
||||
self.target_label.setText(t("axes.target"))
|
||||
self.move_abs_button.setText(t("axes.move_abs"))
|
||||
self.move_rel_button.setText(t("axes.move_rel"))
|
||||
self.stop_all_button.setText(t("axes.stop_all"))
|
||||
self.mm_hint.setText(t("axes.mm_derived_tip"))
|
||||
if hasattr(self, "_detail_label"):
|
||||
self._detail_label.setText(self._detail_text())
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
[t(key) for key in COLUMNS] + [t("axes.controls")]
|
||||
)
|
||||
for row in range(self.axis_count):
|
||||
button = self._rows[row]["enable_button"]
|
||||
button.setText(t("axes.enable_on") if button.isChecked() else t("axes.enable_off"))
|
||||
self._rows[row]["fwd_button"].setText(t("axes.jog_fwd"))
|
||||
self._rows[row]["rev_button"].setText(t("axes.jog_rev"))
|
||||
self._rows[row]["stop_button"].setText(t("axes.stop"))
|
||||
|
||||
|
||||
def _number(value: float, places: int) -> str:
|
||||
try:
|
||||
return f"{float(value):.{places}f}"
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
|
||||
def _millimetres(units: float, user_units: float) -> str:
|
||||
"""The derived millimetre reading, or a dash when it cannot be derived."""
|
||||
if not units:
|
||||
return "—"
|
||||
millimetres = user_units_to_mm(user_units, units=units)
|
||||
if millimetres != millimetres: # NaN
|
||||
return "—"
|
||||
return f"{millimetres:.3f}"
|
||||
|
||||
|
||||
def _home_text(state: AxisState) -> str:
|
||||
if state.status.home_in_progress:
|
||||
return t("axes.home_running")
|
||||
return t("axes.home_ok") if state.home_status == 1 else t("axes.home_pending")
|
||||
|
||||
|
||||
def _status_text(state: AxisState) -> str:
|
||||
"""One column that says what an operator needs to know at a glance.
|
||||
|
||||
Alarm outranks motion outranks homing, because that is the order in which
|
||||
they matter when you are looking at the row for a second.
|
||||
"""
|
||||
if state.status.alarm:
|
||||
return t("status.alarm_short")
|
||||
if not state.idle:
|
||||
return t("axes.moving")
|
||||
return _home_text(state)
|
||||
|
||||
|
||||
def _row_brush(present: bool) -> QBrush:
|
||||
"""Dim a row whose drive the bus did not report."""
|
||||
return QBrush(QColor("#d0d0d0" if present else "#8a8a8a"))
|
||||
@@ -0,0 +1,170 @@
|
||||
"""The permanent axis readout rail.
|
||||
|
||||
Read-only by design. Every per-axis *command* lives in the Axis-debug tab, so
|
||||
there is exactly one place a move can be issued from — two places to jog from is
|
||||
how an operator moves the wrong axis. What this rail adds is the thing the
|
||||
operator needs while working in any other tab: whether each drive is enabled,
|
||||
where it is, and whether it is moving.
|
||||
|
||||
The three global actions are here rather than in the tab because they are the
|
||||
ones you reach for in a hurry, and they must not require finding a tab first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
|
||||
from ..motion import Snapshot, user_units_to_mm
|
||||
|
||||
#: Columns of the compact readout: axis, enable state, position.
|
||||
_COLUMNS = 3
|
||||
|
||||
#: Tone -> colour token, as elsewhere.
|
||||
_TONE_TOKENS = {"ok": "accent", "warn": "warn", "error": "danger", "muted": "text_muted"}
|
||||
|
||||
|
||||
class AxisReadoutPanel(QGroupBox):
|
||||
"""Five axis rows plus the platform-wide actions."""
|
||||
|
||||
enableAllRequested = Signal(bool)
|
||||
stopAllRequested = Signal()
|
||||
clearAllRequested = Signal()
|
||||
|
||||
def __init__(self, axis_count: int, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("groupBox")
|
||||
self.axis_count = int(axis_count)
|
||||
self._theme = theme
|
||||
self._connected = False
|
||||
self._snapshot = Snapshot()
|
||||
self._rows: list[dict] = []
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
outer.setSpacing(6)
|
||||
|
||||
grid = QGridLayout()
|
||||
grid.setContentsMargins(0, 0, 0, 0)
|
||||
grid.setHorizontalSpacing(8)
|
||||
grid.setVerticalSpacing(4)
|
||||
for row in range(self.axis_count):
|
||||
grid.addWidget(self._build_row(row), row, 0)
|
||||
outer.addLayout(grid)
|
||||
|
||||
outer.addStretch(1)
|
||||
|
||||
self.enable_all_button = QPushButton(self)
|
||||
self.enable_all_button.clicked.connect(self._on_enable_all)
|
||||
self.stop_all_button = QPushButton(self)
|
||||
self.stop_all_button.setObjectName("dangerButton")
|
||||
self.stop_all_button.clicked.connect(self.stopAllRequested)
|
||||
self.clear_all_button = QPushButton(self)
|
||||
self.clear_all_button.clicked.connect(self.clearAllRequested)
|
||||
for button in (self.enable_all_button, self.stop_all_button, self.clear_all_button):
|
||||
button.setProperty("rail", True)
|
||||
outer.addWidget(button)
|
||||
|
||||
self.retranslate()
|
||||
self.set_connected(False)
|
||||
|
||||
def _build_row(self, axis: int) -> QWidget:
|
||||
box = QWidget(self)
|
||||
row = QHBoxLayout(box)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(6)
|
||||
|
||||
index = QLabel(str(axis), box)
|
||||
index.setObjectName("axisIndex")
|
||||
index.setFixedWidth(16)
|
||||
|
||||
state = QLabel("", box)
|
||||
state.setObjectName("axisState")
|
||||
state.setFixedWidth(46)
|
||||
|
||||
position = QLabel("", box)
|
||||
position.setObjectName("axisPosition")
|
||||
|
||||
row.addWidget(index)
|
||||
row.addWidget(state)
|
||||
row.addWidget(position, 1)
|
||||
self._rows.append({"state": state, "position": position})
|
||||
return box
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
self.update_snapshot(self._snapshot)
|
||||
|
||||
def set_connected(self, connected: bool) -> None:
|
||||
self._connected = bool(connected)
|
||||
for button in (self.enable_all_button, self.stop_all_button, self.clear_all_button):
|
||||
button.setEnabled(self._connected)
|
||||
|
||||
def update_snapshot(self, snapshot: Snapshot) -> None:
|
||||
self._snapshot = snapshot
|
||||
self.set_connected(snapshot.connected)
|
||||
|
||||
for axis in range(self.axis_count):
|
||||
state = snapshot.axis(axis)
|
||||
widgets = self._rows[axis]
|
||||
if state is None:
|
||||
widgets["state"].setText(t("axes.enable_off"))
|
||||
widgets["position"].setText("—")
|
||||
widgets["state"].setStyleSheet(self._colour("muted"))
|
||||
continue
|
||||
|
||||
if state.status.alarm:
|
||||
widgets["state"].setText(t("status.alarm_short"))
|
||||
widgets["state"].setStyleSheet(self._colour("error"))
|
||||
elif state.enabled:
|
||||
widgets["state"].setText(t("axes.enable_on"))
|
||||
widgets["state"].setStyleSheet(self._colour("ok"))
|
||||
else:
|
||||
widgets["state"].setText(t("axes.enable_off"))
|
||||
widgets["state"].setStyleSheet(self._colour("muted"))
|
||||
|
||||
millimetres = user_units_to_mm(state.dpos, units=state.units) if state.units else None
|
||||
units_text = f"{state.dpos:.3f}"
|
||||
if millimetres is not None and millimetres == millimetres:
|
||||
units_text = t("axes.position_pair", value=units_text, mm=f"{millimetres:.3f}")
|
||||
widgets["position"].setText(units_text)
|
||||
widgets["position"].setToolTip(
|
||||
t("axes.motion_word") + ": " + (t("axes.moving") if not state.idle else t("axes.idle"))
|
||||
+ (" · " + t("axes.home_ok") if state.home_status == 1 else "")
|
||||
)
|
||||
|
||||
enabled_now = sum(1 for s in snapshot.axes if s.enabled)
|
||||
self.enable_all_button.setText(
|
||||
t("axes.disable_all") if enabled_now == self.axis_count else t("axes.enable_all")
|
||||
)
|
||||
|
||||
def _colour(self, tone: str) -> str:
|
||||
colour = load_colors(self._theme).get(_TONE_TOKENS.get(tone, "text_muted"), "#888888")
|
||||
return f"color: {colour};"
|
||||
|
||||
def _on_enable_all(self) -> None:
|
||||
enabled_now = sum(1 for s in self._snapshot.axes if s.enabled)
|
||||
# Toggle: if every drive is already on, this turns them all off.
|
||||
self.enableAllRequested.emit(enabled_now != self.axis_count)
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(t("readout.title"))
|
||||
self.stop_all_button.setText(t("axes.stop_all"))
|
||||
self.clear_all_button.setText(t("axes.clear_alarm_all"))
|
||||
# The toggle's label depends on current state, so re-derive it.
|
||||
self.update_snapshot(self._snapshot)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Top identity strip: brand, product name, and the connection state chip.
|
||||
|
||||
Mirrors the studio's brand bar so the two products look like one family, but
|
||||
carries only what this tool needs: who made it, what it is, and whether the
|
||||
controller is reachable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
from orisys_app_shared.theme import load_colors
|
||||
|
||||
#: Chip tone -> colour token name.
|
||||
_TONE_TOKENS = {
|
||||
"ok": "accent",
|
||||
"warn": "warn",
|
||||
"error": "danger",
|
||||
"muted": "text_muted",
|
||||
}
|
||||
|
||||
|
||||
class BrandBar(QFrame):
|
||||
"""The identity strip, with a state chip that reports the connection."""
|
||||
|
||||
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("brandBar")
|
||||
self.setFixedHeight(40)
|
||||
self._theme = theme
|
||||
self._connected = False
|
||||
# The chip's current content, kept so a theme or locale change can
|
||||
# re-render the same state without the caller re-stating it.
|
||||
self._chip_text = ""
|
||||
self._chip_tone = "muted"
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(12, 4, 12, 4)
|
||||
layout.setSpacing(10)
|
||||
|
||||
self.logo = QLabel(self)
|
||||
self.logo.setObjectName("brandLogo")
|
||||
pixmap = QPixmap(str(logo_topbar_path()))
|
||||
if not pixmap.isNull():
|
||||
self.logo.setPixmap(
|
||||
pixmap.scaledToHeight(24, Qt.TransformationMode.SmoothTransformation)
|
||||
)
|
||||
layout.addWidget(self.logo)
|
||||
|
||||
self.name = QLabel(self)
|
||||
self.name.setObjectName("brandName")
|
||||
layout.addWidget(self.name)
|
||||
|
||||
self.detail = QLabel(self)
|
||||
self.detail.setObjectName("brandDetail")
|
||||
layout.addWidget(self.detail)
|
||||
|
||||
layout.addStretch(1)
|
||||
|
||||
self.product = QLabel(self)
|
||||
self.product.setObjectName("brandProduct")
|
||||
layout.addWidget(self.product)
|
||||
|
||||
self.state_chip = QLabel(self)
|
||||
self.state_chip.setObjectName("stateChip")
|
||||
layout.addWidget(self.state_chip)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
# ----------------------------------------------------------------- content
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Refresh every label from the active locale."""
|
||||
self.name.setText(t("brand.name"))
|
||||
self.detail.setText(t("brand.detail"))
|
||||
self.product.setText(t("app.title"))
|
||||
self.set_connected(self._connected)
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
self.set_state_text(self._chip_text, self._chip_tone)
|
||||
|
||||
def set_connected(self, connected: bool) -> None:
|
||||
self._connected = bool(connected)
|
||||
self.set_state_text(
|
||||
t("connection.state_open") if connected else t("connection.state_closed"),
|
||||
"ok" if connected else "muted",
|
||||
)
|
||||
|
||||
def set_state_text(self, text: str, tone: str = "muted") -> None:
|
||||
self._chip_text = text
|
||||
self._chip_tone = tone
|
||||
self.state_chip.setText(text)
|
||||
token = _TONE_TOKENS.get(tone, "text_muted")
|
||||
color = load_colors(self._theme).get(token, "#888888")
|
||||
self.state_chip.setStyleSheet(
|
||||
f"color: {color}; border: 1px solid {color}; "
|
||||
"border-radius: 9px; padding: 1px 8px;"
|
||||
)
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Raw bus exchange: EtherCAT object dictionary (SDO) and RTEX parameters.
|
||||
|
||||
This is the drive-level panel. It is what a commissioning engineer reaches for
|
||||
when a node behaves oddly: read an object, see what the drive actually has, write
|
||||
one back. The controls are enabled strictly by the bus family the ``.bas``
|
||||
program reported, so an SDO write can never be aimed at an RTEX system (the
|
||||
vendor reference guards this too, by checking ``BUS_TYPE`` before every call).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
#: Height of this panel's fields and buttons. Pinned for the same reason as the
|
||||
#: axis panel's: an unconstrained QLineEdit is ~34 px, and four of them stacked
|
||||
#: push this panel tall enough to steal an axis row from the panel above it.
|
||||
FIELD_HEIGHT = 28
|
||||
|
||||
#: Width of the read/write buttons. A grid stretches a widget across the column
|
||||
#: it spans, and these share a row with unconstrained columns, so without a cap
|
||||
#: "读" renders as a 300 px slab.
|
||||
ACTION_WIDTH = 96
|
||||
|
||||
#: Data types accepted by ``ZAux_BusCmd_SDORead``/``SDOWrite``, in combo order.
|
||||
#: The DLL argument is the index + 1 (see ``sdo_data_type``).
|
||||
SDO_TYPES = (
|
||||
("int8", 1),
|
||||
("int16", 2),
|
||||
("int32", 3),
|
||||
("uint8", 4),
|
||||
("uint16", 5),
|
||||
("uint32", 6),
|
||||
)
|
||||
|
||||
|
||||
def sdo_data_type(index: int) -> int:
|
||||
"""Map a combo index to the DLL's data-type argument."""
|
||||
if 0 <= index < len(SDO_TYPES):
|
||||
return SDO_TYPES[index][1]
|
||||
return SDO_TYPES[0][1]
|
||||
|
||||
|
||||
class BusDataPanel(QGroupBox):
|
||||
"""SDO and RTEX read/write."""
|
||||
|
||||
sdoReadRequested = Signal(int, int, int, int)
|
||||
sdoWriteRequested = Signal(int, int, int, int, int)
|
||||
rtexReadRequested = Signal(int, int)
|
||||
rtexWriteRequested = Signal(int, int, float)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sdo_last: dict | None = None,
|
||||
rtex_last: dict | None = None,
|
||||
axis_count: int = 5,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("groupBox")
|
||||
self._axis_count = int(axis_count)
|
||||
sdo_last = sdo_last or {}
|
||||
rtex_last = rtex_last or {}
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
outer.setSpacing(8)
|
||||
|
||||
outer.addWidget(self._build_sdo(sdo_last))
|
||||
outer.addWidget(self._build_rtex(rtex_last))
|
||||
|
||||
self.result_label = QLabel(self)
|
||||
self.result_label.setObjectName("hintLabel")
|
||||
self.result_label.setWordWrap(True)
|
||||
outer.addWidget(self.result_label)
|
||||
|
||||
self.retranslate()
|
||||
# Disabled until a snapshot says otherwise: a read or write control that
|
||||
# is briefly live on a disconnected panel invites a click that cannot
|
||||
# work, and Qt's default for a new widget is enabled.
|
||||
self.set_bus_type("unknown")
|
||||
self._refresh_readonly()
|
||||
|
||||
# ------------------------------------------------------------------ widgets
|
||||
|
||||
def _build_sdo(self, last: dict) -> QWidget:
|
||||
self.ecat_box = QGroupBox(self)
|
||||
grid = QGridLayout(self.ecat_box)
|
||||
grid.setContentsMargins(10, 6, 10, 6)
|
||||
grid.setHorizontalSpacing(8)
|
||||
grid.setVerticalSpacing(4)
|
||||
|
||||
def field(text: str, width: int) -> QLineEdit:
|
||||
widget = QLineEdit(str(text), self.ecat_box)
|
||||
widget.setFixedWidth(width)
|
||||
widget.setFixedHeight(FIELD_HEIGHT)
|
||||
return widget
|
||||
|
||||
def button(text: str) -> QPushButton:
|
||||
widget = QPushButton(text, self.ecat_box)
|
||||
widget.setProperty("rail", True)
|
||||
widget.setFixedHeight(FIELD_HEIGHT)
|
||||
widget.setMaximumWidth(ACTION_WIDTH)
|
||||
return widget
|
||||
|
||||
self.node_label = QLabel(self.ecat_box)
|
||||
self.node_edit = field(last.get("node", 0), 60)
|
||||
self.index_label = QLabel(self.ecat_box)
|
||||
self.index_edit = field(last.get("index", 24704), 80)
|
||||
self.subindex_label = QLabel(self.ecat_box)
|
||||
self.subindex_edit = field(last.get("subindex", 0), 50)
|
||||
self.sdo_type_label = QLabel(self.ecat_box)
|
||||
self.sdo_type_combo = QComboBox(self.ecat_box)
|
||||
self.sdo_type_combo.setFixedHeight(FIELD_HEIGHT)
|
||||
for name, _value in SDO_TYPES:
|
||||
self.sdo_type_combo.addItem(name)
|
||||
self.sdo_type_combo.setCurrentIndex(int(last.get("type", 0)) % len(SDO_TYPES))
|
||||
self.sdo_value_label = QLabel(self.ecat_box)
|
||||
self.sdo_value_edit = field(0, 90)
|
||||
self.btn_sdo_read = button("")
|
||||
self.btn_sdo_read.clicked.connect(self._emit_sdo_read)
|
||||
self.btn_sdo_write = button("")
|
||||
self.btn_sdo_write.clicked.connect(self._emit_sdo_write)
|
||||
|
||||
grid.addWidget(self.node_label, 0, 0)
|
||||
grid.addWidget(self.node_edit, 0, 1)
|
||||
grid.addWidget(self.index_label, 0, 2)
|
||||
grid.addWidget(self.index_edit, 0, 3)
|
||||
grid.addWidget(self.subindex_label, 0, 4)
|
||||
grid.addWidget(self.subindex_edit, 0, 5)
|
||||
grid.addWidget(self.sdo_type_label, 0, 6)
|
||||
grid.addWidget(self.sdo_type_combo, 0, 7)
|
||||
grid.addWidget(self.sdo_value_label, 0, 8)
|
||||
grid.addWidget(self.sdo_value_edit, 0, 9)
|
||||
grid.addWidget(self.btn_sdo_read, 1, 7)
|
||||
grid.addWidget(self.btn_sdo_write, 1, 9)
|
||||
grid.setColumnStretch(10, 1)
|
||||
return self.ecat_box
|
||||
|
||||
def _build_rtex(self, last: dict) -> QWidget:
|
||||
self.rtex_box = QGroupBox(self)
|
||||
grid = QGridLayout(self.rtex_box)
|
||||
grid.setContentsMargins(10, 6, 10, 6)
|
||||
grid.setHorizontalSpacing(8)
|
||||
grid.setVerticalSpacing(4)
|
||||
|
||||
def field(text: str, width: int) -> QLineEdit:
|
||||
widget = QLineEdit(str(text), self.rtex_box)
|
||||
widget.setFixedWidth(width)
|
||||
widget.setFixedHeight(FIELD_HEIGHT)
|
||||
return widget
|
||||
|
||||
def button(text: str) -> QPushButton:
|
||||
widget = QPushButton(text, self.rtex_box)
|
||||
widget.setProperty("rail", True)
|
||||
widget.setFixedHeight(FIELD_HEIGHT)
|
||||
return widget
|
||||
|
||||
self.rtex_axis_label = QLabel(self.rtex_box)
|
||||
self.rtex_axis_edit = field(last.get("axis", 0), 50)
|
||||
self.rtex_parameter_label = QLabel(self.rtex_box)
|
||||
self.rtex_parameter_edit = field(last.get("parameter", 0), 90)
|
||||
self.rtex_value_label = QLabel(self.rtex_box)
|
||||
self.rtex_value_edit = field(0, 90)
|
||||
self.btn_rtex_read = button("")
|
||||
self.btn_rtex_read.clicked.connect(self._emit_rtex_read)
|
||||
self.btn_rtex_write = button("")
|
||||
self.btn_rtex_write.clicked.connect(self._emit_rtex_write)
|
||||
|
||||
grid.addWidget(self.rtex_axis_label, 0, 0)
|
||||
grid.addWidget(self.rtex_axis_edit, 0, 1)
|
||||
grid.addWidget(self.rtex_parameter_label, 0, 2)
|
||||
grid.addWidget(self.rtex_parameter_edit, 0, 3)
|
||||
grid.addWidget(self.rtex_value_label, 0, 4)
|
||||
grid.addWidget(self.rtex_value_edit, 0, 5)
|
||||
grid.addWidget(self.btn_rtex_read, 0, 6)
|
||||
grid.addWidget(self.btn_rtex_write, 0, 7)
|
||||
grid.setColumnStretch(8, 1)
|
||||
return self.rtex_box
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
def set_bus_type(self, bus_type: str) -> None:
|
||||
"""Enable only the panel matching the bus the controller reported."""
|
||||
self._bus_type = bus_type
|
||||
supports_sdo = bus_type == "ecat"
|
||||
supports_rtex = bus_type == "rtex"
|
||||
self.ecat_box.setEnabled(supports_sdo)
|
||||
self.rtex_box.setEnabled(supports_rtex)
|
||||
self._refresh_readonly()
|
||||
|
||||
_bus_type = "unknown"
|
||||
|
||||
def set_connected(self, connected: bool) -> None:
|
||||
self._connected = bool(connected)
|
||||
self._refresh_readonly()
|
||||
|
||||
_connected = False
|
||||
|
||||
def _refresh_readonly(self) -> None:
|
||||
# Re-applying the bus type is what actually gates the boxes; a
|
||||
# disconnected session disables both regardless of family.
|
||||
if not self._connected:
|
||||
self.ecat_box.setEnabled(False)
|
||||
self.rtex_box.setEnabled(False)
|
||||
return
|
||||
self.ecat_box.setEnabled(self._bus_type == "ecat")
|
||||
self.rtex_box.setEnabled(self._bus_type == "rtex")
|
||||
|
||||
def set_result(self, text: str) -> None:
|
||||
self.result_label.setText(text)
|
||||
|
||||
def sdo_request(self) -> tuple[int, int, int, int]:
|
||||
"""The SDO fields as the service expects them."""
|
||||
return (
|
||||
self._int(self.node_edit.text()),
|
||||
self._int(self.index_edit.text()),
|
||||
self._int(self.subindex_edit.text()),
|
||||
sdo_data_type(self.sdo_type_combo.currentIndex()),
|
||||
)
|
||||
|
||||
def rtex_request(self) -> tuple[int, int]:
|
||||
return (
|
||||
self._int(self.rtex_axis_edit.text()),
|
||||
self._int(self.rtex_parameter_edit.text()),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _int(text: str) -> int:
|
||||
try:
|
||||
return int(str(text).strip() or 0, 0)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def remember_state(self) -> dict[str, dict]:
|
||||
"""The field values the preference file should keep."""
|
||||
node, index, subindex, data_type = self.sdo_request()
|
||||
axis, parameter = self.rtex_request()
|
||||
type_index = next(
|
||||
(i for i, (_name, value) in enumerate(SDO_TYPES) if value == data_type), 0
|
||||
)
|
||||
return {
|
||||
"sdo_last": {
|
||||
"node": node,
|
||||
"index": index,
|
||||
"subindex": subindex,
|
||||
"type": type_index,
|
||||
},
|
||||
"rtex_last": {"axis": axis, "parameter": parameter},
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------- actions
|
||||
|
||||
def _emit_sdo_read(self) -> None:
|
||||
self.sdoReadRequested.emit(*self.sdo_request())
|
||||
|
||||
def _emit_sdo_write(self) -> None:
|
||||
node, index, subindex, data_type = self.sdo_request()
|
||||
self.sdoWriteRequested.emit(
|
||||
node, index, subindex, data_type, self._int(self.sdo_value_edit.text())
|
||||
)
|
||||
|
||||
def _emit_rtex_read(self) -> None:
|
||||
self.rtexReadRequested.emit(*self.rtex_request())
|
||||
|
||||
def _emit_rtex_write(self) -> None:
|
||||
axis, parameter = self.rtex_request()
|
||||
try:
|
||||
value = float(self.rtex_value_edit.text())
|
||||
except ValueError:
|
||||
self.set_result(t("busdata.not_available"))
|
||||
return
|
||||
self.rtexWriteRequested.emit(axis, parameter, value)
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(t("busdata.title"))
|
||||
self.ecat_box.setTitle(t("busdata.ecat"))
|
||||
self.rtex_box.setTitle(t("busdata.rtex"))
|
||||
self.node_label.setText(t("busdata.node"))
|
||||
self.index_label.setText(t("busdata.index"))
|
||||
self.subindex_label.setText(t("busdata.subindex"))
|
||||
self.sdo_type_label.setText(t("busdata.type"))
|
||||
self.sdo_value_label.setText(t("busdata.value"))
|
||||
self.btn_sdo_read.setText(t("busdata.read"))
|
||||
self.btn_sdo_write.setText(t("busdata.write"))
|
||||
self.rtex_axis_label.setText(t("busdata.axis"))
|
||||
self.rtex_parameter_label.setText(t("busdata.parameter"))
|
||||
self.rtex_value_label.setText(t("busdata.value"))
|
||||
self.btn_rtex_read.setText(t("busdata.read"))
|
||||
self.btn_rtex_write.setText(t("busdata.write"))
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Bus initialisation: pick a BAS program, run it, and read the result.
|
||||
|
||||
The sequence is the vendor's, not ours: ``BasDown`` writes the program to ROM
|
||||
and the controller runs it; the program delays, scans the slot, maps axes, and
|
||||
finally publishes ``BUS_TYPE``, ``Bus_InitStatus``, ``Bus_TotalAxisnum`` and the
|
||||
node count. This panel is a view onto that state machine plus the two buttons
|
||||
that (re)start it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import app_root
|
||||
|
||||
from ..motion import INIT_FAILED, INIT_OK, INIT_RUNNING, BusStatus
|
||||
|
||||
_STATE_KEYS = {
|
||||
INIT_RUNNING: "bus.state_running",
|
||||
INIT_OK: "bus.state_ok",
|
||||
INIT_FAILED: "bus.state_failed",
|
||||
}
|
||||
|
||||
_STATE_TONES = {
|
||||
"bus.state_running": "warn",
|
||||
"bus.state_ok": "ok",
|
||||
"bus.state_failed": "error",
|
||||
"bus.state_timeout": "error",
|
||||
}
|
||||
|
||||
#: The vendor requires a non-Chinese path for a BAS project; default the picker
|
||||
#: at the shipped copies so the operator starts somewhere legal.
|
||||
DEFAULT_BAS_DIR_NAME = "bas"
|
||||
|
||||
|
||||
def default_bas_dir() -> Path:
|
||||
return app_root() / "config" / DEFAULT_BAS_DIR_NAME
|
||||
|
||||
|
||||
class BusPanel(QGroupBox):
|
||||
"""BAS download, re-initialisation, and decoded bus state."""
|
||||
|
||||
chooseRequested = Signal()
|
||||
downloadRequested = Signal()
|
||||
reinitRequested = Signal()
|
||||
|
||||
def __init__(self, bas_path: str = "", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("groupBox")
|
||||
self._bas_path = bas_path
|
||||
self._state_text = t("bus.state_pending")
|
||||
self._state_tone = "muted"
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
outer.setSpacing(8)
|
||||
|
||||
grid = QGridLayout()
|
||||
grid.setHorizontalSpacing(8)
|
||||
grid.setVerticalSpacing(6)
|
||||
|
||||
self.path_label = QLabel(self)
|
||||
self.path_edit = QLineEdit(self)
|
||||
self.path_edit.setReadOnly(True)
|
||||
self.path_edit.setText(bas_path)
|
||||
self.choose_button = QPushButton(self)
|
||||
self.choose_button.clicked.connect(self.chooseRequested)
|
||||
grid.addWidget(self.path_label, 0, 0)
|
||||
grid.addWidget(self.path_edit, 0, 1, 1, 2)
|
||||
grid.addWidget(self.choose_button, 0, 3)
|
||||
|
||||
self.state_label = QLabel(self)
|
||||
self.state_value = QLabel(self)
|
||||
self.state_value.setObjectName("statusValue")
|
||||
grid.addWidget(self.state_label, 1, 0)
|
||||
grid.addWidget(self.state_value, 1, 1, 1, 3)
|
||||
|
||||
self.type_label = QLabel(self)
|
||||
self.type_value = QLabel(self)
|
||||
self.type_value.setObjectName("statusValue")
|
||||
grid.addWidget(self.type_label, 2, 0)
|
||||
grid.addWidget(self.type_value, 2, 1, 1, 3)
|
||||
|
||||
self.nodes_label = QLabel(self)
|
||||
self.nodes_value = QLabel(self)
|
||||
self.nodes_value.setObjectName("statusValue")
|
||||
self.axes_label = QLabel(self)
|
||||
self.axes_value = QLabel(self)
|
||||
self.axes_value.setObjectName("statusValue")
|
||||
grid.addWidget(self.nodes_label, 3, 0)
|
||||
grid.addWidget(self.nodes_value, 3, 1)
|
||||
grid.addWidget(self.axes_label, 3, 2)
|
||||
grid.addWidget(self.axes_value, 3, 3)
|
||||
|
||||
outer.addLayout(grid)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
self.download_button = QPushButton(self)
|
||||
self.download_button.setObjectName("primaryButton")
|
||||
self.download_button.clicked.connect(self.downloadRequested)
|
||||
self.reinit_button = QPushButton(self)
|
||||
self.reinit_button.clicked.connect(self.reinitRequested)
|
||||
buttons.addWidget(self.download_button)
|
||||
buttons.addWidget(self.reinit_button)
|
||||
buttons.addStretch(1)
|
||||
outer.addLayout(buttons)
|
||||
|
||||
self.message_label = QLabel(self)
|
||||
self.message_label.setObjectName("hintLabel")
|
||||
self.message_label.setWordWrap(True)
|
||||
outer.addWidget(self.message_label)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
@property
|
||||
def bas_path(self) -> str:
|
||||
return self._bas_path
|
||||
|
||||
def set_bas_path(self, path: str) -> None:
|
||||
self._bas_path = path
|
||||
self.path_edit.setText(path)
|
||||
|
||||
def set_connected(self, connected: bool) -> None:
|
||||
self.download_button.setEnabled(connected)
|
||||
self.reinit_button.setEnabled(connected)
|
||||
self.choose_button.setEnabled(True)
|
||||
|
||||
def set_status(self, bus: BusStatus, *, now: float | None = None) -> None:
|
||||
"""Render the bus state, including the vendor's timeout case."""
|
||||
key = _STATE_KEYS.get(bus.init_status, "bus.state_pending")
|
||||
if bus.init_status == INIT_FAILED and bus.message and "超时" in bus.message:
|
||||
key = "bus.state_timeout"
|
||||
self._state_text = t(key)
|
||||
self._state_tone = _STATE_TONES.get(key, "muted")
|
||||
self.state_value.setText(self._state_text)
|
||||
|
||||
type_keys = {
|
||||
"ecat": "bus.type_ecat",
|
||||
"rtex": "bus.type_rtex",
|
||||
"unknown": "bus.type_unknown",
|
||||
}
|
||||
self.type_value.setText(t(type_keys.get(bus.bus_type, "bus.type_unknown")))
|
||||
self.nodes_value.setText(str(bus.node_count))
|
||||
self.axes_value.setText(str(bus.total_axes))
|
||||
self.message_label.setText(bus.message)
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(t("bus.title"))
|
||||
self.path_label.setText(t("bus.bas_path"))
|
||||
self.state_label.setText(t("bus.state"))
|
||||
self.type_label.setText(t("bus.type"))
|
||||
self.nodes_label.setText(t("bus.nodes"))
|
||||
self.axes_label.setText(t("bus.axes"))
|
||||
self.choose_button.setText(t("bus.choose"))
|
||||
self.download_button.setText(t("bus.download"))
|
||||
self.reinit_button.setText(t("bus.reinit"))
|
||||
self.state_value.setText(self._state_text)
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Connection controls: mode, address, scan, connect, and connection state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion import ConnectionSpec
|
||||
from ..preferences import CONNECTION_MODES, DEFAULT_CONTROLLER_IPS
|
||||
|
||||
#: Modes that are wired up. The rest report "not available yet" rather than
|
||||
#: pretending to connect: a serial/PCI path that silently does nothing is worse
|
||||
#: than one that says it is not there.
|
||||
_IMPLEMENTED_MODES = ("eth", "local", "demo")
|
||||
|
||||
_MODE_LABEL_KEYS = {
|
||||
"eth": "connection.mode_eth",
|
||||
"com": "connection.mode_com",
|
||||
"pci": "connection.mode_pci",
|
||||
"local": "connection.mode_local",
|
||||
"demo": "connection.mode_demo",
|
||||
}
|
||||
|
||||
|
||||
class ConnectionPanel(QGroupBox):
|
||||
"""Everything needed to reach (or leave) the controller."""
|
||||
|
||||
connectRequested = Signal(object)
|
||||
disconnectRequested = Signal()
|
||||
scanRequested = Signal()
|
||||
|
||||
def __init__(self, spec: ConnectionSpec, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("groupBox")
|
||||
self._spec = spec
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
outer.setSpacing(8)
|
||||
|
||||
grid = QGridLayout()
|
||||
grid.setHorizontalSpacing(8)
|
||||
grid.setVerticalSpacing(6)
|
||||
|
||||
self.mode_label = QLabel(self)
|
||||
self.mode_combo = QComboBox(self)
|
||||
for mode in CONNECTION_MODES:
|
||||
self.mode_combo.addItem("", mode)
|
||||
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
|
||||
grid.addWidget(self.mode_label, 0, 0)
|
||||
grid.addWidget(self.mode_combo, 0, 1, 1, 2)
|
||||
|
||||
self.address_label = QLabel(self)
|
||||
self.address_edit = QLineEdit(self)
|
||||
self.address_edit.setMinimumWidth(150)
|
||||
self.scan_button = QPushButton(self)
|
||||
self.scan_button.clicked.connect(self.scanRequested)
|
||||
grid.addWidget(self.address_label, 1, 0)
|
||||
grid.addWidget(self.address_edit, 1, 1)
|
||||
grid.addWidget(self.scan_button, 1, 2)
|
||||
|
||||
self.ip_hint = QLabel(self)
|
||||
self.ip_hint.setObjectName("hintLabel")
|
||||
grid.addWidget(self.ip_hint, 2, 1, 1, 2)
|
||||
|
||||
outer.addLayout(grid)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
self.open_button = QPushButton(self)
|
||||
self.open_button.setObjectName("primaryButton")
|
||||
self.open_button.clicked.connect(self._emit_connect)
|
||||
self.close_button = QPushButton(self)
|
||||
self.close_button.clicked.connect(self.disconnectRequested)
|
||||
self.close_button.setEnabled(False)
|
||||
buttons.addWidget(self.open_button)
|
||||
buttons.addWidget(self.close_button)
|
||||
buttons.addStretch(1)
|
||||
outer.addLayout(buttons)
|
||||
|
||||
self.status_label = QLabel(self)
|
||||
self.status_label.setObjectName("statusValue")
|
||||
self.status_label.setWordWrap(True)
|
||||
outer.addWidget(self.status_label)
|
||||
|
||||
self.source_label = QLabel(self)
|
||||
self.source_label.setObjectName("hintLabel")
|
||||
self.source_label.setWordWrap(True)
|
||||
outer.addWidget(self.source_label)
|
||||
|
||||
self.retranslate()
|
||||
self.apply_spec(spec)
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
@property
|
||||
def spec(self) -> ConnectionSpec:
|
||||
"""The spec the fields currently describe."""
|
||||
mode = self.mode_combo.currentData() or "eth"
|
||||
if mode == "eth":
|
||||
return ConnectionSpec(mode="eth", ip=self.address_edit.text().strip())
|
||||
if mode == "local":
|
||||
return ConnectionSpec(mode="local", local_string=self.address_edit.text().strip() or "0")
|
||||
if mode == "com":
|
||||
return ConnectionSpec(mode="com", com_id=self._as_int(self.address_edit.text()))
|
||||
if mode == "pci":
|
||||
return ConnectionSpec(mode="pci", card_num=self._as_int(self.address_edit.text()))
|
||||
return ConnectionSpec(mode="demo")
|
||||
|
||||
@staticmethod
|
||||
def _as_int(text: str) -> int:
|
||||
try:
|
||||
return int(str(text).strip() or 0)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def apply_spec(self, spec: ConnectionSpec) -> None:
|
||||
self._spec = spec
|
||||
index = self.mode_combo.findData(spec.mode)
|
||||
if index >= 0:
|
||||
self.mode_combo.setCurrentIndex(index)
|
||||
self._fill_address(spec)
|
||||
self._on_mode_changed()
|
||||
|
||||
def _fill_address(self, spec: ConnectionSpec) -> None:
|
||||
if spec.mode == "eth":
|
||||
self.address_edit.setText(spec.ip or DEFAULT_CONTROLLER_IPS[0])
|
||||
elif spec.mode == "com":
|
||||
self.address_edit.setText(str(spec.com_id))
|
||||
elif spec.mode == "pci":
|
||||
self.address_edit.setText(str(spec.card_num))
|
||||
elif spec.mode == "local":
|
||||
self.address_edit.setText(spec.local_string or "0")
|
||||
else:
|
||||
self.address_edit.setText("")
|
||||
|
||||
def set_ips(self, ips: list[str]) -> None:
|
||||
"""Offer scanned addresses for the Ethernet mode."""
|
||||
if self.mode_combo.currentData() != "eth" or not ips:
|
||||
return
|
||||
current = self.address_edit.text().strip()
|
||||
if current and current not in ips:
|
||||
return
|
||||
self.ip_hint.setText(" ".join(ips[:4]))
|
||||
|
||||
def set_connected(self, connected: bool, source: str = "") -> None:
|
||||
self.open_button.setEnabled(not connected)
|
||||
self.close_button.setEnabled(connected)
|
||||
self.mode_combo.setEnabled(not connected)
|
||||
self.address_edit.setEnabled(not connected)
|
||||
self.status_label.setText(
|
||||
t("connection.state_open") if connected else t("connection.state_closed")
|
||||
)
|
||||
self.status_label.setObjectName("statusValueOk" if connected else "statusValue")
|
||||
self.source_label.setText(source)
|
||||
# Re-apply the stylesheet so the object-name change takes effect.
|
||||
self.status_label.style().unpolish(self.status_label)
|
||||
self.status_label.style().polish(self.status_label)
|
||||
|
||||
def set_status(self, text: str) -> None:
|
||||
self.status_label.setText(text)
|
||||
|
||||
# ----------------------------------------------------------------- actions
|
||||
|
||||
def _emit_connect(self) -> None:
|
||||
mode = self.mode_combo.currentData() or "eth"
|
||||
if mode not in _IMPLEMENTED_MODES:
|
||||
self.set_status(t("connection.no_more", mode=t(_MODE_LABEL_KEYS[mode])))
|
||||
return
|
||||
self.connectRequested.emit(self.spec)
|
||||
|
||||
def _on_mode_changed(self) -> None:
|
||||
mode = self.mode_combo.currentData() or "eth"
|
||||
keys = {
|
||||
"eth": ("connection.ip", True),
|
||||
"com": ("connection.com", True),
|
||||
"pci": ("connection.card", True),
|
||||
"local": ("connection.local_string", True),
|
||||
"demo": ("connection.mode", False),
|
||||
}
|
||||
label_key, editable = keys.get(mode, ("connection.ip", True))
|
||||
self.address_label.setText(t(label_key))
|
||||
self.address_edit.setEnabled(editable)
|
||||
self.scan_button.setEnabled(mode == "eth")
|
||||
self.ip_hint.setText("")
|
||||
if mode == "com":
|
||||
self.address_edit.setText(str(self._spec.com_id))
|
||||
elif mode == "pci":
|
||||
self.address_edit.setText(str(self._spec.card_num))
|
||||
elif mode == "local":
|
||||
self.address_edit.setText(self._spec.local_string or "0")
|
||||
elif mode == "eth":
|
||||
self.address_edit.setText(self._spec.ip or DEFAULT_CONTROLLER_IPS[0])
|
||||
else:
|
||||
self.address_edit.setText("")
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(t("connection.title"))
|
||||
self.mode_label.setText(t("connection.mode"))
|
||||
for index, mode in enumerate(CONNECTION_MODES):
|
||||
self.mode_combo.setItemText(index, t(_MODE_LABEL_KEYS[mode]))
|
||||
self.scan_button.setText(t("connection.scan"))
|
||||
self.open_button.setText(t("connection.open"))
|
||||
self.close_button.setText(t("connection.close"))
|
||||
self._on_mode_changed()
|
||||
@@ -0,0 +1,487 @@
|
||||
"""Test-sequence design: what points on the sensor get tested (workflow P5).
|
||||
|
||||
Three zones, because point generation is three questions that must be answered
|
||||
together:
|
||||
|
||||
* **profile** — the shape and the motion: grid mode, pitch and margin, standoff
|
||||
heights, approach and press speeds, press depth, dwell, settle window,
|
||||
repeats, visiting order. Persisted, so a bench profile is entered once.
|
||||
Laid out as two columns of six fields: one column of twelve wasted the vertical
|
||||
space the point list needs, and the list is the part that grows.
|
||||
* **preview** — the sensor surface with the generated points and the order they
|
||||
will be visited. Reserved space for the 3D view; today it says so rather than
|
||||
pretending.
|
||||
* **validation + point table** — every generated point is checked three ways
|
||||
(on the surface, inside travel, contact confirmable) and carries a run status,
|
||||
so the design and the run progress live in the same list. The list is a
|
||||
``QTableWidget`` because it has to hold hundreds of points and scroll; the
|
||||
previous fixed grid could only ever show three rows.
|
||||
|
||||
Nothing here moves the platform yet. The controls that would generate a point
|
||||
set are present and bound to the profile, but generation, simulation and the run
|
||||
are later phases — and :meth:`SequencePanel.planned_steps` still returns an empty
|
||||
list so a runner cannot start from a table that was never validated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..preferences import POINT_ORDERS, SURFACE_MODES
|
||||
|
||||
#: Profile fields, in form order: ``(key, locale_key, kind)``.
|
||||
#: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit).
|
||||
PROFILE_FIELDS: tuple[tuple[str, str, str], ...] = (
|
||||
("mode", "sequence.field_mode", "choice"),
|
||||
("pitch_mm", "sequence.field_pitch", "number"),
|
||||
("margin_mm", "sequence.field_margin", "number"),
|
||||
("radius_mm", "sequence.field_radius", "number"),
|
||||
("clear_height_mm", "sequence.field_clear_height", "number"),
|
||||
("approach_speed_mm_s", "sequence.field_approach_speed", "number"),
|
||||
("press_speed_mm_s", "sequence.field_press_speed", "number"),
|
||||
("press_depth_mm", "sequence.field_press_depth", "number"),
|
||||
("dwell_s", "sequence.field_dwell", "number"),
|
||||
("settle_window_s", "sequence.field_settle", "number"),
|
||||
("repeats", "sequence.field_repeats", "number"),
|
||||
("order", "sequence.field_order", "choice"),
|
||||
)
|
||||
|
||||
#: How many rows the two-column profile form has.
|
||||
PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2
|
||||
|
||||
#: Point-table columns: ``(key, locale_key)``.
|
||||
POINT_COLUMNS: tuple[tuple[str, str], ...] = (
|
||||
("index", "sequence.col_index"),
|
||||
("x", "sequence.col_x"),
|
||||
("y", "sequence.col_y"),
|
||||
("z", "sequence.col_z"),
|
||||
("status", "sequence.col_status"),
|
||||
("sensitivity", "sequence.col_sensitivity"),
|
||||
("samples", "sequence.col_samples"),
|
||||
("actions", "sequence.col_actions"),
|
||||
)
|
||||
|
||||
#: Editability of the point table. Positions are *generated*, not typed: a typed
|
||||
#: coordinate is one that nothing has checked against the sensor.
|
||||
_EDITABLE_POINT_COLUMNS: tuple[str, ...] = ()
|
||||
|
||||
#: Column widths of the point table, by column index.
|
||||
_POINT_COLUMN_WIDTHS = (50, 130, 130, 130, 120, 160, 90, 200)
|
||||
|
||||
#: Reserved controls and the phase that will fill each one.
|
||||
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run", "save", "load")
|
||||
|
||||
#: Which phase each pending control belongs to (see docs/workflow.md).
|
||||
PENDING_PHASES: dict[str, str] = {
|
||||
"generate": "P5",
|
||||
"simulate": "P6",
|
||||
"dry_run": "P6",
|
||||
"save": "P8",
|
||||
"load": "P8",
|
||||
}
|
||||
|
||||
#: The validation checks, with the locale key naming what each one proves.
|
||||
VALIDATION_CHECKS: tuple[tuple[str, str], ...] = (
|
||||
("on_surface", "sequence.check_surface"),
|
||||
("within_travel", "sequence.check_travel"),
|
||||
("contact_confirmable", "sequence.check_contact"),
|
||||
)
|
||||
|
||||
#: Placeholder rows, so the flow shows its shape. Marked as unmeasured: a row
|
||||
#: with no status is a design, not a measurement.
|
||||
_EXAMPLE_POINTS: tuple[tuple[float, float, float], ...] = (
|
||||
(0.0, 0.0, 12.4),
|
||||
(2.0, 0.0, 12.4),
|
||||
(4.0, 0.0, 12.4),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SequenceStep:
|
||||
"""One planned step, as the future runner will receive it.
|
||||
|
||||
``x``/``y``/``z`` are platform-frame user units (millimetres are shown beside
|
||||
them), ``repeat`` is a count, and ``force_n`` is ``None`` until FT feedback
|
||||
exists. ``sensor_xyz`` is the point's position in the sensor frame, computed
|
||||
from the P4 transform — the thing that makes it checkable against the surface.
|
||||
"""
|
||||
|
||||
index: int
|
||||
targets: dict[str, float]
|
||||
sensor_xyz: tuple[float, float, float] | None = None
|
||||
repeat: int = 1
|
||||
force_n: float | None = None
|
||||
dwell_s: float = 0.0
|
||||
note: str = ""
|
||||
|
||||
|
||||
class SequencePanel(QGroupBox):
|
||||
"""Workflow P5: profile, preview, validation and the point list."""
|
||||
|
||||
generateRequested = Signal()
|
||||
simulateRequested = Signal()
|
||||
dryRunRequested = Signal()
|
||||
saveRequested = Signal()
|
||||
loadRequested = Signal()
|
||||
|
||||
def __init__(self, profile: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("groupBox")
|
||||
self._profile_fields: dict[str, QWidget] = {}
|
||||
self._profile_labels: list[QLabel] = []
|
||||
self._choices: dict[str, QComboBox] = {}
|
||||
self._pending_buttons: dict[str, QPushButton] = {}
|
||||
self._check_rows: dict[str, QLabel] = {}
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
outer.setSpacing(8)
|
||||
|
||||
zones = QHBoxLayout()
|
||||
zones.setSpacing(10)
|
||||
zones.addWidget(self._build_profile(), 0)
|
||||
zones.addWidget(self._build_preview(), 1)
|
||||
zones.addWidget(self._build_validation(), 0)
|
||||
outer.addLayout(zones, 0)
|
||||
|
||||
# The point list is the part that grows, so it takes the vertical stretch
|
||||
# and the zones above it get what they need.
|
||||
outer.addWidget(self._build_point_table(), 1)
|
||||
|
||||
self.set_profile(profile or {})
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------ profile
|
||||
|
||||
def _build_profile(self) -> QWidget:
|
||||
box = QGroupBox(self)
|
||||
box.setObjectName("groupBox")
|
||||
grid = QGridLayout(box)
|
||||
grid.setContentsMargins(10, 8, 10, 8)
|
||||
grid.setHorizontalSpacing(10)
|
||||
grid.setVerticalSpacing(4)
|
||||
|
||||
# Two fields per row: label, field, label, field. Seeing the whole profile
|
||||
# at once is what lets an operator sanity-check a pitch against a depth.
|
||||
for index, (key, _locale_key, kind) in enumerate(PROFILE_FIELDS):
|
||||
row, side = divmod(index, 2)
|
||||
column = side * 2
|
||||
label = QLabel(box)
|
||||
label.setObjectName("hintLabel")
|
||||
grid.addWidget(label, row, column)
|
||||
self._profile_labels.append(label)
|
||||
|
||||
if kind == "choice":
|
||||
field: QWidget = QComboBox(box)
|
||||
field.setFixedHeight(28)
|
||||
self._choices[key] = field # type: ignore[assignment]
|
||||
else:
|
||||
field = QLineEdit(box)
|
||||
field.setFixedHeight(28)
|
||||
field.setProperty("fieldKey", key)
|
||||
grid.addWidget(field, row, column + 1)
|
||||
self._profile_fields[key] = field
|
||||
|
||||
grid.setColumnStretch(1, 1)
|
||||
grid.setColumnStretch(3, 1)
|
||||
self._profile_box = box
|
||||
return box
|
||||
|
||||
# ------------------------------------------------------------------ preview
|
||||
|
||||
def _build_preview(self) -> QWidget:
|
||||
box = QGroupBox(self)
|
||||
box.setObjectName("groupBox")
|
||||
column = QVBoxLayout(box)
|
||||
column.setContentsMargins(10, 8, 10, 8)
|
||||
column.setSpacing(6)
|
||||
|
||||
self.preview_label = QLabel(box)
|
||||
self.preview_label.setObjectName("hintLabel")
|
||||
self.preview_label.setWordWrap(True)
|
||||
self.preview_label.setMinimumHeight(120)
|
||||
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
column.addWidget(self.preview_label, 1)
|
||||
|
||||
self.preview_hint = QLabel(box)
|
||||
self.preview_hint.setObjectName("hintLabel")
|
||||
self.preview_hint.setWordWrap(True)
|
||||
column.addWidget(self.preview_hint)
|
||||
self._preview_box = box
|
||||
return box
|
||||
|
||||
# --------------------------------------------------------------- validation
|
||||
|
||||
def _build_validation(self) -> QWidget:
|
||||
box = QGroupBox(self)
|
||||
box.setObjectName("groupBox")
|
||||
column = QVBoxLayout(box)
|
||||
column.setContentsMargins(10, 8, 10, 8)
|
||||
column.setSpacing(4)
|
||||
|
||||
for key, _locale_key in VALIDATION_CHECKS:
|
||||
row = QLabel(box)
|
||||
row.setObjectName("statusValue")
|
||||
row.setWordWrap(True)
|
||||
column.addWidget(row)
|
||||
self._check_rows[key] = row
|
||||
|
||||
self.summary_label = QLabel(box)
|
||||
self.summary_label.setObjectName("hintLabel")
|
||||
self.summary_label.setWordWrap(True)
|
||||
column.addWidget(self.summary_label)
|
||||
column.addStretch(1)
|
||||
|
||||
for key in PENDING_CONTROLS:
|
||||
button = QPushButton(box)
|
||||
button.setProperty("rail", True)
|
||||
button.setFixedHeight(28)
|
||||
column.addWidget(button)
|
||||
self._pending_buttons[key] = button
|
||||
|
||||
self._pending_buttons["generate"].clicked.connect(self.generateRequested)
|
||||
self._pending_buttons["simulate"].clicked.connect(self.simulateRequested)
|
||||
self._pending_buttons["dry_run"].clicked.connect(self.dryRunRequested)
|
||||
self._pending_buttons["save"].clicked.connect(self.saveRequested)
|
||||
self._pending_buttons["load"].clicked.connect(self.loadRequested)
|
||||
# None of these can run: generation, simulation, the runner and the
|
||||
# dataset writer belong to later phases (see docs/workflow.md).
|
||||
for button in self._pending_buttons.values():
|
||||
button.setEnabled(False)
|
||||
|
||||
self._validation_box = box
|
||||
return box
|
||||
|
||||
# -------------------------------------------------------------- point table
|
||||
|
||||
def _build_point_table(self) -> QWidget:
|
||||
box = QGroupBox(self)
|
||||
box.setObjectName("groupBox")
|
||||
column = QVBoxLayout(box)
|
||||
column.setContentsMargins(10, 8, 10, 8)
|
||||
column.setSpacing(6)
|
||||
|
||||
self.table = QTableWidget(len(_EXAMPLE_POINTS), len(POINT_COLUMNS), box)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
||||
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self.table.setAlternatingRowColors(True)
|
||||
for index, width in enumerate(_POINT_COLUMN_WIDTHS):
|
||||
self.table.setColumnWidth(index, width)
|
||||
self.table.horizontalHeader().setSectionResizeMode(
|
||||
len(POINT_COLUMNS) - 1, QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
# The list scrolls rather than clipping: a grid of fixed labels could
|
||||
# only ever show the rows it was built with.
|
||||
self.table.setMinimumHeight(120)
|
||||
|
||||
for row, (x, y, z) in enumerate(_EXAMPLE_POINTS):
|
||||
values = {
|
||||
0: str(row + 1),
|
||||
1: f"{x:.3f}",
|
||||
2: f"{y:.3f}",
|
||||
3: f"{z:.3f}",
|
||||
4: t("sequence.status_pending"),
|
||||
5: "—",
|
||||
6: "—",
|
||||
7: "",
|
||||
}
|
||||
for column_index, text in values.items():
|
||||
item = QTableWidgetItem(text)
|
||||
if column_index >= 4:
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.table.setItem(row, column_index, item)
|
||||
|
||||
column.addWidget(self.table, 1)
|
||||
|
||||
self.point_hint = QLabel(box)
|
||||
self.point_hint.setObjectName("hintLabel")
|
||||
self.point_hint.setWordWrap(True)
|
||||
column.addWidget(self.point_hint)
|
||||
|
||||
self._table_box = box
|
||||
return box
|
||||
|
||||
# -------------------------------------------------------------------- model
|
||||
|
||||
def profile(self) -> dict[str, Any]:
|
||||
"""The profile as typed, with numbers parsed where they are numbers."""
|
||||
values: dict[str, Any] = {}
|
||||
for key, _locale_key, kind in PROFILE_FIELDS:
|
||||
widget = self._profile_fields[key]
|
||||
if kind == "choice":
|
||||
values[key] = widget.currentData() or widget.currentText()
|
||||
else:
|
||||
values[key] = _as_number(widget.text(), 0.0)
|
||||
values["repeats"] = int(max(1, values.get("repeats", 1)))
|
||||
return values
|
||||
|
||||
def set_profile(self, profile: dict[str, Any]) -> None:
|
||||
"""Load a stored profile; unknown or missing keys keep their default."""
|
||||
for key, _locale_key, kind in PROFILE_FIELDS:
|
||||
if key not in profile:
|
||||
continue
|
||||
widget = self._profile_fields[key]
|
||||
value = profile[key]
|
||||
if kind == "choice":
|
||||
index = widget.findData(value)
|
||||
if index >= 0:
|
||||
widget.setCurrentIndex(index)
|
||||
else:
|
||||
widget.setText(_format_number(value))
|
||||
|
||||
def profile_keys(self) -> list[str]:
|
||||
return [key for key, _locale_key, _kind in PROFILE_FIELDS]
|
||||
|
||||
def field(self, key: str) -> QWidget:
|
||||
return self._profile_fields[key]
|
||||
|
||||
def choice(self, key: str) -> QComboBox:
|
||||
return self._choices[key]
|
||||
|
||||
def pending_controls(self) -> list[str]:
|
||||
return list(PENDING_CONTROLS)
|
||||
|
||||
def pending_phases(self) -> dict[str, str]:
|
||||
return dict(PENDING_PHASES)
|
||||
|
||||
def validation_checks(self) -> list[str]:
|
||||
return [key for key, _locale_key in VALIDATION_CHECKS]
|
||||
|
||||
def check_row(self, key: str) -> QLabel:
|
||||
return self._check_rows[key]
|
||||
|
||||
def set_check(self, key: str, text: str, ok: bool = True) -> None:
|
||||
"""Report one validation result (used once generation exists)."""
|
||||
row = self._check_rows[key]
|
||||
row.setText(text)
|
||||
row.setProperty("checkOk", ok)
|
||||
|
||||
def column_keys(self) -> list[str]:
|
||||
return [key for key, _locale_key in POINT_COLUMNS]
|
||||
|
||||
def point_row(self, index: int) -> dict[str, str]:
|
||||
return {
|
||||
key: (self.table.item(index, column).text() if self.table.item(index, column) else "")
|
||||
for column, (key, _locale_key) in enumerate(POINT_COLUMNS)
|
||||
}
|
||||
|
||||
def point_row_count(self) -> int:
|
||||
return self.table.rowCount()
|
||||
|
||||
def append_point(self, x: float, y: float, z: float, status: str = "") -> int:
|
||||
"""Add a generated point. Used by the generator once P5 exists."""
|
||||
row = self.table.rowCount()
|
||||
self.table.insertRow(row)
|
||||
values = {
|
||||
0: str(row + 1),
|
||||
1: f"{x:.3f}",
|
||||
2: f"{y:.3f}",
|
||||
3: f"{z:.3f}",
|
||||
4: status or t("sequence.status_pending"),
|
||||
5: "—",
|
||||
6: "—",
|
||||
7: "",
|
||||
}
|
||||
for column, text in values.items():
|
||||
item = QTableWidgetItem(text)
|
||||
if column >= 4:
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.table.setItem(row, column, item)
|
||||
return row
|
||||
|
||||
def planned_steps(self) -> list[SequenceStep]:
|
||||
"""The steps the table describes, for the future runner.
|
||||
|
||||
Empty while the tab is a design surface: generation has not run, nothing
|
||||
has been validated, and a runner must not start from a table that was
|
||||
never checked — which is what the P5 gate exists to prevent.
|
||||
"""
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(t("sequence.title"))
|
||||
self._profile_box.setTitle(t("sequence.profile_title"))
|
||||
self._preview_box.setTitle(t("sequence.preview_title"))
|
||||
self._validation_box.setTitle(t("sequence.validation_title"))
|
||||
self._table_box.setTitle(t("sequence.points_title"))
|
||||
|
||||
for label, (_key, locale_key, _kind) in zip(self._profile_labels, PROFILE_FIELDS):
|
||||
label.setText(t(locale_key))
|
||||
for key, combo in self._choices.items():
|
||||
_fill_choice(combo, key)
|
||||
|
||||
self.preview_label.setText(t("sequence.preview_placeholder"))
|
||||
self.preview_hint.setText(t("sequence.preview_hint"))
|
||||
for key, _locale_key in VALIDATION_CHECKS:
|
||||
self._check_rows[key].setText(t(f"sequence.check_{key}") + ": —")
|
||||
self.summary_label.setText(t("sequence.validation_pending"))
|
||||
for key in PENDING_CONTROLS:
|
||||
self._pending_buttons[key].setText(t(f"sequence.btn_{key}"))
|
||||
self._pending_buttons[key].setToolTip(
|
||||
t("sequence.pending_tooltip", phase=PENDING_PHASES[key])
|
||||
)
|
||||
|
||||
self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS])
|
||||
for row in range(self.table.rowCount()):
|
||||
item = self.table.item(row, 4)
|
||||
if item is not None and item.text() in ("", t("sequence.status_pending")):
|
||||
item.setText(t("sequence.status_pending"))
|
||||
self.point_hint.setText(t("sequence.points_hint"))
|
||||
|
||||
|
||||
def _fill_choice(combo: QComboBox, key: str) -> None:
|
||||
"""Populate a choice field from its option list, keeping the selection."""
|
||||
current = combo.currentData()
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
if key == "mode":
|
||||
for value in SURFACE_MODES:
|
||||
combo.addItem(t(f"sequence.mode_{value}"), value)
|
||||
elif key == "order":
|
||||
for value in POINT_ORDERS:
|
||||
combo.addItem(t(f"sequence.order_{value}"), value)
|
||||
combo.blockSignals(False)
|
||||
if current is not None:
|
||||
index = combo.findData(current)
|
||||
if index >= 0:
|
||||
combo.setCurrentIndex(index)
|
||||
|
||||
|
||||
def _as_number(text: str, fallback: float) -> float:
|
||||
try:
|
||||
value = float(str(text).strip())
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
return value if value == value else fallback # reject NaN
|
||||
|
||||
|
||||
def _format_number(value: Any) -> str:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
if number == int(number) and abs(number) < 1e6:
|
||||
return str(int(number))
|
||||
return f"{number:g}"
|
||||
@@ -0,0 +1,424 @@
|
||||
"""The permanent motion status strip, and the run bar beneath it.
|
||||
|
||||
Two rows on purpose:
|
||||
|
||||
* :class:`StatusStrip` — connection, bus and last-action state. This is what must
|
||||
not move: the operator needs to know whether the platform is live while
|
||||
working in any tab, so it sits **above** the workspace tabs and switching tabs
|
||||
cannot change what it says. It also carries the poll-health chip, because a
|
||||
controller that stopped answering looks identical to one that is answering
|
||||
normally unless something shows the age of the last reading.
|
||||
* :class:`RunBar` — a run's phase, progress and controls. Separate from the strip
|
||||
so a run cannot hide the platform state, and because the abort control must be
|
||||
reachable from every tab rather than from inside the tab that started the run.
|
||||
|
||||
Neither widget issues a controller call. The window wires the signals, so there
|
||||
is still one place a command can originate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QProgressBar,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
|
||||
from ..motion import Snapshot
|
||||
|
||||
#: Tone -> colour token. Matches the brand bar's chip vocabulary.
|
||||
_TONE_TOKENS = {
|
||||
"ok": "accent",
|
||||
"warn": "warn",
|
||||
"error": "danger",
|
||||
"muted": "text_muted",
|
||||
}
|
||||
|
||||
#: Bus type -> locale key.
|
||||
_BUS_KEYS = {
|
||||
"ecat": "bus.type_ecat",
|
||||
"rtex": "bus.type_rtex",
|
||||
"unknown": "bus.type_unknown",
|
||||
}
|
||||
|
||||
#: Run phases, and the locale key that names each.
|
||||
RUN_PHASES = {
|
||||
"idle": "run.phase_idle",
|
||||
"dry_run": "run.phase_dry_run",
|
||||
"running": "run.phase_running",
|
||||
"paused": "run.phase_paused",
|
||||
"done": "run.phase_done",
|
||||
"failed": "run.phase_failed",
|
||||
}
|
||||
|
||||
#: A snapshot older than this many poll intervals is stale, and then unhealthy.
|
||||
_STALE_INTERVALS = 3.0
|
||||
_FAILED_INTERVALS = 10.0
|
||||
|
||||
|
||||
class _Chip(QLabel):
|
||||
"""A small toned label: the strip is read at a glance, not studied."""
|
||||
|
||||
def __init__(self, theme: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("statusChip")
|
||||
self._theme = theme
|
||||
self._tone = "muted"
|
||||
self.set_tone("muted")
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
self.set_tone(self._tone)
|
||||
|
||||
def set_tone(self, tone: str) -> None:
|
||||
self._tone = tone
|
||||
colour = load_colors(self._theme).get(_TONE_TOKENS.get(tone, "text_muted"), "#888888")
|
||||
self.setStyleSheet(
|
||||
f"color: {colour}; border: 1px solid {colour}; "
|
||||
"border-radius: 9px; padding: 1px 9px;"
|
||||
)
|
||||
|
||||
|
||||
class StatusStrip(QFrame):
|
||||
"""Connection, bus and last-action state, always on screen."""
|
||||
|
||||
pauseRequested = Signal()
|
||||
stopAllRequested = Signal()
|
||||
|
||||
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("statusStrip")
|
||||
self.setFixedHeight(40)
|
||||
self._theme = theme
|
||||
self._snapshot = Snapshot()
|
||||
self._last_action = ""
|
||||
self._last_action_ok = True
|
||||
self._last_snapshot_at: float | None = None
|
||||
self._poll_interval_ms = 100
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(10, 4, 10, 4)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self.title = QLabel(self)
|
||||
self.title.setObjectName("stripTitle")
|
||||
layout.addWidget(self.title)
|
||||
|
||||
self.chip_connection = _Chip(theme, self)
|
||||
self.chip_bus = _Chip(theme, self)
|
||||
self.chip_init = _Chip(theme, self)
|
||||
self.chip_axes = _Chip(theme, self)
|
||||
self.chip_health = _Chip(theme, self)
|
||||
for chip in (
|
||||
self.chip_connection,
|
||||
self.chip_bus,
|
||||
self.chip_init,
|
||||
self.chip_axes,
|
||||
self.chip_health,
|
||||
):
|
||||
layout.addWidget(chip)
|
||||
|
||||
layout.addStretch(1)
|
||||
|
||||
self.action_label = QLabel(self)
|
||||
self.action_label.setObjectName("stripAction")
|
||||
layout.addWidget(self.action_label)
|
||||
|
||||
self.pause_button = QPushButton(self)
|
||||
self.pause_button.setProperty("rail", True)
|
||||
self.pause_button.setCheckable(True)
|
||||
self.pause_button.clicked.connect(self.pauseRequested)
|
||||
layout.addWidget(self.pause_button)
|
||||
|
||||
self.stop_button = QPushButton(self)
|
||||
self.stop_button.setObjectName("dangerButton")
|
||||
self.stop_button.setProperty("rail", True)
|
||||
self.stop_button.clicked.connect(self.stopAllRequested)
|
||||
layout.addWidget(self.stop_button)
|
||||
|
||||
self.retranslate()
|
||||
self.set_snapshot(Snapshot())
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
for chip in (
|
||||
self.chip_connection,
|
||||
self.chip_bus,
|
||||
self.chip_init,
|
||||
self.chip_axes,
|
||||
self.chip_health,
|
||||
):
|
||||
chip.set_theme(theme)
|
||||
self.chip_health.set_tone(self.chip_health._tone)
|
||||
|
||||
def set_poll_interval_ms(self, interval_ms: int) -> None:
|
||||
self._poll_interval_ms = max(1, int(interval_ms))
|
||||
|
||||
def set_snapshot(self, snapshot: Snapshot) -> None:
|
||||
"""Render one poll. Never raises: this runs on a timer."""
|
||||
self._snapshot = snapshot
|
||||
self._last_snapshot_at = time.monotonic()
|
||||
connected = snapshot.connected
|
||||
|
||||
self.chip_connection.setText(
|
||||
t("connection.state_open") if connected else t("connection.state_closed")
|
||||
)
|
||||
self.chip_connection.set_tone("ok" if connected else "muted")
|
||||
|
||||
self.chip_bus.setText(t(_BUS_KEYS.get(snapshot.bus.bus_type, "bus.type_unknown")))
|
||||
self.chip_bus.set_tone("ok" if snapshot.bus.bus_type != "unknown" else "muted")
|
||||
|
||||
if not connected:
|
||||
self.chip_init.setText(t("bus.state_pending"))
|
||||
self.chip_init.set_tone("muted")
|
||||
elif snapshot.bus.initialized:
|
||||
self.chip_init.setText(t("bus.state_ok"))
|
||||
self.chip_init.set_tone("ok")
|
||||
elif snapshot.bus.init_status == 0:
|
||||
text = (
|
||||
"bus.state_timeout"
|
||||
if "超时" in snapshot.bus.message or "timed out" in snapshot.bus.message
|
||||
else "bus.state_failed"
|
||||
)
|
||||
self.chip_init.setText(t(text))
|
||||
self.chip_init.set_tone("error")
|
||||
else:
|
||||
self.chip_init.setText(t("bus.state_running"))
|
||||
self.chip_init.set_tone("warn")
|
||||
|
||||
enabled = sum(1 for state in snapshot.axes if state.enabled)
|
||||
total = len(snapshot.axes) or self._axis_total
|
||||
self.chip_axes.setText(t("status.axes_enabled", on=enabled, total=total))
|
||||
self.chip_axes.set_tone("ok" if enabled else "muted")
|
||||
|
||||
self.refresh_health()
|
||||
self._refresh_action()
|
||||
|
||||
#: Fallback axis total before the first snapshot describes the bus.
|
||||
_axis_total = 5
|
||||
|
||||
def set_axis_total(self, total: int) -> None:
|
||||
self._axis_total = max(1, int(total))
|
||||
|
||||
def refresh_health(self) -> None:
|
||||
"""Show how old the last reading is.
|
||||
|
||||
A stalled poll is otherwise invisible: every other chip keeps displaying
|
||||
its last value, so a controller that stopped answering looks exactly like
|
||||
one that is answering normally. Called on each snapshot *and* on the
|
||||
window's own timer, so the chip ages even when no snapshot arrives —
|
||||
which is the case it exists to reveal.
|
||||
"""
|
||||
if self._last_snapshot_at is None:
|
||||
self.chip_health.setText(t("status.health_none"))
|
||||
self.chip_health.set_tone("muted")
|
||||
return
|
||||
if self.paused:
|
||||
self.chip_health.setText(t("status.health_paused"))
|
||||
self.chip_health.set_tone("muted")
|
||||
return
|
||||
|
||||
age_ms = (time.monotonic() - self._last_snapshot_at) * 1000.0
|
||||
intervals = age_ms / float(self._poll_interval_ms)
|
||||
self.chip_health.setText(t("status.health_age", ms=int(age_ms)))
|
||||
if intervals > _FAILED_INTERVALS:
|
||||
self.chip_health.set_tone("error")
|
||||
elif intervals > _STALE_INTERVALS:
|
||||
self.chip_health.set_tone("warn")
|
||||
else:
|
||||
self.chip_health.set_tone("ok")
|
||||
|
||||
def set_action_result(self, text: str, ok: bool = True) -> None:
|
||||
"""Record the outcome of the last command, and show it here.
|
||||
|
||||
The status bar carries the same text; the strip is what stays visible
|
||||
when a tab is switched, so the last refusal cannot scroll away unseen.
|
||||
"""
|
||||
self._last_action = str(text)
|
||||
self._last_action_ok = bool(ok)
|
||||
self._refresh_action()
|
||||
|
||||
def _refresh_action(self) -> None:
|
||||
self.action_label.setText(self._last_action or t("status.none"))
|
||||
tone = "ok" if self._last_action_ok else "error"
|
||||
colour = load_colors(self._theme).get(_TONE_TOKENS[tone], "#888888")
|
||||
self.action_label.setStyleSheet(f"color: {colour};")
|
||||
|
||||
@property
|
||||
def paused(self) -> bool:
|
||||
return self.pause_button.isChecked()
|
||||
|
||||
def set_paused(self, paused: bool) -> None:
|
||||
self.pause_button.setChecked(bool(paused))
|
||||
self._refresh_pause_text()
|
||||
self.refresh_health()
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("status.motion"))
|
||||
self._refresh_pause_text()
|
||||
self.stop_button.setText(t("axes.stop_all"))
|
||||
self.set_snapshot(self._snapshot)
|
||||
self.refresh_health()
|
||||
|
||||
def _refresh_pause_text(self) -> None:
|
||||
self.pause_button.setText(
|
||||
t("status.resume") if self.pause_button.isChecked() else t("status.pause")
|
||||
)
|
||||
|
||||
|
||||
class RunBar(QFrame):
|
||||
"""A run's phase, progress and controls, visible from every tab."""
|
||||
|
||||
runRequested = Signal()
|
||||
pauseRequested = Signal()
|
||||
abortRequested = Signal()
|
||||
|
||||
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("runBar")
|
||||
self.setFixedHeight(40)
|
||||
self._theme = theme
|
||||
self._phase = "idle"
|
||||
self._total = 0
|
||||
self._completed = 0
|
||||
self._has_run = False
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(10, 4, 10, 4)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self.title = QLabel(self)
|
||||
self.title.setObjectName("stripTitle")
|
||||
layout.addWidget(self.title)
|
||||
|
||||
self.phase_label = QLabel(self)
|
||||
self.phase_label.setObjectName("statusValue")
|
||||
self.phase_label.setMinimumWidth(90)
|
||||
layout.addWidget(self.phase_label)
|
||||
|
||||
self.step_label = QLabel(self)
|
||||
self.step_label.setObjectName("stripAction")
|
||||
self.step_label.setMinimumWidth(140)
|
||||
layout.addWidget(self.step_label)
|
||||
|
||||
self.progress = QProgressBar(self)
|
||||
self.progress.setTextVisible(False)
|
||||
self.progress.setFixedHeight(12)
|
||||
self.progress.setMinimumWidth(160)
|
||||
layout.addWidget(self.progress, 1)
|
||||
|
||||
self.time_label = QLabel(self)
|
||||
self.time_label.setObjectName("stripAction")
|
||||
self.time_label.setMinimumWidth(180)
|
||||
layout.addWidget(self.time_label)
|
||||
|
||||
self.run_button = QPushButton(self)
|
||||
self.run_button.setObjectName("primaryButton")
|
||||
self.pause_button = QPushButton(self)
|
||||
self.abort_button = QPushButton(self)
|
||||
self.abort_button.setObjectName("dangerButton")
|
||||
for button in (self.run_button, self.pause_button, self.abort_button):
|
||||
button.setProperty("rail", True)
|
||||
button.setFixedHeight(26)
|
||||
layout.addWidget(button)
|
||||
|
||||
self.run_button.clicked.connect(self.runRequested)
|
||||
self.pause_button.clicked.connect(self.pauseRequested)
|
||||
self.abort_button.clicked.connect(self.abortRequested)
|
||||
|
||||
self.retranslate()
|
||||
self.set_run(False)
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
|
||||
def set_phase(self, phase: str) -> None:
|
||||
if phase not in RUN_PHASES:
|
||||
phase = "idle"
|
||||
self._phase = phase
|
||||
self.phase_label.setText(t(RUN_PHASES[phase]))
|
||||
busy = phase in ("running", "paused")
|
||||
tone = {
|
||||
"running": "accent",
|
||||
"paused": "warn",
|
||||
"failed": "danger",
|
||||
"done": "accent",
|
||||
}.get(phase, "text_muted")
|
||||
colour = load_colors(self._theme).get(tone, "#888888")
|
||||
self.phase_label.setStyleSheet(f"color: {colour};")
|
||||
self._refresh_buttons(busy)
|
||||
|
||||
def set_progress(self, completed: int, total: int) -> None:
|
||||
self._completed = max(0, int(completed))
|
||||
self._total = max(0, int(total))
|
||||
self.progress.setRange(0, self._total or 1)
|
||||
self.progress.setValue(min(self._completed, self._total or 1))
|
||||
if self._total:
|
||||
self.step_label.setText(
|
||||
t("run.step_of", done=self._completed, total=self._total)
|
||||
)
|
||||
else:
|
||||
self.step_label.setText(t("run.no_steps"))
|
||||
|
||||
def set_timing(self, elapsed_s: float, remaining_s: float | None) -> None:
|
||||
"""Elapsed, and the estimate. ``None`` means "not known yet"."""
|
||||
if remaining_s is None:
|
||||
self.time_label.setText(t("run.elapsed_only", elapsed=_duration(elapsed_s)))
|
||||
else:
|
||||
self.time_label.setText(
|
||||
t("run.elapsed_remaining", elapsed=_duration(elapsed_s), remaining=_duration(remaining_s))
|
||||
)
|
||||
|
||||
def set_run(self, has_run: bool) -> None:
|
||||
"""Whether a run exists at all — i.e. whether abort must be reachable."""
|
||||
self._has_run = bool(has_run)
|
||||
self._refresh_buttons(self._phase in ("running", "paused"))
|
||||
|
||||
def _refresh_buttons(self, busy: bool) -> None:
|
||||
# Abort is enabled as soon as a run exists: it is the one control that
|
||||
# must never require finding a tab first.
|
||||
self.abort_button.setEnabled(self._has_run)
|
||||
self.pause_button.setEnabled(self._has_run and busy)
|
||||
self.run_button.setEnabled(not busy)
|
||||
|
||||
@property
|
||||
def phase(self) -> str:
|
||||
return self._phase
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("run.title"))
|
||||
self.phase_label.setText(t(RUN_PHASES[self._phase]))
|
||||
self.run_button.setText(t("run.run"))
|
||||
self.pause_button.setText(t("run.pause"))
|
||||
self.abort_button.setText(t("run.abort"))
|
||||
if self._total:
|
||||
self.step_label.setText(
|
||||
t("run.step_of", done=self._completed, total=self._total)
|
||||
)
|
||||
else:
|
||||
self.step_label.setText(t("run.no_steps"))
|
||||
self.time_label.setText(t("run.elapsed_only", elapsed=_duration(0.0)))
|
||||
|
||||
|
||||
def _duration(seconds: float) -> str:
|
||||
"""``m:ss`` for a duration, which is how an operator reads a bench timer."""
|
||||
seconds = max(0.0, float(seconds))
|
||||
minutes, rest = divmod(int(seconds), 60)
|
||||
return f"{minutes:d}:{rest:02d}"
|
||||
@@ -0,0 +1,913 @@
|
||||
"""The AutoCal main window: panels, menus, and the bridge to the poller.
|
||||
|
||||
The window never touches the controller itself. Every controller call goes
|
||||
through :class:`~orisys_autocal.motion.poller.MotionPoller`, which owns the DLL
|
||||
handle on its own thread; results arrive as signals. That keeps the vendor
|
||||
library's blocking calls (a ROM write, a bus scan) off the GUI thread and keeps
|
||||
the ctypes handle single-threaded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.paths import outputs_dir
|
||||
from orisys_app_shared.theme import build_qss, load_colors
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QCloseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QScrollArea,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import __version__ as app_version
|
||||
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
|
||||
from ..preferences import load_preferences, save_preferences
|
||||
from .axis_panel import AxisPanel
|
||||
from .axis_readout import AxisReadoutPanel
|
||||
from .brand_bar import BrandBar
|
||||
from .bus_data_panel import BusDataPanel
|
||||
from .bus_panel import BusPanel, default_bas_dir
|
||||
from .connection_panel import ConnectionPanel
|
||||
from .sequence_panel import SequencePanel
|
||||
from .status_strip import RunBar, StatusStrip
|
||||
from .workspace import TABS, WorkspaceTabs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: The main split: setup + readout rail, then the workspace.
|
||||
#:
|
||||
#: Two columns, not three. Two thin rails bracketing a workspace that has to hold
|
||||
#: a twelve-field form, a 3D preview, a checks list and an eight-column table
|
||||
#: cost 566 px of a 1920 screen — 29% — for chrome. Merging them into one rail
|
||||
#: gives the workspace that 240 px back at every window size, which is the
|
||||
#: difference between a workable sequence page and a cramped one at the minimum
|
||||
#: window width.
|
||||
MAIN_SPLITTER_SIZES = (330, 1560)
|
||||
|
||||
#: Width below which the rail would clip its own controls.
|
||||
MAIN_MINIMUM_WIDTH = 1440
|
||||
|
||||
#: Default and minimum window sizes. The default is the design target; the
|
||||
#: minimum stays viable on a smaller bench monitor.
|
||||
DEFAULT_WINDOW_SIZE = (1920, 1080)
|
||||
MINIMUM_WINDOW_SIZE = (1440, 900)
|
||||
|
||||
#: The action key whose result the status bar is currently showing.
|
||||
_COMMAND_NAMES = {
|
||||
"connect": "connection.open",
|
||||
"disconnect": "connection.close",
|
||||
"scan": "connection.scan",
|
||||
"bas": "bus.download",
|
||||
"reinit": "bus.reinit",
|
||||
}
|
||||
|
||||
|
||||
class AutoCalMainWindow(QMainWindow):
|
||||
"""Platform control, five axes, one window."""
|
||||
|
||||
def __init__(self, prefs: dict | None = None) -> None:
|
||||
super().__init__()
|
||||
self.prefs = dict(prefs or load_preferences())
|
||||
self._theme = str(self.prefs.get("theme") or "dark")
|
||||
self._language = str(self.prefs.get("language") or "zh_CN")
|
||||
self._last_snapshot = Snapshot()
|
||||
self._pending: dict[int, str] = {}
|
||||
self._last_error = ""
|
||||
|
||||
self.setWindowTitle(t("app.title"))
|
||||
self.setMinimumSize(*MINIMUM_WINDOW_SIZE)
|
||||
|
||||
self._build_ui()
|
||||
self._build_menus()
|
||||
self._build_poller()
|
||||
|
||||
self._restore_geometry()
|
||||
self.retranslate()
|
||||
self._apply_theme()
|
||||
|
||||
# --------------------------------------------------------------- build UI
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
central = QWidget(self)
|
||||
layout = QVBoxLayout(central)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self.brand_bar = BrandBar(self._theme, central)
|
||||
layout.addWidget(self.brand_bar)
|
||||
|
||||
# The status strip sits above the workspace, outside the tab widget, so
|
||||
# that switching tabs cannot hide whether the platform is live. The run
|
||||
# bar sits under it for the same reason: abort must be reachable from
|
||||
# every tab, not only from the one that started the run.
|
||||
self.status_strip = StatusStrip(self._theme, central)
|
||||
layout.addWidget(self.status_strip)
|
||||
self.run_bar = RunBar(self._theme, central)
|
||||
layout.addWidget(self.run_bar)
|
||||
|
||||
body = QWidget(central)
|
||||
body_layout = QHBoxLayout(body)
|
||||
body_layout.setContentsMargins(8, 6, 8, 6)
|
||||
body_layout.setSpacing(8)
|
||||
layout.addWidget(body, stretch=1)
|
||||
|
||||
# ----------------------------------------------------------------- rail
|
||||
# One rail, not two. It holds setup, the axis readout and the collapsed
|
||||
# bus data, and it scrolls: at 900 px high those three groups do not fit,
|
||||
# and a rail that clips its own controls is worse than one that scrolls.
|
||||
self.rail_content = QWidget()
|
||||
left = QVBoxLayout(self.rail_content)
|
||||
left.setContentsMargins(0, 0, 0, 0)
|
||||
left.setSpacing(8)
|
||||
|
||||
connection = self.prefs.get("connection") or {}
|
||||
self.connection_panel = ConnectionPanel(
|
||||
ConnectionSpec(
|
||||
mode=str(connection.get("mode") or "eth"),
|
||||
ip=str(connection.get("ip") or ""),
|
||||
com_id=int(connection.get("com_id") or 0),
|
||||
card_num=int(connection.get("card_num") or 0),
|
||||
local_string=str(connection.get("local_string") or "0"),
|
||||
),
|
||||
self.rail_content,
|
||||
)
|
||||
self.bus_panel = BusPanel(str(self.prefs.get("bas_path") or ""), self.rail_content)
|
||||
self.axis_readout = AxisReadoutPanel(
|
||||
MotionService().axis_count, self._theme, self.rail_content
|
||||
)
|
||||
left.addWidget(self.connection_panel)
|
||||
left.addWidget(self.bus_panel)
|
||||
left.addWidget(self.axis_readout)
|
||||
|
||||
# Bus data (SDO/RTEX) is commissioning-only, so it is collapsed by
|
||||
# default: it was costing the bottom of the page ~270 px for a control
|
||||
# used once in a while.
|
||||
self.bus_data_group = QGroupBox(self.rail_content)
|
||||
self.bus_data_group.setObjectName("groupBox")
|
||||
self.bus_data_group.setCheckable(True)
|
||||
self.bus_data_group.setChecked(False)
|
||||
bus_data_layout = QVBoxLayout(self.bus_data_group)
|
||||
bus_data_layout.setContentsMargins(8, 6, 8, 8)
|
||||
self.bus_data_panel = BusDataPanel(
|
||||
sdo_last=self.prefs.get("sdo_last") or {},
|
||||
rtex_last=self.prefs.get("rtex_last") or {},
|
||||
axis_count=MotionService().axis_count,
|
||||
parent=self.bus_data_group,
|
||||
)
|
||||
bus_data_layout.addWidget(self.bus_data_panel)
|
||||
self.bus_data_panel.setVisible(False)
|
||||
self.bus_data_group.toggled.connect(self._on_bus_data_toggled)
|
||||
left.addWidget(self.bus_data_group)
|
||||
left.addStretch(1)
|
||||
|
||||
self.left_rail = QScrollArea(body)
|
||||
self.left_rail.setObjectName("railScroll")
|
||||
self.left_rail.setWidget(self.rail_content)
|
||||
self.left_rail.setWidgetResizable(True)
|
||||
self.left_rail.setFrameShape(QScrollArea.Shape.NoFrame)
|
||||
self.left_rail.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.left_rail.setMinimumWidth(300)
|
||||
|
||||
# ------------------------------------------------------------ workspace
|
||||
params = {
|
||||
axis: AxisParams(**values)
|
||||
for axis, values in _safe_axis_params(self.prefs.get("axis_params"))
|
||||
}
|
||||
self.axis_panel = AxisPanel(
|
||||
MotionService().axis_count,
|
||||
params,
|
||||
units_per_mm=float(self.prefs.get("units_per_mm") or 10.0),
|
||||
parent=body,
|
||||
)
|
||||
self.sequence_panel = SequencePanel(self.prefs.get("sequence_profile") or {}, body)
|
||||
self.workspace = WorkspaceTabs(self.axis_panel, self.sequence_panel, body)
|
||||
|
||||
self.main_splitter = QSplitter(Qt.Orientation.Horizontal, body)
|
||||
self.main_splitter.addWidget(self.left_rail)
|
||||
self.main_splitter.addWidget(self.workspace)
|
||||
self.main_splitter.setSizes(list(MAIN_SPLITTER_SIZES))
|
||||
self.main_splitter.setChildrenCollapsible(False)
|
||||
self.main_splitter.setStretchFactor(0, 0)
|
||||
self.main_splitter.setStretchFactor(1, 1)
|
||||
self.main_splitter.setObjectName("mainSplitter")
|
||||
body_layout.addWidget(self.main_splitter, 1)
|
||||
|
||||
self.diagnostics_box = self._build_diagnostics(central)
|
||||
layout.addWidget(self.diagnostics_box)
|
||||
|
||||
self.setCentralWidget(central)
|
||||
self.statusBar().showMessage(t("status.ready"))
|
||||
self._apply_chrome_colours()
|
||||
|
||||
self._wire_signals()
|
||||
|
||||
def _wire_signals(self) -> None:
|
||||
self.connection_panel.connectRequested.connect(self._on_connect)
|
||||
self.connection_panel.disconnectRequested.connect(self._on_disconnect)
|
||||
self.connection_panel.scanRequested.connect(self._on_scan)
|
||||
self.bus_panel.chooseRequested.connect(self._on_choose_bas)
|
||||
self.bus_panel.downloadRequested.connect(self._on_download_bas)
|
||||
self.bus_panel.reinitRequested.connect(self._on_reinit)
|
||||
self.axis_panel.paramsApplied.connect(self._on_params_applied)
|
||||
self.axis_panel.enableToggled.connect(self._on_enable_toggled)
|
||||
self.axis_panel.jogRequested.connect(self._on_jog)
|
||||
self.axis_panel.stopRequested.connect(self._on_stop)
|
||||
self.axis_panel.stopAllRequested.connect(self._on_stop_all)
|
||||
self.axis_panel.moveAbsRequested.connect(self._on_move_abs)
|
||||
self.axis_panel.moveRelRequested.connect(self._on_move_rel)
|
||||
self.axis_panel.homeRequested.connect(self._on_home)
|
||||
self.axis_panel.clearAlarmRequested.connect(self._on_clear_alarm)
|
||||
self.bus_data_panel.sdoReadRequested.connect(self._on_sdo_read)
|
||||
self.bus_data_panel.sdoWriteRequested.connect(self._on_sdo_write)
|
||||
self.bus_data_panel.rtexReadRequested.connect(self._on_rtex_read)
|
||||
self.bus_data_panel.rtexWriteRequested.connect(self._on_rtex_write)
|
||||
self.status_strip.stopAllRequested.connect(self._on_stop_all)
|
||||
self.status_strip.pauseRequested.connect(self._on_pause_polling)
|
||||
self.run_bar.runRequested.connect(self._on_run_requested)
|
||||
self.run_bar.pauseRequested.connect(self._on_run_pause)
|
||||
self.run_bar.abortRequested.connect(self._on_run_abort)
|
||||
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
|
||||
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
|
||||
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
|
||||
|
||||
# ---------------------------------------------------------------- run bar
|
||||
|
||||
def _on_run_requested(self) -> None:
|
||||
"""Refuse, naming the phase that owns the runner.
|
||||
|
||||
Connected rather than left dangling: a control that does nothing when
|
||||
pressed is indistinguishable from a bug, and the point of wiring this now
|
||||
is that the sooner it exists, the sooner the phase that fills it has a
|
||||
seam to build against.
|
||||
"""
|
||||
self._report_run("run.not_implemented", ok=False)
|
||||
|
||||
def _on_run_pause(self) -> None:
|
||||
self.run_bar.set_phase("paused" if self.run_bar.phase != "paused" else "running")
|
||||
|
||||
def _on_run_abort(self) -> None:
|
||||
# Abort always works, even before the runner exists: it stops the axes.
|
||||
self.run_bar.set_phase("failed")
|
||||
self._on_stop_all()
|
||||
self._report_run("run.abort_stopped", ok=True)
|
||||
|
||||
def _report_run(self, locale_key: str, *, ok: bool) -> None:
|
||||
text = t(locale_key)
|
||||
self.statusBar().showMessage(text)
|
||||
self.status_strip.set_action_result(text, ok)
|
||||
|
||||
def _apply_chrome_colours(self) -> None:
|
||||
"""Style the status bar from the theme rather than Qt's default.
|
||||
|
||||
``brand_bar_bg`` is a dark navy that reads well as a header and heavy as
|
||||
a footer, so the bar gets its own token with the brand colour as the
|
||||
fallback for a theme that predates it.
|
||||
"""
|
||||
colours = load_colors(self._theme)
|
||||
background = colours.get("status_bar_bg") or colours.get("brand_bar_bg", "#12315D")
|
||||
text = colours.get("status_bar_text") or "#ffffff"
|
||||
self.statusBar().setStyleSheet(
|
||||
f"QStatusBar {{ background-color: {background}; color: {text}; }}"
|
||||
f"QStatusBar::item {{ border: none; }}"
|
||||
)
|
||||
|
||||
def _on_bus_data_toggled(self, expanded: bool) -> None:
|
||||
self.bus_data_panel.setVisible(bool(expanded))
|
||||
self.bus_data_group.setMaximumHeight(16777215 if expanded else 28)
|
||||
|
||||
def _on_pause_polling(self) -> None:
|
||||
"""Stop or resume the state poll, without touching the connection."""
|
||||
paused = self.status_strip.paused
|
||||
if paused:
|
||||
self.poller.pause()
|
||||
self.statusBar().showMessage(t("status.paused"))
|
||||
else:
|
||||
self.poller.resume()
|
||||
self.statusBar().showMessage(t("status.ready"))
|
||||
# The health chip says "paused" rather than ageing into a false alarm.
|
||||
self.status_strip.refresh_health()
|
||||
|
||||
def _on_enable_all(self, enabled: bool) -> None:
|
||||
for axis in range(self.axis_panel.axis_count):
|
||||
self.poller.submit(
|
||||
lambda service, a=axis, on=enabled: service.set_axis_enable(a, on)
|
||||
)
|
||||
|
||||
def _on_clear_all_alarms(self) -> None:
|
||||
for axis in range(self.axis_panel.axis_count):
|
||||
self.poller.submit(lambda service, a=axis: service.clear_alarm(a))
|
||||
|
||||
def _build_diagnostics(self, parent: QWidget) -> QGroupBox:
|
||||
box = QGroupBox(parent)
|
||||
box.setObjectName("groupBox")
|
||||
layout = QVBoxLayout(box)
|
||||
layout.setContentsMargins(10, 8, 10, 10)
|
||||
layout.setSpacing(4)
|
||||
self.diagnostics_label = QLabel(box)
|
||||
self.diagnostics_label.setObjectName("hintLabel")
|
||||
self.diagnostics_label.setWordWrap(True)
|
||||
self.diagnostics_label.setTextInteractionFlags(
|
||||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
layout.addWidget(self.diagnostics_label)
|
||||
box.setVisible(bool(self.prefs.get("show_diagnostics")))
|
||||
return box
|
||||
|
||||
def _build_menus(self) -> None:
|
||||
menu_bar = self.menuBar()
|
||||
|
||||
self.menu_file = menu_bar.addMenu("")
|
||||
self.act_download_bas = QAction("", self)
|
||||
self.act_download_bas.triggered.connect(self._on_download_bas)
|
||||
self.act_reveal_config = QAction("", self)
|
||||
self.act_reveal_config.triggered.connect(self._on_reveal_config)
|
||||
self.act_reveal_outputs = QAction("", self)
|
||||
self.act_reveal_outputs.triggered.connect(self._on_reveal_outputs)
|
||||
self.act_quit = QAction("", self)
|
||||
self.act_quit.triggered.connect(self.close)
|
||||
for action in (
|
||||
self.act_download_bas,
|
||||
self.act_reveal_config,
|
||||
self.act_reveal_outputs,
|
||||
):
|
||||
self.menu_file.addAction(action)
|
||||
self.menu_file.addSeparator()
|
||||
self.menu_file.addAction(self.act_quit)
|
||||
|
||||
self.menu_view = menu_bar.addMenu("")
|
||||
self.menu_theme = self.menu_view.addMenu("")
|
||||
self.act_theme_dark = QAction("", self)
|
||||
self.act_theme_dark.setCheckable(True)
|
||||
self.act_theme_light = QAction("", self)
|
||||
self.act_theme_light.setCheckable(True)
|
||||
self.act_theme_dark.triggered.connect(lambda: self._set_theme("dark"))
|
||||
self.act_theme_light.triggered.connect(lambda: self._set_theme("light"))
|
||||
self.menu_theme.addAction(self.act_theme_dark)
|
||||
self.menu_theme.addAction(self.act_theme_light)
|
||||
|
||||
self.menu_language = self.menu_view.addMenu("")
|
||||
self.act_lang_zh = QAction("", self)
|
||||
self.act_lang_zh.setCheckable(True)
|
||||
self.act_lang_en = QAction("", self)
|
||||
self.act_lang_en.setCheckable(True)
|
||||
self.act_lang_zh.triggered.connect(lambda: self._set_language("zh_CN"))
|
||||
self.act_lang_en.triggered.connect(lambda: self._set_language("en_US"))
|
||||
self.menu_language.addAction(self.act_lang_zh)
|
||||
self.menu_language.addAction(self.act_lang_en)
|
||||
|
||||
self.act_diagnostics = QAction("", self)
|
||||
self.act_diagnostics.setCheckable(True)
|
||||
self.act_diagnostics.setChecked(bool(self.prefs.get("show_diagnostics")))
|
||||
self.act_diagnostics.toggled.connect(self._set_diagnostics)
|
||||
self.menu_view.addSeparator()
|
||||
self.menu_view.addAction(self.act_diagnostics)
|
||||
|
||||
# Tab shortcuts: the workspace is the page, so reaching a tab from the
|
||||
# keyboard is the navigation an operator uses most.
|
||||
self.menu_tabs = self.menu_view.addMenu("")
|
||||
self.act_tabs: dict[str, QAction] = {}
|
||||
for index, (key, locale_key) in enumerate(TABS):
|
||||
action = QAction("", self)
|
||||
action.setShortcut(f"Ctrl+{index + 1}")
|
||||
action.triggered.connect(lambda _checked=False, k=key: self.workspace.set_current_key(k))
|
||||
self.menu_tabs.addAction(action)
|
||||
self.act_tabs[key] = action
|
||||
self._tab_locale_keys = dict(TABS)
|
||||
|
||||
self.menu_help = menu_bar.addMenu("")
|
||||
self.act_about = QAction("", self)
|
||||
self.act_about.triggered.connect(self._on_about)
|
||||
self.menu_help.addAction(self.act_about)
|
||||
|
||||
def _build_poller(self) -> None:
|
||||
self.poller = MotionPoller(
|
||||
MotionService(language=self._language),
|
||||
axes=range(self.axis_panel.axis_count),
|
||||
)
|
||||
self.poller.snapshotReady.connect(self._on_snapshot)
|
||||
self.poller.commandFinished.connect(self._on_command_finished)
|
||||
self.poller.commandFailed.connect(self._on_command_failed)
|
||||
self.poller.set_interval_ms(int(self.prefs.get("state_poll_ms") or 100))
|
||||
self.status_strip.set_poll_interval_ms(int(self.prefs.get("state_poll_ms") or 100))
|
||||
|
||||
# Ages the poll-health chip even when no snapshot arrives. Runs on the
|
||||
# GUI thread and only reads a timestamp — the controller is untouched —
|
||||
# so a stopped poll becomes visible instead of leaving stale chips
|
||||
# looking healthy.
|
||||
self._health_timer = QTimer(self)
|
||||
self._health_timer.timeout.connect(self.status_strip.refresh_health)
|
||||
self._health_timer.start(500)
|
||||
|
||||
# ------------------------------------------------------------- controller
|
||||
|
||||
def connect_controller(
|
||||
self, spec: ConnectionSpec | None = None, *, label: str = "connect"
|
||||
) -> int:
|
||||
"""Ask the poller to open the controller. Returns the command id."""
|
||||
target = spec or self.connection_panel.spec
|
||||
token = self.poller.connect_controller(target)
|
||||
self._pending[token] = label
|
||||
self.connection_panel.set_status(t("connection.state_connecting"))
|
||||
return token
|
||||
|
||||
def disconnect_controller(self, *, label: str = "disconnect") -> int:
|
||||
token = self.poller.disconnect_controller()
|
||||
self._pending[token] = label
|
||||
return token
|
||||
|
||||
# --------------------------------------------------------------- snapshot
|
||||
|
||||
def _on_snapshot(self, snapshot: Snapshot) -> None:
|
||||
"""Signal handler for the poller's periodic state read."""
|
||||
self.update_snapshot(snapshot)
|
||||
|
||||
def update_snapshot(self, snapshot: Snapshot) -> None:
|
||||
self._last_snapshot = snapshot
|
||||
self.connection_panel.set_connected(
|
||||
snapshot.connected, self.poller.service.transport.source
|
||||
)
|
||||
self.brand_bar.set_connected(snapshot.connected)
|
||||
self.bus_panel.set_connected(snapshot.connected)
|
||||
self.bus_panel.set_status(snapshot.bus)
|
||||
# The bus-data panel gates on the connection first and the bus family
|
||||
# second, so both have to reach it or every SDO/RTEX control stays dead.
|
||||
self.bus_data_panel.set_connected(snapshot.connected)
|
||||
self.bus_data_panel.set_bus_type(snapshot.bus.bus_type)
|
||||
self.axis_panel.update_snapshot(snapshot)
|
||||
self.axis_readout.update_snapshot(snapshot)
|
||||
# The strip is outside the tabs, so it is fed on every poll regardless
|
||||
# of which tab is showing.
|
||||
self.status_strip.set_snapshot(snapshot)
|
||||
# The run bar tracks the designed point list: before a runner exists that
|
||||
# is zero, and its abort control stays correct either way.
|
||||
self.run_bar.set_progress(0, len(self.sequence_panel.planned_steps()))
|
||||
self.run_bar.set_run(bool(self.sequence_panel.planned_steps()))
|
||||
if snapshot.error:
|
||||
self._last_error = snapshot.error
|
||||
self._refresh_status_bar()
|
||||
if self.diagnostics_box.isVisible():
|
||||
self._refresh_diagnostics()
|
||||
|
||||
def _refresh_status_bar(self) -> None:
|
||||
snapshot = self._last_snapshot
|
||||
if not snapshot.connected:
|
||||
self.statusBar().showMessage(t("status.ready"))
|
||||
return
|
||||
parts = [
|
||||
t("status.poll_paused") if self.status_strip.paused else t("status.poll_ok"),
|
||||
t("status.poll_every", ms=self.poller.interval_ms),
|
||||
]
|
||||
if snapshot.bus.init_status == 1:
|
||||
parts.append(t("bus.state_ok"))
|
||||
if self._last_error:
|
||||
parts.append(f"{t('status.last_error')}: {self._last_error}")
|
||||
self.statusBar().showMessage(" | ".join(parts))
|
||||
|
||||
def _refresh_diagnostics(self) -> None:
|
||||
from ..preferences import path as preference_path
|
||||
|
||||
service = self.poller.service
|
||||
self.diagnostics_label.setText(
|
||||
"\n".join(
|
||||
[
|
||||
f"{t('diagnostics.binding')}: PySide6 {_pyside_version()}",
|
||||
f"{t('diagnostics.dll_dir')}: {service.transport.source}",
|
||||
f"{t('diagnostics.prefs')}: {preference_path()}",
|
||||
f"{t('diagnostics.outputs')}: {outputs_dir()}",
|
||||
f"{t('diagnostics.bas_loaded')}: "
|
||||
f"{t('diagnostics.yes') if service.bas_loaded else t('diagnostics.no')}",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# ----------------------------------------------------- command completion
|
||||
|
||||
def _on_command_finished(self, token: int, result) -> None:
|
||||
"""Report a completed command, whatever shape its result has.
|
||||
|
||||
Commands return different things: a ``MotionResult``, a list of them
|
||||
(stop-all), a ``(result, value)`` pair (SDO/RTEX reads), a list of IPs
|
||||
(scan), or ``None``. Rather than a convention each caller must remember,
|
||||
the shape is detected here.
|
||||
"""
|
||||
label_key = _COMMAND_NAMES.get(self._pending.pop(token, ""), "")
|
||||
name = t(label_key) if label_key else ""
|
||||
|
||||
message, ok = _interpret_result(result)
|
||||
if ok:
|
||||
self._last_error = ""
|
||||
if message:
|
||||
self.bus_data_panel.set_result(message)
|
||||
text = f"{name} {t('action.ok')}".strip()
|
||||
self.statusBar().showMessage(text)
|
||||
# The strip keeps the outcome visible after a tab switch, so a
|
||||
# command that was refused while the operator was elsewhere is not
|
||||
# lost the moment the status bar is overwritten.
|
||||
self.status_strip.set_action_result(text, True)
|
||||
return
|
||||
|
||||
self._last_error = message or t("action.failed")
|
||||
self.bus_data_panel.set_result(self._last_error)
|
||||
text = f"{name} {t('action.refused')}: {self._last_error}"
|
||||
self.statusBar().showMessage(text)
|
||||
self.status_strip.set_action_result(text, False)
|
||||
logger.warning("Command refused/failed: %s", self._last_error)
|
||||
|
||||
def _on_command_failed(self, token: int, message: str) -> None:
|
||||
label_key = _COMMAND_NAMES.get(self._pending.pop(token, ""), "")
|
||||
self._last_error = message
|
||||
name = t(label_key) if label_key else ""
|
||||
text = f"{name} {t('action.failed')}: {message}"
|
||||
self.statusBar().showMessage(text)
|
||||
self.status_strip.set_action_result(text, False)
|
||||
logger.error("Command raised: %s", message)
|
||||
|
||||
# ---------------------------------------------------------------- handlers
|
||||
|
||||
def _on_connect(self, spec: ConnectionSpec) -> None:
|
||||
self.connect_controller(spec)
|
||||
|
||||
def _on_disconnect(self) -> None:
|
||||
if not self._last_snapshot.connected:
|
||||
return
|
||||
moving = [
|
||||
state.axis
|
||||
for state in self._last_snapshot.axes
|
||||
if state.enabled and not state.idle
|
||||
]
|
||||
if moving:
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
t("connection.close"),
|
||||
t("axes.motion") + f" {moving}",
|
||||
)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self.disconnect_controller()
|
||||
|
||||
def _on_scan(self) -> None:
|
||||
def _scan(service: MotionService) -> object:
|
||||
return service.transport.search_eth()
|
||||
|
||||
token = self.poller.submit(_scan)
|
||||
self._pending[token] = "scan"
|
||||
|
||||
def _on_choose_bas(self) -> None:
|
||||
start = self.bus_panel.bas_path or str(default_bas_dir())
|
||||
path, _selected = QFileDialog.getOpenFileName(
|
||||
self, t("bus.dialog_title"), start, "BAS (*.bas);;All files (*)"
|
||||
)
|
||||
if path:
|
||||
self.bus_panel.set_bas_path(path)
|
||||
self.prefs["bas_path"] = path
|
||||
|
||||
def _on_download_bas(self) -> None:
|
||||
path = self.bus_panel.bas_path
|
||||
if not path:
|
||||
self._on_choose_bas()
|
||||
path = self.bus_panel.bas_path
|
||||
if not path:
|
||||
return
|
||||
token = self.poller.submit(lambda service: service.download_bas(path, 1))
|
||||
self._pending[token] = "bas"
|
||||
self.statusBar().showMessage(t("bus.downloaded"))
|
||||
|
||||
def _on_reinit(self) -> None:
|
||||
token = self.poller.submit(lambda service: service.reinit_bus())
|
||||
self._pending[token] = "reinit"
|
||||
|
||||
def _on_params_applied(self, axis: int, params: AxisParams) -> None:
|
||||
token = self.poller.submit(lambda service: service.set_axis_params(axis, params))
|
||||
self._pending[token] = ""
|
||||
|
||||
def _on_enable_toggled(self, axis: int, enabled: bool) -> None:
|
||||
token = self.poller.submit(lambda service: service.set_axis_enable(axis, enabled))
|
||||
self._pending[token] = ""
|
||||
|
||||
def _on_jog(self, axis: int, forward: bool) -> None:
|
||||
self.poller.submit(lambda service: service.jog(axis, forward))
|
||||
|
||||
def _on_stop(self, axis: int) -> None:
|
||||
self.poller.submit(lambda service: service.stop(axis))
|
||||
|
||||
def _on_stop_all(self) -> None:
|
||||
def _stop_all(service: MotionService) -> object:
|
||||
return service.stop_all()
|
||||
|
||||
self.poller.submit(_stop_all)
|
||||
|
||||
def _on_move_abs(self, axis: int, target: float) -> None:
|
||||
self.poller.submit(lambda service: service.move_abs(axis, target))
|
||||
|
||||
def _on_move_rel(self, axis: int, distance: float) -> None:
|
||||
self.poller.submit(lambda service: service.move_rel(axis, distance))
|
||||
|
||||
def _on_home(self, axis: int) -> None:
|
||||
self.poller.submit(lambda service: service.home(axis))
|
||||
|
||||
def _on_clear_alarm(self, axis: int) -> None:
|
||||
self.poller.submit(lambda service: service.clear_alarm(axis))
|
||||
|
||||
# ---------------------------------------------------------------- bus data
|
||||
|
||||
def _on_sdo_read(self, node: int, index: int, subindex: int, data_type: int) -> None:
|
||||
def _read(service: MotionService):
|
||||
result, value = service.sdo_read(node, index, subindex, data_type)
|
||||
return result, value
|
||||
|
||||
token = self.poller.submit(_read)
|
||||
self._pending[token] = ""
|
||||
|
||||
def _on_sdo_write(
|
||||
self, node: int, index: int, subindex: int, data_type: int, value: int
|
||||
) -> None:
|
||||
token = self.poller.submit(
|
||||
lambda service: service.sdo_write(node, index, subindex, data_type, value)
|
||||
)
|
||||
self._pending[token] = ""
|
||||
|
||||
def _on_rtex_read(self, axis: int, parameter: int) -> None:
|
||||
token = self.poller.submit(lambda service: service.rtex_read(axis, parameter))
|
||||
self._pending[token] = ""
|
||||
|
||||
def _on_rtex_write(self, axis: int, parameter: int, value: float) -> None:
|
||||
token = self.poller.submit(
|
||||
lambda service: service.rtex_write(axis, parameter, value)
|
||||
)
|
||||
self._pending[token] = ""
|
||||
|
||||
# ------------------------------------------------------------------ menus
|
||||
|
||||
def _set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
self.prefs["theme"] = theme
|
||||
self._apply_theme()
|
||||
self._persist_prefs()
|
||||
|
||||
def _apply_theme(self) -> None:
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.setStyleSheet(build_qss(self._theme))
|
||||
self.brand_bar.set_theme(self._theme)
|
||||
self.status_strip.set_theme(self._theme)
|
||||
self.run_bar.set_theme(self._theme)
|
||||
self.axis_readout.set_theme(self._theme)
|
||||
self._apply_chrome_colours()
|
||||
self.act_theme_dark.setChecked(self._theme == "dark")
|
||||
self.act_theme_light.setChecked(self._theme == "light")
|
||||
|
||||
def _set_language(self, language: str) -> None:
|
||||
self._language = language
|
||||
self.prefs["language"] = language
|
||||
set_locale(language)
|
||||
self.poller.set_language(language)
|
||||
self.retranslate()
|
||||
self._persist_prefs()
|
||||
|
||||
def _set_diagnostics(self, visible: bool) -> None:
|
||||
"""Show or hide the diagnostics box.
|
||||
|
||||
It used to be a splitter pane, which meant toggling it resized the whole
|
||||
page. Now it is a fixed footer: the layout above it does not move, so an
|
||||
operator cannot lose their place by turning the diagnostics on.
|
||||
"""
|
||||
self.prefs["show_diagnostics"] = bool(visible)
|
||||
self.diagnostics_box.setVisible(bool(visible))
|
||||
if visible:
|
||||
self._refresh_diagnostics()
|
||||
self._persist_prefs()
|
||||
|
||||
def _on_reveal_config(self) -> None:
|
||||
from orisys_app_shared.paths import config_dir
|
||||
|
||||
self._reveal(config_dir())
|
||||
|
||||
def _on_reveal_outputs(self) -> None:
|
||||
self._reveal(outputs_dir())
|
||||
|
||||
@staticmethod
|
||||
def _reveal(path: Path) -> None:
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
subprocess.Popen(["explorer", str(path)])
|
||||
else: # pragma: no cover - the platform is Windows
|
||||
subprocess.Popen(["xdg-open", str(path)])
|
||||
except OSError:
|
||||
logger.exception("Could not open %s", path)
|
||||
|
||||
def _on_about(self) -> None:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
t("app.about_title"),
|
||||
t(
|
||||
"app.about_body",
|
||||
app_version=app_version,
|
||||
shared_version=shared_version,
|
||||
api_version=API_VERSION,
|
||||
binding=f"PySide6 {_pyside_version()}",
|
||||
dll_dir=self.poller.service.transport.source,
|
||||
prefs_path=self.prefs_path(),
|
||||
),
|
||||
)
|
||||
|
||||
def prefs_path(self) -> str:
|
||||
from ..preferences import path as preference_path
|
||||
|
||||
return str(preference_path())
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setWindowTitle(f"{t('app.title')} {app_version}")
|
||||
self.brand_bar.retranslate()
|
||||
self.status_strip.retranslate()
|
||||
self.run_bar.retranslate()
|
||||
self.connection_panel.retranslate()
|
||||
self.bus_panel.retranslate()
|
||||
self.bus_data_group.setTitle(t("busdata.group"))
|
||||
self.bus_data_panel.retranslate()
|
||||
self.axis_panel.retranslate()
|
||||
self.axis_readout.retranslate()
|
||||
self.sequence_panel.retranslate()
|
||||
self.workspace.retranslate()
|
||||
|
||||
self.menu_file.setTitle(t("menu.file"))
|
||||
self.act_download_bas.setText(t("menu.open_bas"))
|
||||
self.act_reveal_config.setText(t("menu.reveal_config"))
|
||||
self.act_reveal_outputs.setText(t("menu.outputs"))
|
||||
self.act_quit.setText(t("menu.quit"))
|
||||
self.menu_view.setTitle(t("menu.view"))
|
||||
self.menu_theme.setTitle(t("menu.theme"))
|
||||
self.act_theme_dark.setText(t("menu.theme_dark"))
|
||||
self.act_theme_light.setText(t("menu.theme_light"))
|
||||
self.menu_language.setTitle(t("menu.language"))
|
||||
self.act_lang_zh.setText(t("menu.lang_zh"))
|
||||
self.act_lang_en.setText(t("menu.lang_en"))
|
||||
self.act_diagnostics.setText(t("menu.show_diagnostics"))
|
||||
self.menu_tabs.setTitle(t("menu.tabs"))
|
||||
for key, locale_key in self._tab_locale_keys.items():
|
||||
self.act_tabs[key].setText(t(locale_key))
|
||||
self.menu_help.setTitle(t("menu.help"))
|
||||
self.act_about.setText(t("menu.about"))
|
||||
self.act_lang_zh.setChecked(self._language == "zh_CN")
|
||||
self.act_lang_en.setChecked(self._language == "en_US")
|
||||
self.diagnostics_box.setTitle(t("diagnostics.title"))
|
||||
self._refresh_status_bar()
|
||||
|
||||
# ------------------------------------------------------------ persistence
|
||||
|
||||
def _restore_geometry(self) -> None:
|
||||
geometry = self.prefs.get("window_geometry")
|
||||
if isinstance(geometry, list) and len(geometry) == 4:
|
||||
try:
|
||||
self.setGeometry(*(int(value) for value in geometry))
|
||||
self._apply_splitter_sizes()
|
||||
return
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Ignoring malformed window_geometry: %r", geometry)
|
||||
self.resize(*DEFAULT_WINDOW_SIZE)
|
||||
self._apply_splitter_sizes()
|
||||
|
||||
def _apply_splitter_sizes(self) -> None:
|
||||
"""Restore the column split, or fall back to the designed proportions.
|
||||
|
||||
The previous layout stored three column widths in ``splitter_sizes`` for
|
||||
a different splitter; that key no longer describes anything, so it is
|
||||
ignored rather than misapplied.
|
||||
"""
|
||||
stored = self.prefs.get("main_splitter_sizes")
|
||||
if isinstance(stored, list) and len(stored) == self.main_splitter.count():
|
||||
try:
|
||||
self.main_splitter.setSizes([int(value) for value in stored])
|
||||
return
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Ignoring malformed main_splitter_sizes: %r", stored)
|
||||
self.main_splitter.setSizes(list(MAIN_SPLITTER_SIZES))
|
||||
|
||||
def _persist_prefs(self) -> None:
|
||||
"""Write the current UI state to AutoCal's preference file."""
|
||||
geometry = self.geometry()
|
||||
self.prefs["window_geometry"] = [
|
||||
geometry.x(),
|
||||
geometry.y(),
|
||||
geometry.width(),
|
||||
geometry.height(),
|
||||
]
|
||||
self.prefs["main_splitter_sizes"] = [int(v) for v in self.main_splitter.sizes()]
|
||||
self.prefs["bas_path"] = self.bus_panel.bas_path
|
||||
self.prefs["units_per_mm"] = self.axis_panel._units_per_mm
|
||||
self.prefs["axis_params"] = {
|
||||
str(axis): _params_to_dict(params)
|
||||
for axis, params in self.axis_panel.stored_params().items()
|
||||
}
|
||||
self.prefs.update(self.bus_data_panel.remember_state())
|
||||
# The sequence profile is the P5 design artefact: whatever is on screen is
|
||||
# what a later run would use, so it is persisted with everything else.
|
||||
self.prefs["sequence_profile"] = self.sequence_panel.profile()
|
||||
try:
|
||||
save_preferences(self.prefs)
|
||||
except OSError:
|
||||
logger.exception("Could not write the preference file")
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override
|
||||
"""Stop the platform, close the controller, then persist and leave."""
|
||||
logger.info("Closing: stopping axes and shutting the poller down")
|
||||
try:
|
||||
self.poller.submit(lambda service: service.stop_all())
|
||||
self.poller.disconnect_controller()
|
||||
self.poller.shutdown()
|
||||
except Exception: # noqa: BLE001 - closing must always finish
|
||||
logger.exception("Shutdown reported a problem")
|
||||
self._persist_prefs()
|
||||
event.accept()
|
||||
|
||||
|
||||
def _pyside_version() -> str:
|
||||
"""The installed PySide6 version, for the About box and diagnostics."""
|
||||
import PySide6
|
||||
|
||||
return getattr(PySide6, "__version__", "unknown")
|
||||
|
||||
|
||||
def _safe_axis_params(raw: object) -> list[tuple[int, dict]]:
|
||||
"""Turn the stored per-axis dict into ``AxisParams`` keyword groups.
|
||||
|
||||
A preference file written by an older build (or edited by hand) may be the
|
||||
wrong shape; a bad entry is dropped rather than crashing the window.
|
||||
"""
|
||||
known = set(AxisParams.__dataclass_fields__)
|
||||
result: list[tuple[int, dict]] = []
|
||||
if not isinstance(raw, dict):
|
||||
return result
|
||||
for key, values in raw.items():
|
||||
if not isinstance(values, dict):
|
||||
continue
|
||||
try:
|
||||
axis = int(key)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
clean = {name: value for name, value in values.items() if name in known}
|
||||
try:
|
||||
result.append((axis, clean))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Ignoring malformed axis_params[%r]: %r", key, values)
|
||||
return result
|
||||
|
||||
|
||||
def _params_to_dict(params: AxisParams) -> dict:
|
||||
return {
|
||||
name: getattr(params, name) for name in AxisParams.__dataclass_fields__
|
||||
}
|
||||
|
||||
|
||||
def _interpret_result(result) -> tuple[str, bool]:
|
||||
"""Reduce a command's result to ``(message, ok)``.
|
||||
|
||||
See :meth:`AutoCalMainWindow._on_command_finished` for why this is needed
|
||||
rather than each caller knowing its own result shape.
|
||||
"""
|
||||
if result is None:
|
||||
return "", True
|
||||
|
||||
# A single MotionResult (most commands).
|
||||
if hasattr(result, "ok"):
|
||||
return str(getattr(result, "message", "") or ""), bool(result.ok)
|
||||
|
||||
# A (MotionResult, value) pair from the SDO/RTEX reads. Checked before the
|
||||
# plain-list case, because a tuple's first element is a MotionResult here
|
||||
# while a stop-all list's items are.
|
||||
if isinstance(result, tuple) and len(result) == 2 and hasattr(result[0], "ok"):
|
||||
head, value = result
|
||||
if not head.ok:
|
||||
return str(getattr(head, "message", "") or ""), False
|
||||
return t("busdata.read_ok", value=value), True
|
||||
|
||||
# A list of MotionResults (stop-all): ok when every one succeeded, and the
|
||||
# first refusal's reason is the useful thing to show.
|
||||
if isinstance(result, (list, tuple)) and result and hasattr(result[0], "ok"):
|
||||
failures = [item for item in result if not item.ok]
|
||||
if failures:
|
||||
return str(getattr(failures[0], "message", "") or ""), False
|
||||
return "", True
|
||||
|
||||
# A scanned address list.
|
||||
if isinstance(result, (list, tuple)):
|
||||
return ", ".join(str(item) for item in result), True
|
||||
|
||||
return str(result), True
|
||||
@@ -0,0 +1,130 @@
|
||||
"""The single-page workspace.
|
||||
|
||||
One tab per task the operator does on the platform, with the motion status strip
|
||||
living *outside* this widget so a tab change cannot hide the state that decides
|
||||
whether it is safe to act.
|
||||
|
||||
Pages that are not built yet are present as labelled placeholders rather than
|
||||
missing: the layout is being reviewed for operator use, and a tab that appears
|
||||
only later would change the layout under review. Each placeholder names what
|
||||
will fill it, so nobody mistakes an empty page for a broken one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from .axis_panel import AxisPanel
|
||||
from .sequence_panel import SequencePanel
|
||||
|
||||
#: Tab identity -> locale key for its label. Order is the display order, and it
|
||||
#: follows the work: commission the axes, describe the sequence, watch the
|
||||
#: sensor, look at the surface, then record calibration points.
|
||||
TABS: tuple[tuple[str, str], ...] = (
|
||||
("axes", "workspace.tab_axes"),
|
||||
("sequence", "workspace.tab_sequence"),
|
||||
("sensor", "workspace.tab_sensor"),
|
||||
("surface", "workspace.tab_surface"),
|
||||
("points", "workspace.tab_points"),
|
||||
)
|
||||
|
||||
#: Tabs whose content does not exist yet, with the locale key that explains what
|
||||
#: will appear there and when.
|
||||
_PLACEHOLDERS: dict[str, str] = {
|
||||
"sensor": "workspace.reserved_sensor",
|
||||
"surface": "workspace.reserved_surface",
|
||||
"points": "workspace.reserved_points",
|
||||
}
|
||||
|
||||
|
||||
class PlaceholderPage(QWidget):
|
||||
"""An empty page that says what it is waiting for."""
|
||||
|
||||
def __init__(self, locale_key: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._locale_key = locale_key
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(8)
|
||||
self.headline = QLabel(self)
|
||||
self.headline.setObjectName("placeholderTitle")
|
||||
self.body = QLabel(self)
|
||||
self.body.setObjectName("hintLabel")
|
||||
self.body.setWordWrap(True)
|
||||
layout.addWidget(self.headline)
|
||||
layout.addWidget(self.body)
|
||||
layout.addStretch(1)
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.headline.setText(t("workspace.not_built"))
|
||||
self.body.setText(t(self._locale_key))
|
||||
|
||||
|
||||
class WorkspaceTabs(QTabWidget):
|
||||
"""The task workspace, with a stable tab set."""
|
||||
|
||||
pageChanged = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
axis_panel: AxisPanel,
|
||||
sequence_panel: SequencePanel,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("workspaceTabs")
|
||||
self._pages: dict[str, QWidget] = {}
|
||||
self._placeholder_keys: dict[str, str] = {}
|
||||
|
||||
self._pages["axes"] = axis_panel
|
||||
self._pages["sequence"] = sequence_panel
|
||||
for key, locale_key in _PLACEHOLDERS.items():
|
||||
page = PlaceholderPage(locale_key, self)
|
||||
self._pages[key] = page
|
||||
self._placeholder_keys[key] = locale_key
|
||||
|
||||
for key, _locale_key in TABS:
|
||||
self.addTab(self._pages[key], "")
|
||||
|
||||
self.currentChanged.connect(self._on_current_changed)
|
||||
self.retranslate()
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
|
||||
def page_keys(self) -> list[str]:
|
||||
return [key for key, _locale_key in TABS]
|
||||
|
||||
def page(self, key: str) -> QWidget:
|
||||
return self._pages[key]
|
||||
|
||||
def current_key(self) -> str:
|
||||
index = self.currentIndex()
|
||||
keys = self.page_keys()
|
||||
return keys[index] if 0 <= index < len(keys) else keys[0]
|
||||
|
||||
def set_current_key(self, key: str) -> None:
|
||||
keys = self.page_keys()
|
||||
if key in keys:
|
||||
self.setCurrentIndex(keys.index(key))
|
||||
|
||||
def retranslate(self) -> None:
|
||||
for index, (key, locale_key) in enumerate(TABS):
|
||||
self.setTabText(index, t(locale_key))
|
||||
for key, locale_key in self._placeholder_keys.items():
|
||||
page = self._pages[key]
|
||||
if isinstance(page, PlaceholderPage):
|
||||
page.retranslate()
|
||||
|
||||
def _on_current_changed(self, index: int) -> None:
|
||||
keys = self.page_keys()
|
||||
if 0 <= index < len(keys):
|
||||
self.pageChanged.emit(keys[index])
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Fixtures for the AutoCal tests.
|
||||
|
||||
Everything here runs offscreen with no controller and no hardware. Two
|
||||
environment facts are set before Qt is imported:
|
||||
|
||||
* ``QT_QPA_PLATFORM=offscreen`` so a window can be built without a display;
|
||||
* ``QT_QPA_FONTDIR`` because the offscreen platform finds no fonts on Windows
|
||||
otherwise, and every label would measure as zero-width.
|
||||
|
||||
The suite requires PySide6 — the app's only Qt binding. Where it is missing
|
||||
(an older interpreter, or a machine with no way to install it) the tests are
|
||||
reported as skipped rather than failing, so a broken environment cannot look
|
||||
like a broken application.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("ORISYS_APP_ID", "autocal")
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
|
||||
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
|
||||
if _fonts.is_dir():
|
||||
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
|
||||
|
||||
|
||||
def _have_pyside6() -> tuple[bool, str]:
|
||||
try:
|
||||
import PySide6
|
||||
except ImportError as exc:
|
||||
return False, str(exc)
|
||||
return True, getattr(PySide6, "__version__", "unknown")
|
||||
|
||||
|
||||
HAVE_PYSIDE6, PYSIDE6_VERSION = _have_pyside6()
|
||||
|
||||
if not HAVE_PYSIDE6: # pragma: no cover - depends on the machine
|
||||
collect_ignore_glob = ["test_*.py"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def qt_app():
|
||||
"""One ``QApplication`` per session (Qt forbids more than one)."""
|
||||
if not HAVE_PYSIDE6: # pragma: no cover
|
||||
pytest.skip("PySide6 is not installed")
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
SCRATCH_ROOT = Path(__file__).resolve().parents[1] / "outputs" / "_test_scratch"
|
||||
|
||||
|
||||
def _scratch_dir() -> Path:
|
||||
"""A fresh scratch directory under the app's own (gitignored) outputs."""
|
||||
import time
|
||||
|
||||
path = SCRATCH_ROOT / f"{os.getpid()}-{int(time.time() * 1000) % 1_000_000}"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_window(qt_app, monkeypatch):
|
||||
"""Build a window whose preference writes stay inside a scratch directory.
|
||||
|
||||
Deliberately not ``tmp_path``: this suite has to run inside a sandboxed
|
||||
shell, and ``tmp_path`` depends on a writable process temp root that such an
|
||||
environment may not provide. Using the app's own ``outputs/`` (already
|
||||
gitignored) removes that dependency entirely.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
from orisys_autocal.motion import DemoTransport
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
|
||||
scratch = _scratch_dir()
|
||||
prefs_file = scratch / "preference.json"
|
||||
monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file)
|
||||
monkeypatch.setattr(prefs_pkg.store, "preference_path", lambda: prefs_file)
|
||||
monkeypatch.setattr(prefs_mod, "preference_path", lambda: prefs_file)
|
||||
|
||||
created = []
|
||||
|
||||
def _make(**overrides):
|
||||
prefs = prefs_mod.default_preferences()
|
||||
prefs.update(overrides)
|
||||
# The translator is process-global, so a window built after another test
|
||||
# switched language would otherwise keep that language and then be
|
||||
# labelled with the wrong locale.
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
|
||||
set_locale(str(prefs.get("language") or "zh_CN"))
|
||||
window = AutoCalMainWindow(prefs=prefs)
|
||||
# Swap in the simulated platform: no test in this suite touches a DLL.
|
||||
window.poller.service._transport = DemoTransport() # noqa: SLF001
|
||||
created.append(window)
|
||||
return window
|
||||
|
||||
yield _make
|
||||
|
||||
for window in created:
|
||||
try:
|
||||
window.poller.shutdown()
|
||||
window.close()
|
||||
except Exception: # noqa: BLE001 - teardown must not mask a failure
|
||||
pass
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
|
||||
|
||||
def pump(app, seconds: float = 0.2) -> None:
|
||||
"""Run the event loop for a short, bounded time."""
|
||||
import time
|
||||
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
time.sleep(0.005)
|
||||
|
||||
|
||||
def visible_descendants(widget):
|
||||
"""Yield every visible descendant of ``widget``."""
|
||||
for child in widget.findChildren(object):
|
||||
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
|
||||
continue
|
||||
if child.isVisible():
|
||||
yield child
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Binding policy for AutoCal.
|
||||
|
||||
AutoCal is **PySide6 only**, and this file is the guard for that:
|
||||
|
||||
* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has
|
||||
PySide6 and nothing else, so such an import is a hard start-up failure;
|
||||
* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``), which PySide6
|
||||
removed in favour of ``Qt.AlignmentFlag.AlignLeft``;
|
||||
* ``shared/`` — which serves both this app and the PyQt5-based studio — must
|
||||
import no Qt binding at all;
|
||||
* the controller layer must stay importable from a headless process.
|
||||
|
||||
The reason the binding matters at all is licensing, not taste: PyQt5's free
|
||||
option is GPLv3-or-commercial, while PySide6 is LGPLv3, so an internal
|
||||
closed-source tool needs no licence purchase. See ``README.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
APP_SRC = Path(__file__).resolve().parents[1] / "src"
|
||||
SHARED_SRC = Path(__file__).resolve().parents[3] / "shared" / "src"
|
||||
|
||||
#: Bindings the app must never import.
|
||||
FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
|
||||
|
||||
#: The one binding allowed, and where it may be named.
|
||||
ALLOWED_BINDING = "PySide6"
|
||||
|
||||
#: Scoped enum holders we care about, with the value names actually used.
|
||||
_SCOPED_ENUMS = {
|
||||
"Qt": (
|
||||
"AlignmentFlag",
|
||||
"ItemDataRole",
|
||||
"Orientation",
|
||||
"ConnectionType",
|
||||
"TextInteractionFlag",
|
||||
"TransformationMode",
|
||||
"ToolButtonStyle",
|
||||
"FocusPolicy",
|
||||
"ScrollBarPolicy",
|
||||
),
|
||||
"QTableWidget": ("SelectionMode", "EditTrigger"),
|
||||
"QSizePolicy": ("Policy",),
|
||||
"QHeaderView": ("ResizeMode",),
|
||||
"QMessageBox": ("StandardButton", "Icon"),
|
||||
"QDialog": ("DialogCode",),
|
||||
"QAbstractItemView": ("SelectionMode", "EditTrigger"),
|
||||
}
|
||||
|
||||
|
||||
def _python_files(root: Path) -> list[Path]:
|
||||
return sorted(path for path in root.rglob("*.py") if "__pycache__" not in path.parts)
|
||||
|
||||
|
||||
def _imported_packages(path: Path) -> set[str]:
|
||||
"""Every top-level package name imported by ``path``."""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
found: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
found.add(alias.name.split(".")[0])
|
||||
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
||||
found.add(node.module.split(".")[0])
|
||||
return found
|
||||
|
||||
|
||||
def _unscoped_enum_references(path: Path) -> list[str]:
|
||||
"""Attribute reads like ``Qt.AlignLeft`` — the spelling PySide6 rejects.
|
||||
|
||||
Parsed rather than pattern-matched for two reasons: a regex also hits prose
|
||||
in a comment or docstring (which is exactly where the scoped spelling gets
|
||||
*explained*), and ``QMessageBox.information(...)`` looks like an enumerator
|
||||
but is a static method. The parent map separates the two, and
|
||||
``Qt.AlignmentFlag.AlignLeft`` is an inner attribute on an allow-listed enum
|
||||
class rather than a plain name, so it is never a candidate.
|
||||
"""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
called = {
|
||||
id(node.func)
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||
}
|
||||
found: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Attribute) or not isinstance(node.value, ast.Name):
|
||||
continue
|
||||
if id(node) in called:
|
||||
continue # a method or function call, not an enumerator
|
||||
holder = node.value.id
|
||||
enum_names = _SCOPED_ENUMS.get(holder)
|
||||
if not enum_names or node.attr in enum_names:
|
||||
continue
|
||||
found.add(f"{holder}.{node.attr}")
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def test_no_module_imports_a_forbidden_binding():
|
||||
offenders = []
|
||||
for path in _python_files(APP_SRC):
|
||||
bad = _imported_packages(path) & FORBIDDEN_BINDINGS
|
||||
if bad:
|
||||
offenders.append(f"{path.name}: {sorted(bad)}")
|
||||
assert not offenders, (
|
||||
f"AutoCal is {ALLOWED_BINDING}-only; these modules import another "
|
||||
f"binding: {offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_every_ui_module_imports_the_allowed_binding():
|
||||
"""A UI file that names no binding would be using one it does not declare."""
|
||||
offenders = []
|
||||
for path in _python_files(APP_SRC / "orisys_autocal" / "ui"):
|
||||
if path.name == "__init__.py":
|
||||
continue # a re-export barrel, not a widget module
|
||||
if ALLOWED_BINDING not in _imported_packages(path):
|
||||
offenders.append(path.name)
|
||||
assert not offenders, f"these UI modules do not import {ALLOWED_BINDING}: {offenders}"
|
||||
|
||||
|
||||
def test_no_module_uses_an_unscoped_enum():
|
||||
offenders = []
|
||||
for path in _python_files(APP_SRC):
|
||||
references = _unscoped_enum_references(path)
|
||||
if references:
|
||||
offenders.append(f"{path.name}: {references}")
|
||||
assert not offenders, (
|
||||
"unscoped Qt enumerators were removed in PySide6; use the scoped "
|
||||
f"spelling: {offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_controller_interface_is_qt_free():
|
||||
"""The controller and its guards stay importable from a headless process.
|
||||
|
||||
``poller`` is deliberately absent from this check: it *is* the Qt worker
|
||||
bridge (``QThread``, signals), and it lives beside the service precisely so
|
||||
that the service, the transports, the models and the error map do not need
|
||||
a GUI to be exercised.
|
||||
"""
|
||||
qt_free = ("models.py", "errors.py", "units.py", "service.py", "transport.py")
|
||||
offenders = []
|
||||
for path in _python_files(APP_SRC / "orisys_autocal" / "motion"):
|
||||
if path.name not in qt_free:
|
||||
continue
|
||||
qt_imports = _imported_packages(path) & {"PySide2", "PySide6", "PyQt5", "PyQt6"}
|
||||
if qt_imports:
|
||||
offenders.append(f"{path.name}: {sorted(qt_imports)}")
|
||||
assert not offenders, offenders
|
||||
|
||||
|
||||
def test_shared_imports_no_qt_binding():
|
||||
"""Shared code serves both products, so it must not pick a side."""
|
||||
offenders = []
|
||||
for path in _python_files(SHARED_SRC):
|
||||
qt_imports = _imported_packages(path) & {"PySide2", "PySide6"}
|
||||
if qt_imports:
|
||||
offenders.append(f"{path.name}: {sorted(qt_imports)}")
|
||||
assert not offenders, (
|
||||
"shared code must not import PySide2/PySide6 (studio is PyQt5): "
|
||||
f"{offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_zmotion_client_imports_no_qt_binding():
|
||||
zmotion = SHARED_SRC / "orisys_app_shared" / "zmotion"
|
||||
offenders = []
|
||||
for path in _python_files(zmotion):
|
||||
qt_imports = _imported_packages(path) & {"PySide2", "PySide6", "PyQt5", "PyQt6"}
|
||||
if qt_imports:
|
||||
offenders.append(f"{path.name}: {sorted(qt_imports)}")
|
||||
assert not offenders, offenders
|
||||
|
||||
|
||||
def test_the_app_declares_its_binding_dependency():
|
||||
"""The packaging must state PySide6, not leave it to chance."""
|
||||
pyproject = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert ALLOWED_BINDING in pyproject
|
||||
for forbidden in FORBIDDEN_BINDINGS:
|
||||
assert f'"{forbidden}' not in pyproject, forbidden
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Controller return-code translation.
|
||||
|
||||
The vendor SDK header defines the auxiliary error block at 30000; everything
|
||||
else is the firmware's own number. The point of these tests is that AutoCal
|
||||
never invents a meaning for a code it cannot read out of that header.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import errors
|
||||
|
||||
DOCUMENTED = sorted(errors.MESSAGES)
|
||||
|
||||
|
||||
def test_zero_is_success_in_both_languages():
|
||||
assert errors.describe(0) == "成功"
|
||||
assert errors.describe(0, language="en_US") == "OK"
|
||||
|
||||
|
||||
def test_the_documented_block_is_the_vendor_offset():
|
||||
assert errors.ERR_AUX_OFFSET == 30000
|
||||
assert DOCUMENTED == [30000, 30001, 30002, 30003, 30004, 30005]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", DOCUMENTED)
|
||||
def test_every_documented_code_has_both_languages(code):
|
||||
zh = errors.describe(code)
|
||||
en = errors.describe(code, language="en_US")
|
||||
assert zh and en and zh != en
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", DOCUMENTED)
|
||||
def test_no_documented_message_is_empty(code):
|
||||
assert errors.describe(code).strip()
|
||||
assert errors.describe(code, language="en_US").strip()
|
||||
|
||||
|
||||
def test_an_unknown_code_keeps_its_number():
|
||||
"""A guessed meaning on a motion controller is worse than 'unknown'."""
|
||||
message = errors.describe(12345)
|
||||
assert "12345" in message
|
||||
assert message == "错误码 12345"
|
||||
|
||||
|
||||
def test_an_unknown_code_points_at_zdevelop():
|
||||
message = errors.describe_with_hint(-1)
|
||||
assert "-1" in message
|
||||
assert errors.UNKNOWN_HINT in message
|
||||
|
||||
|
||||
def test_a_documented_code_gets_no_hint_appended():
|
||||
assert errors.describe_with_hint(30000) == errors.describe(30000)
|
||||
@@ -0,0 +1,469 @@
|
||||
"""The single-page shell, from an operator's point of view.
|
||||
|
||||
These assertions are about the layout contract rather than any one panel:
|
||||
|
||||
* the motion status strip is outside the tabs, so it survives every tab change;
|
||||
* all five tabs exist, are labelled, and their pages fit;
|
||||
* the right-hand readout rail shows every drive and issues no per-axis command;
|
||||
* the window refuses to shrink below the size at which the page would clip.
|
||||
|
||||
The overflow check lives in ``test_panel_smoke.py`` because it needs the colour
|
||||
tables; what is here is the structure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import pump
|
||||
|
||||
from orisys_autocal.ui.workspace import TABS
|
||||
|
||||
|
||||
def _show(window, size=(1600, 900)):
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window.resize(*size)
|
||||
window.show()
|
||||
QApplication.processEvents()
|
||||
return window
|
||||
|
||||
|
||||
def test_the_tab_set_is_the_documented_one(make_window):
|
||||
window = _show(make_window())
|
||||
assert window.workspace.page_keys() == [key for key, _ in TABS]
|
||||
|
||||
|
||||
def test_every_tab_has_a_localised_label(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window = _show(make_window())
|
||||
for index, (_key, locale_key) in enumerate(TABS):
|
||||
label = window.workspace.tabText(index)
|
||||
assert label, f"tab {index} has no label"
|
||||
assert label == t(locale_key)
|
||||
assert label != locale_key, f"tab {index} shows its raw key"
|
||||
|
||||
|
||||
def test_the_status_strip_survives_every_tab_change(make_window):
|
||||
"""The whole point of putting the strip above the tabs."""
|
||||
window = _show(make_window())
|
||||
for key in window.workspace.page_keys():
|
||||
window.workspace.set_current_key(key)
|
||||
assert window.status_strip.isVisible(), f"the strip hid on tab {key}"
|
||||
assert window.status_strip.height() <= 48
|
||||
|
||||
|
||||
def test_the_strip_reports_connection_and_bus_state(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from orisys_autocal.motion import BusStatus, Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
strip = window.status_strip
|
||||
|
||||
strip.set_snapshot(Snapshot(connected=False))
|
||||
assert strip.chip_connection.text() == t("connection.state_closed")
|
||||
assert strip.chip_init.text() == t("bus.state_pending")
|
||||
|
||||
strip.set_snapshot(
|
||||
Snapshot(connected=True, bus=BusStatus(bus_type="ecat", init_status=1, total_axes=5))
|
||||
)
|
||||
assert strip.chip_connection.text() == t("connection.state_open")
|
||||
assert strip.chip_bus.text() == t("bus.type_ecat")
|
||||
assert strip.chip_init.text() == t("bus.state_ok")
|
||||
|
||||
|
||||
def test_a_refusal_stays_visible_on_the_strip(make_window):
|
||||
"""A command refused while the operator was on another tab must not vanish."""
|
||||
window = _show(make_window())
|
||||
window.workspace.set_current_key("sensor")
|
||||
window.status_strip.set_action_result("轴0 回零模式未设置", False)
|
||||
assert "回零模式" in window.status_strip.action_label.text()
|
||||
|
||||
|
||||
def test_the_readout_rail_covers_every_axis(make_window):
|
||||
window = _show(make_window())
|
||||
assert len(window.axis_readout._rows) == window.axis_panel.axis_count == 5
|
||||
|
||||
|
||||
def test_the_readout_rail_shows_position_and_state(make_window):
|
||||
from orisys_autocal.motion import AxisState, Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
snapshot = Snapshot(
|
||||
connected=True,
|
||||
axes=(
|
||||
AxisState(axis=0, enabled=True, units=10.0, dpos=1000.0, idle=True),
|
||||
AxisState(axis=1, enabled=False, units=10.0, dpos=0.0, idle=True),
|
||||
),
|
||||
)
|
||||
window.axis_readout.update_snapshot(snapshot)
|
||||
# 1000 user units at units=10 is the documented 2.000 mm, and the readout
|
||||
# shows both so an operator never has to convert in their head.
|
||||
text = window.axis_readout._rows[0]["position"].text()
|
||||
assert "1000.000" in text and "2.000 mm" in text, text
|
||||
assert window.axis_readout._rows[1]["state"].text()
|
||||
|
||||
|
||||
def test_the_readout_rail_issues_no_per_axis_motion(make_window):
|
||||
"""Per-axis commands live in one place only: the Axis-debug tab."""
|
||||
window = _show(make_window())
|
||||
rail = window.axis_readout
|
||||
assert not hasattr(rail, "jogRequested")
|
||||
assert not hasattr(rail, "moveAbsRequested")
|
||||
assert hasattr(rail, "stopAllRequested")
|
||||
assert hasattr(rail, "enableAllRequested")
|
||||
|
||||
|
||||
def test_enable_all_toggles_with_the_current_state(make_window):
|
||||
from orisys_autocal.motion import AxisState, Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
requested: list[bool] = []
|
||||
window.axis_readout.enableAllRequested.connect(requested.append)
|
||||
|
||||
window.axis_readout.update_snapshot(
|
||||
Snapshot(connected=True, axes=tuple(AxisState(axis=a, enabled=True) for a in range(5)))
|
||||
)
|
||||
window.axis_readout.enable_all_button.click()
|
||||
assert requested == [False], "enable-all did not become disable-all"
|
||||
|
||||
window.axis_readout.update_snapshot(Snapshot(connected=True, axes=()))
|
||||
window.axis_readout.enable_all_button.click()
|
||||
assert requested == [False, True]
|
||||
|
||||
|
||||
def test_the_window_refuses_to_shrink_below_the_page(make_window):
|
||||
window = _show(make_window())
|
||||
window.resize(800, 600)
|
||||
assert window.width() >= window.minimumWidth()
|
||||
assert window.height() >= window.minimumHeight()
|
||||
assert window.minimumWidth() >= 1300
|
||||
|
||||
|
||||
def test_the_bus_data_group_starts_collapsed(make_window):
|
||||
"""It is commissioning-only; expanded, it cost the page ~270 px."""
|
||||
window = _show(make_window())
|
||||
assert not window.bus_data_group.isChecked()
|
||||
assert not window.bus_data_panel.isVisible()
|
||||
|
||||
|
||||
def test_expanding_bus_data_does_not_resize_the_window(make_window):
|
||||
window = _show(make_window())
|
||||
before = (window.width(), window.height())
|
||||
window.bus_data_group.setChecked(True)
|
||||
pump(_app(), 0.1)
|
||||
assert (window.width(), window.height()) == before
|
||||
|
||||
|
||||
def _app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance()
|
||||
|
||||
|
||||
def test_the_sequence_tab_shows_the_documented_profile_fields(make_window):
|
||||
from orisys_autocal.ui.sequence_panel import PROFILE_FIELDS
|
||||
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
assert panel.profile_keys() == [key for key, _locale, _kind in PROFILE_FIELDS]
|
||||
|
||||
|
||||
def test_the_sequence_panel_exposes_its_three_zones(make_window):
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
assert panel.validation_checks() == [
|
||||
"on_surface",
|
||||
"within_travel",
|
||||
"contact_confirmable",
|
||||
]
|
||||
assert panel.point_row_count() > 0, "the point list is empty"
|
||||
assert panel.summary_label.text()
|
||||
|
||||
|
||||
def test_every_pending_control_names_the_phase_that_owns_it(make_window):
|
||||
"""A disabled control must say why, or it reads as a broken button."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
assert panel.pending_controls() == [
|
||||
"generate",
|
||||
"simulate",
|
||||
"dry_run",
|
||||
"save",
|
||||
"load",
|
||||
]
|
||||
for name, phase in panel.pending_phases().items():
|
||||
button = panel._pending_buttons[name]
|
||||
assert not button.isEnabled(), f"{name} is usable but not implemented"
|
||||
assert phase in button.toolTip(), f"{name} does not name its phase"
|
||||
assert button.text(), f"{name} has no label"
|
||||
|
||||
|
||||
def test_the_sequence_tab_hands_out_no_runnable_steps(make_window):
|
||||
"""A runner must not be able to start from points nothing has validated."""
|
||||
window = _show(make_window())
|
||||
assert window.sequence_panel.planned_steps() == []
|
||||
|
||||
|
||||
def test_point_coordinates_are_generated_not_typed(make_window):
|
||||
"""A typed coordinate is one nothing has checked against the sensor."""
|
||||
from orisys_autocal.ui.sequence_panel import _EDITABLE_POINT_COLUMNS
|
||||
|
||||
assert _EDITABLE_POINT_COLUMNS == ()
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
# The point list is a real table, so it scrolls instead of clipping rows.
|
||||
assert panel.table.rowCount() == panel.point_row_count()
|
||||
assert panel.point_row(0)["x"] == "0.000"
|
||||
# Every cell is read-only: the table's edit triggers are off, so a generated
|
||||
# coordinate cannot be typed over.
|
||||
from PySide6.QtWidgets import QTableWidget
|
||||
|
||||
assert panel.table.editTriggers() == QTableWidget.EditTrigger.NoEditTriggers
|
||||
|
||||
|
||||
def test_the_profile_round_trips_through_preferences(make_window):
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.field("pitch_mm").setText("3.5")
|
||||
panel.field("repeats").setText("4")
|
||||
window._persist_prefs()
|
||||
stored = window.prefs["sequence_profile"]
|
||||
assert stored["pitch_mm"] == 3.5
|
||||
assert stored["repeats"] == 4
|
||||
|
||||
|
||||
def test_an_unusable_profile_number_falls_back_rather_than_raising(make_window):
|
||||
from orisys_autocal.preferences import default_preferences
|
||||
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.field("pitch_mm").setText("not a number")
|
||||
assert panel.profile()["pitch_mm"] == 0.0
|
||||
# A blank repeats field must not become zero repeats.
|
||||
panel.field("repeats").setText("")
|
||||
assert panel.profile()["repeats"] >= 1
|
||||
assert "pitch_mm" in default_preferences()["sequence_profile"]
|
||||
|
||||
|
||||
def test_the_stored_profile_is_loaded_into_the_form(make_window):
|
||||
window = _show(make_window(sequence_profile={"pitch_mm": 7.5, "order": "row"}))
|
||||
panel = window.sequence_panel
|
||||
assert panel.profile()["pitch_mm"] == 7.5
|
||||
assert panel.profile()["order"] == "row"
|
||||
|
||||
|
||||
def test_the_axis_table_shows_seven_columns(make_window):
|
||||
from orisys_autocal.ui.axis_panel import COLUMNS
|
||||
|
||||
window = _show(make_window())
|
||||
assert len(COLUMNS) == 7
|
||||
assert window.axis_panel.table.columnCount() == 8 # seven data + actions
|
||||
|
||||
|
||||
def test_the_axis_detail_line_carries_what_the_table_dropped(make_window):
|
||||
"""Axis type, accel, feedback and the status word must stay reachable."""
|
||||
from orisys_autocal.motion import AxisState, Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
window.axis_panel.set_selected_axis(3)
|
||||
window.axis_panel.update_snapshot(
|
||||
Snapshot(
|
||||
connected=True,
|
||||
axes=(AxisState(axis=3, enabled=True, atype=65, accel=1234.0, mpos=5.0),),
|
||||
)
|
||||
)
|
||||
detail = window.axis_panel._detail_label.text()
|
||||
assert "65" in detail and "1234" in detail
|
||||
assert "3" in detail
|
||||
|
||||
|
||||
def test_the_axis_detail_follows_the_selected_axis(make_window):
|
||||
from orisys_autocal.motion import AxisState, Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
window.axis_panel.update_snapshot(
|
||||
Snapshot(
|
||||
connected=True,
|
||||
axes=(AxisState(axis=0, atype=65), AxisState(axis=1, atype=67)),
|
||||
)
|
||||
)
|
||||
window.axis_panel.set_selected_axis(0)
|
||||
assert "65" in window.axis_panel._detail_label.text()
|
||||
window.axis_panel.set_selected_axis(1)
|
||||
assert "67" in window.axis_panel._detail_label.text()
|
||||
|
||||
|
||||
def test_the_window_defaults_to_the_design_resolution(make_window):
|
||||
from orisys_autocal.ui.window import DEFAULT_WINDOW_SIZE, MINIMUM_WINDOW_SIZE
|
||||
|
||||
assert DEFAULT_WINDOW_SIZE == (1920, 1080)
|
||||
assert MINIMUM_WINDOW_SIZE[0] >= 1440
|
||||
|
||||
|
||||
def test_the_default_theme_is_light(make_window):
|
||||
from orisys_autocal.preferences import DEFAULT_THEME
|
||||
|
||||
assert DEFAULT_THEME == "light"
|
||||
window = _show(make_window())
|
||||
assert window._theme == "light"
|
||||
|
||||
|
||||
def test_pausing_the_strip_pauses_the_poller(make_window):
|
||||
window = _show(make_window())
|
||||
window.status_strip.pause_button.setChecked(True)
|
||||
window._on_pause_polling()
|
||||
assert window.poller.paused
|
||||
window.status_strip.pause_button.setChecked(False)
|
||||
window._on_pause_polling()
|
||||
assert not window.poller.paused
|
||||
|
||||
|
||||
def test_the_diagnostics_footer_does_not_move_the_layout(make_window):
|
||||
window = _show(make_window())
|
||||
splitters = window.main_splitter.sizes()
|
||||
window._set_diagnostics(True)
|
||||
window._set_diagnostics(False)
|
||||
assert window.main_splitter.sizes() == splitters
|
||||
|
||||
|
||||
def test_splitter_sizes_round_trip_through_preferences(make_window):
|
||||
window = _show(make_window())
|
||||
window.main_splitter.setSizes([340, 1500])
|
||||
window._persist_prefs()
|
||||
stored = window.prefs["main_splitter_sizes"]
|
||||
assert len(stored) == 2
|
||||
assert stored[1] > stored[0]
|
||||
|
||||
|
||||
def test_the_rail_is_a_single_scrollable_column(make_window):
|
||||
"""Two thin rails bracketing the workspace cost it 240 px at every size."""
|
||||
from orisys_autocal.ui.window import MAIN_SPLITTER_SIZES
|
||||
|
||||
assert len(MAIN_SPLITTER_SIZES) == 2, "the layout is back to three columns"
|
||||
window = _show(make_window())
|
||||
assert window.main_splitter.count() == 2
|
||||
assert window.left_rail.widget() is window.rail_content
|
||||
# The readout lives in the rail now, not in a rail of its own.
|
||||
assert window.rail_content.findChild(type(window.axis_readout)) is window.axis_readout
|
||||
|
||||
|
||||
def test_the_rail_scrolls_rather_than_clipping_its_controls(make_window):
|
||||
"""At 900 px high the three rail groups do not fit; the rail must scroll."""
|
||||
window = _show(make_window(), (1440, 900))
|
||||
viewport = window.left_rail.viewport().height()
|
||||
content = window.rail_content.sizeHint().height()
|
||||
scroll = window.left_rail.verticalScrollBar()
|
||||
assert content <= viewport or scroll.maximum() > 0, (
|
||||
f"rail content is {content}px in a {viewport}px viewport and cannot scroll"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- run controls
|
||||
|
||||
|
||||
def test_the_run_bar_is_visible_from_every_tab(make_window):
|
||||
"""Abort must never require finding the tab that started the run."""
|
||||
window = _show(make_window())
|
||||
for key in window.workspace.page_keys():
|
||||
window.workspace.set_current_key(key)
|
||||
assert window.run_bar.isVisible(), f"the run bar hid on tab {key}"
|
||||
|
||||
|
||||
def test_run_is_wired_and_refuses_by_naming_its_phase(make_window):
|
||||
window = _show(make_window())
|
||||
window.run_bar.run_button.click()
|
||||
assert "P7" in window.status_strip.action_label.text()
|
||||
|
||||
|
||||
def test_abort_stops_the_axes_and_is_enabled_once_a_run_exists(make_window):
|
||||
window = _show(make_window())
|
||||
assert not window.run_bar.abort_button.isEnabled()
|
||||
|
||||
requested = []
|
||||
window.run_bar.abortRequested.connect(lambda: requested.append(True))
|
||||
window.run_bar.set_run(True)
|
||||
assert window.run_bar.abort_button.isEnabled()
|
||||
window.run_bar.abort_button.click()
|
||||
assert requested == [True]
|
||||
assert window.run_bar.phase == "failed"
|
||||
|
||||
|
||||
def test_the_run_bar_shows_progress_and_timing(make_window):
|
||||
window = _show(make_window())
|
||||
window.run_bar.set_progress(7, 40)
|
||||
assert "7" in window.run_bar.step_label.text()
|
||||
assert "40" in window.run_bar.step_label.text()
|
||||
assert window.run_bar.progress.maximum() == 40
|
||||
assert window.run_bar.progress.value() == 7
|
||||
|
||||
window.run_bar.set_timing(125.0, 300.0)
|
||||
assert "2:05" in window.run_bar.time_label.text()
|
||||
# An unknown estimate must say so rather than showing a wrong number.
|
||||
window.run_bar.set_timing(125.0, None)
|
||||
assert "2:05" in window.run_bar.time_label.text()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- poll health
|
||||
|
||||
|
||||
def test_the_health_chip_reports_the_age_of_the_last_reading(make_window):
|
||||
from orisys_autocal.motion import Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
strip = window.status_strip
|
||||
strip.set_snapshot(Snapshot(connected=True))
|
||||
assert strip.chip_health.text()
|
||||
assert strip.chip_health._tone == "ok"
|
||||
|
||||
|
||||
def test_the_health_chip_ages_when_the_poll_stops(make_window):
|
||||
"""A stalled poll must not keep looking healthy."""
|
||||
from orisys_autocal.motion import Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
strip = window.status_strip
|
||||
strip.set_poll_interval_ms(100)
|
||||
strip.set_snapshot(Snapshot(connected=True))
|
||||
strip._last_snapshot_at -= 1.5 # fifteen intervals ago
|
||||
strip.refresh_health()
|
||||
assert strip.chip_health._tone == "error", strip.chip_health.text()
|
||||
|
||||
|
||||
def test_the_health_chip_says_paused_rather_than_ageing(make_window):
|
||||
from orisys_autocal.motion import Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
strip = window.status_strip
|
||||
strip.set_snapshot(Snapshot(connected=True))
|
||||
strip.set_paused(True)
|
||||
strip.refresh_health()
|
||||
assert strip.chip_health._tone == "muted"
|
||||
assert strip.chip_health.text() != ""
|
||||
|
||||
|
||||
def test_the_status_bar_uses_its_own_theme_token(make_window):
|
||||
"""The brand navy reads well as a header and heavy as a footer."""
|
||||
from orisys_app_shared.theme import load_colors
|
||||
|
||||
for theme in ("light", "dark"):
|
||||
colours = load_colors(theme)
|
||||
assert "status_bar_bg" in colours, theme
|
||||
assert "status_bar_text" in colours, theme
|
||||
window = _show(make_window())
|
||||
assert "QStatusBar" in window.statusBar().styleSheet()
|
||||
|
||||
|
||||
def test_language_switch_relabels_the_new_shell(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window = _show(make_window(language="zh_CN"))
|
||||
zh_tabs = [window.workspace.tabText(i) for i in range(window.workspace.count())]
|
||||
window._set_language("en_US")
|
||||
assert window.status_strip.title.text() == t("status.motion")
|
||||
assert window.run_bar.title.text() == t("run.title")
|
||||
en_tabs = [window.workspace.tabText(i) for i in range(window.workspace.count())]
|
||||
assert en_tabs != zh_tabs
|
||||
assert all(en_tabs)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Locale files: same keys everywhere, and a real fallback.
|
||||
|
||||
``Translator`` returns the key itself when a string is missing, which is silent
|
||||
in production — a label would simply read ``axes.nope``. The studio suite guards
|
||||
this by comparing key sets; the same guard applies here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
|
||||
LOCALES = Path(__file__).resolve().parents[1] / "config" / "locales"
|
||||
LANGUAGES = ("zh_CN", "en_US")
|
||||
|
||||
|
||||
def _load(language: str) -> dict:
|
||||
return json.loads((LOCALES / f"{language}.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _flatten(node: dict, prefix: str = "") -> set[str]:
|
||||
keys: set[str] = set()
|
||||
for key, value in node.items():
|
||||
path = f"{prefix}{key}"
|
||||
if isinstance(value, dict):
|
||||
keys |= _flatten(value, f"{path}.")
|
||||
else:
|
||||
keys.add(path)
|
||||
return keys
|
||||
|
||||
|
||||
def test_every_locale_file_exists():
|
||||
for language in LANGUAGES:
|
||||
assert (LOCALES / f"{language}.json").is_file(), language
|
||||
|
||||
|
||||
def test_locales_have_identical_key_sets():
|
||||
reference = _flatten(_load("zh_CN"))
|
||||
for language in LANGUAGES[1:]:
|
||||
other = _flatten(_load(language))
|
||||
assert other == reference, {
|
||||
f"{language}_only": sorted(other - reference),
|
||||
f"zh_CN_only": sorted(reference - other),
|
||||
}
|
||||
|
||||
|
||||
def test_no_translation_is_empty_where_a_label_is_expected():
|
||||
"""A blank string renders as an empty widget, which looks like a bug."""
|
||||
for language in LANGUAGES:
|
||||
for key, value in _walk(_load(language)):
|
||||
assert str(value).strip(), f"{language}: {key} is blank"
|
||||
|
||||
|
||||
def test_the_product_name_is_localised():
|
||||
"""The window title is Chinese in zh_CN and English in en_US."""
|
||||
assert _load("zh_CN")["app"]["title"] != _load("en_US")["app"]["title"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("language", LANGUAGES)
|
||||
def test_every_locale_loads_through_the_translator(language):
|
||||
from orisys_app_shared.i18n import Translator
|
||||
|
||||
translator = Translator(LOCALES, locale=language)
|
||||
for key in sorted(_flatten(_load(language))):
|
||||
assert translator.t(key) != key, f"{language}: {key} did not resolve"
|
||||
|
||||
|
||||
def test_the_default_preferences_file_matches_the_schema_shape():
|
||||
"""A shipped default that names a key the app does not have is dead weight."""
|
||||
shipped = json.loads(
|
||||
(LOCALES.parent / "preferences.default.json").read_text(encoding="utf-8")
|
||||
)
|
||||
schema = load_preferences()
|
||||
for key in shipped:
|
||||
assert key in schema, f"preferences.default.json has an unknown key: {key}"
|
||||
|
||||
|
||||
def _walk(node, prefix: str = ""):
|
||||
for key, value in node.items():
|
||||
path = f"{prefix}{key}"
|
||||
if isinstance(value, dict):
|
||||
yield from _walk(value, f"{path}.")
|
||||
else:
|
||||
yield path, value
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Guard and state-machine behaviour of the motion service.
|
||||
|
||||
Driven against the simulated platform, so every assertion here is about AutoCal's
|
||||
own logic: what it refuses, what it reads, and when. No test in this file touches
|
||||
a controller or a DLL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import (
|
||||
INIT_FAILED,
|
||||
INIT_OK,
|
||||
INIT_RUNNING,
|
||||
AxisParams,
|
||||
ConnectionSpec,
|
||||
DemoTransport,
|
||||
MotionService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service():
|
||||
return MotionService(DemoTransport())
|
||||
|
||||
|
||||
def _initialise(service, timeout: float = 5.0) -> None:
|
||||
"""Download a BAS program and wait for the simulated bus to report ready."""
|
||||
service.download_bas(__file__, 1)
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline and service.bus.init_status != INIT_OK:
|
||||
service.poll([0])
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- connection
|
||||
|
||||
|
||||
def test_a_fresh_service_is_disconnected_and_touches_nothing(service):
|
||||
assert not service.connected
|
||||
assert not service.bas_loaded
|
||||
assert service.poll([0]).connected is False
|
||||
|
||||
|
||||
def test_connect_reports_success_and_an_unknown_bus_without_a_bas(service):
|
||||
result = service.connect(ConnectionSpec(mode="demo"))
|
||||
assert result.ok
|
||||
assert service.connected
|
||||
# No BAS program has run, so the bus type must not be guessed at.
|
||||
snapshot = service.poll([0])
|
||||
assert snapshot.bus.bus_type == "unknown"
|
||||
assert not snapshot.bus.initialized
|
||||
|
||||
|
||||
def test_connect_failure_is_reported_without_raising(service):
|
||||
result = service.connect(ConnectionSpec(mode="eth", ip="127.0.0.1"))
|
||||
assert not result.ok
|
||||
assert not service.connected
|
||||
|
||||
|
||||
def test_disconnect_clears_bus_state(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
service.disconnect()
|
||||
assert not service.connected
|
||||
assert service.bus.bus_type == "unknown"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- bus init
|
||||
|
||||
|
||||
def test_download_requires_a_real_file(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
assert not service.download_bas("", 1).ok
|
||||
assert not service.download_bas(r"D:\nope\missing.bas", 1).ok
|
||||
|
||||
|
||||
def test_download_needs_a_connection(service):
|
||||
assert not service.download_bas(__file__, 1).ok
|
||||
|
||||
|
||||
def test_download_then_poll_reaches_initialised(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
assert service.download_bas(__file__, 1).ok
|
||||
_initialise(service)
|
||||
assert service.bus.init_status == INIT_OK
|
||||
assert service.bus.bus_type == "ecat"
|
||||
assert service.bus.total_axes == 5
|
||||
assert service.bus.node_count == 5
|
||||
|
||||
|
||||
def test_bus_is_running_immediately_after_download(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
service.download_bas(__file__, 1)
|
||||
assert service.bus.init_status == INIT_RUNNING
|
||||
|
||||
|
||||
def test_reinitialise_is_refused_while_already_running(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
service.download_bas(__file__, 1)
|
||||
assert not service.reinit_bus().ok
|
||||
|
||||
|
||||
def test_reinitialise_is_refused_without_a_bas_program(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
result = service.reinit_bus()
|
||||
assert not result.ok
|
||||
assert "BAS" in result.message or "Bas" in result.message
|
||||
|
||||
|
||||
def test_init_timeout_is_reported_rather_than_raising():
|
||||
"""A controller that never publishes its globals must time out, not hang."""
|
||||
|
||||
class SilentTransport(DemoTransport):
|
||||
def user_var(self, name):
|
||||
from orisys_app_shared.zmotion import ZAuxError
|
||||
|
||||
raise ZAuxError(30002, f"{name} is not defined")
|
||||
|
||||
service = MotionService(SilentTransport())
|
||||
service._init_timeout = 0.05 # noqa: SLF001 - shortening the vendor window
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
service.download_bas(__file__, 1)
|
||||
time.sleep(0.1)
|
||||
snapshot = service.poll([0])
|
||||
assert snapshot.bus.init_status == INIT_FAILED
|
||||
assert snapshot.bus.message # carries the ZDevelop hint, not an empty string
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- guards
|
||||
|
||||
|
||||
def test_motion_is_refused_before_the_bus_is_initialised(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
for attempt in (
|
||||
service.jog(0, True),
|
||||
service.move_abs(0, 100.0),
|
||||
service.move_rel(0, 10.0),
|
||||
service.home(0),
|
||||
):
|
||||
assert not attempt.ok
|
||||
assert attempt.message
|
||||
|
||||
|
||||
def test_motion_is_refused_when_not_connected(service):
|
||||
assert not service.jog(0, True).ok
|
||||
assert not service.move_abs(0, 1.0).ok
|
||||
assert not service.stop(0).ok
|
||||
|
||||
|
||||
def test_motion_on_an_unknown_axis_is_refused(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
result = service.jog(99, True)
|
||||
assert not result.ok
|
||||
assert "99" in result.message or "0-4" in result.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
|
||||
def test_a_non_finite_target_is_refused(service, bad):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
result = service.move_abs(0, bad)
|
||||
assert not result.ok
|
||||
assert result.message
|
||||
|
||||
|
||||
def test_home_needs_a_configured_mode(service):
|
||||
"""0 is not a valid homing mode; inheriting one would be worse than saying so."""
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
result = service.home(0)
|
||||
assert not result.ok
|
||||
assert "0" in result.message
|
||||
|
||||
|
||||
def test_home_runs_once_a_mode_is_set(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
service.set_axis_params(0, AxisParams(home_mode=17))
|
||||
assert service.home(0).ok
|
||||
|
||||
|
||||
def test_home_is_refused_on_rtex(service):
|
||||
"""The vendor example refuses RTEX homing; so do we."""
|
||||
|
||||
class RtexTransport(DemoTransport):
|
||||
def user_var(self, name):
|
||||
return 1.0 if name == "BUS_TYPE" else super().user_var(name)
|
||||
|
||||
rtex = MotionService(RtexTransport())
|
||||
rtex.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(rtex)
|
||||
assert rtex.bus.bus_type == "rtex"
|
||||
result = rtex.home(0)
|
||||
assert not result.ok
|
||||
assert result.message
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- motion
|
||||
|
||||
|
||||
def test_jog_and_stop_move_the_state_machine(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
assert service.jog(0, True).ok
|
||||
state = service.poll([0]).axis(0)
|
||||
assert state is not None and not state.idle
|
||||
|
||||
assert service.stop(0).ok
|
||||
state = service.poll([0]).axis(0)
|
||||
assert state is not None and state.idle
|
||||
|
||||
|
||||
def test_absolute_and_relative_moves_change_the_command_position(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
assert service.move_abs(1, 250.0).ok
|
||||
assert service.poll([1]).axis(1).dpos == pytest.approx(250.0)
|
||||
assert service.move_rel(1, -50.0).ok
|
||||
assert service.poll([1]).axis(1).dpos == pytest.approx(200.0)
|
||||
|
||||
|
||||
def test_set_axis_params_writes_and_remembers(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
params = AxisParams(units=5.0, speed=200.0, accel=2000.0)
|
||||
assert service.set_axis_params(2, params).ok
|
||||
assert service.params(2) == params
|
||||
assert service.to_mm(2, 1000.0) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_enable_toggles_the_axis(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
assert service.set_axis_enable(3, False).ok
|
||||
assert service.poll([3]).axis(3).enabled is False
|
||||
assert service.set_axis_enable(3, True).ok
|
||||
assert service.poll([3]).axis(3).enabled is True
|
||||
|
||||
|
||||
def test_stop_all_covers_every_axis(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
results = service.stop_all()
|
||||
assert len(results) == service.axis_count
|
||||
assert all(result.ok for result in results)
|
||||
|
||||
|
||||
def test_clear_alarm_reaches_the_drive(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
assert service.clear_alarm(0).ok
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- bus data
|
||||
|
||||
|
||||
def test_sdo_round_trips_on_ethercat(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
assert service.sdo_write(0, 0x6040, 0, 3, 6).ok
|
||||
result, value = service.sdo_read(0, 0x6040, 0, 3)
|
||||
assert result.ok
|
||||
assert value == 6
|
||||
|
||||
|
||||
def test_sdo_is_refused_on_rtex(service):
|
||||
class RtexTransport(DemoTransport):
|
||||
def user_var(self, name):
|
||||
return 1.0 if name == "BUS_TYPE" else super().user_var(name)
|
||||
|
||||
rtex = MotionService(RtexTransport())
|
||||
rtex.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(rtex)
|
||||
result, _value = rtex.sdo_read(0, 0x6040, 0, 3)
|
||||
assert not result.ok
|
||||
|
||||
|
||||
def test_rtex_is_refused_on_ethercat(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
result, _value = service.rtex_read(0, 1)
|
||||
assert not result.ok
|
||||
|
||||
|
||||
def test_bus_data_is_refused_before_the_bus_type_is_known(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
result, _value = service.sdo_read(0, 0x6040, 0, 3)
|
||||
assert not result.ok
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- polling
|
||||
|
||||
|
||||
def test_polling_reports_every_requested_axis(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
snapshot = service.poll([0, 1, 2, 3, 4])
|
||||
assert [state.axis for state in snapshot.axes] == [0, 1, 2, 3, 4]
|
||||
assert all(state.read_ok for state in snapshot.axes)
|
||||
|
||||
|
||||
def test_a_read_failure_is_marked_not_silently_zeroed():
|
||||
"""A failed read must not look like a position of zero."""
|
||||
|
||||
class FailingReads(DemoTransport):
|
||||
def get_dpos(self, axis):
|
||||
from orisys_app_shared.zmotion import ZAuxError
|
||||
|
||||
raise ZAuxError(30000, "no response")
|
||||
|
||||
service = MotionService(FailingReads())
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
snapshot = service.poll([0])
|
||||
state = snapshot.axis(0)
|
||||
assert state is not None
|
||||
assert state.read_ok is False
|
||||
assert state.error
|
||||
|
||||
|
||||
def test_language_switches_the_refusal_text(service):
|
||||
assert not service.jog(0, True).ok
|
||||
zh = service.jog(0, True).message
|
||||
service.set_language("en_US")
|
||||
en = service.jog(0, True).message
|
||||
assert zh != en
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Offscreen UI smoke tests: construction, layout, gating, and preferences.
|
||||
|
||||
The window is built for real, with the simulated platform standing in for the
|
||||
controller. Nothing here needs a display or hardware.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import pump, visible_descendants
|
||||
|
||||
RESOLUTIONS = ((1440, 900), (1600, 900), (1920, 1080))
|
||||
|
||||
|
||||
def _show(window, size=(1440, 880)):
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window.resize(*size)
|
||||
window.show()
|
||||
QApplication.processEvents()
|
||||
return window
|
||||
|
||||
|
||||
def _enabled(widget) -> bool:
|
||||
"""Whether ``widget`` is effectively enabled, shown or not.
|
||||
|
||||
A plain ``isEnabled()`` is False for every child of a window that has not
|
||||
been shown, which makes an assertion about gating depend on whether the
|
||||
test happened to realise the window. ``isEnabledTo`` answers the question
|
||||
the tests actually mean: would a user be able to operate this control?
|
||||
"""
|
||||
return widget.isEnabledTo(widget.window())
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ structure
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("theme", "language"), [
|
||||
("dark", "zh_CN"),
|
||||
("light", "zh_CN"),
|
||||
("dark", "en_US"),
|
||||
("light", "en_US"),
|
||||
])
|
||||
def test_the_window_builds_in_every_theme_and_language(make_window, theme, language):
|
||||
window = _show(make_window(theme=theme, language=language))
|
||||
assert window.windowTitle()
|
||||
assert window.axis_panel.table.rowCount() == 5
|
||||
|
||||
|
||||
def test_the_product_name_is_localised_in_the_window_title(make_window):
|
||||
window = _show(make_window(language="zh_CN"))
|
||||
zh = window.windowTitle()
|
||||
window._set_language("en_US")
|
||||
assert window.windowTitle() != zh
|
||||
|
||||
|
||||
def test_every_axis_row_has_a_labelled_control(make_window):
|
||||
window = _show(make_window())
|
||||
panel = window.axis_panel
|
||||
for row in range(panel.axis_count):
|
||||
widgets = panel._rows[row]
|
||||
for key in ("enable_button", "fwd_button", "rev_button", "stop_button"):
|
||||
assert widgets[key].text().strip(), f"row {row} {key} has no label"
|
||||
|
||||
|
||||
def test_the_table_shows_one_column_per_documented_quantity(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from orisys_autocal.ui.axis_panel import COLUMNS
|
||||
|
||||
window = _show(make_window())
|
||||
headers = [
|
||||
window.axis_panel.table.horizontalHeaderItem(index).text()
|
||||
for index in range(window.axis_panel.table.columnCount())
|
||||
]
|
||||
assert headers == [t(key) for key in COLUMNS] + [t("axes.controls")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", RESOLUTIONS)
|
||||
def test_every_axis_row_is_reachable(make_window, size):
|
||||
"""No drive may be hidden by the splitter.
|
||||
|
||||
The panel asks for three rows without scrolling; at the default window size
|
||||
all five must be visible, because a clipped fifth row on a five-axis platform
|
||||
reads as a missing drive rather than as a scrollbar. If the panel is ever
|
||||
squeezed below that, the table has to scroll rather than silently cut a row.
|
||||
"""
|
||||
window = _show(make_window(), size)
|
||||
table = window.axis_panel.table
|
||||
rows_height = sum(
|
||||
table.rowHeight(row) for row in range(window.axis_panel.axis_count)
|
||||
)
|
||||
header = table.horizontalHeader().height()
|
||||
available = table.viewport().height()
|
||||
|
||||
assert available >= rows_height or table.verticalScrollBar().maximum() > 0, (
|
||||
f"{size}: rows need {rows_height}px, viewport has {available}px "
|
||||
f"(header {header}px) and it cannot scroll"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", RESOLUTIONS)
|
||||
def test_no_visible_child_escapes_its_parent(make_window, size):
|
||||
window = _show(make_window(), size)
|
||||
oversize = []
|
||||
for child in visible_descendants(window):
|
||||
if _inside_scroll_area(child):
|
||||
# A scroll area's content is *meant* to exceed its viewport: the rail
|
||||
# scrolls instead of squeezing its controls into an unreadable column.
|
||||
continue
|
||||
parent = child.parentWidget()
|
||||
if parent is None or not parent.isVisible():
|
||||
continue
|
||||
geometry = child.geometry()
|
||||
if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1:
|
||||
oversize.append(
|
||||
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
|
||||
f" > parent {parent.width()}x{parent.height()}"
|
||||
)
|
||||
assert not oversize, oversize
|
||||
|
||||
|
||||
def _inside_scroll_area(widget) -> bool:
|
||||
"""Whether ``widget`` is content of a QAbstractScrollArea, not a laid-out child."""
|
||||
from PySide6.QtWidgets import QAbstractScrollArea
|
||||
|
||||
parent = widget.parentWidget()
|
||||
while parent is not None:
|
||||
if isinstance(parent, QAbstractScrollArea):
|
||||
return True
|
||||
parent = parent.parentWidget()
|
||||
return False
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- gating
|
||||
|
||||
|
||||
def test_motion_controls_start_disabled_without_a_bus(make_window):
|
||||
window = _show(make_window())
|
||||
assert not _enabled(window.axis_panel._rows[0]["fwd_button"])
|
||||
assert not _enabled(window.axis_panel._rows[0]["rev_button"])
|
||||
|
||||
|
||||
def test_bus_data_panels_follow_the_reported_bus_type(make_window):
|
||||
window = _show(make_window())
|
||||
window.bus_data_panel.set_connected(True)
|
||||
|
||||
window.bus_data_panel.set_bus_type("ecat")
|
||||
assert _enabled(window.bus_data_panel.ecat_box)
|
||||
assert not _enabled(window.bus_data_panel.rtex_box)
|
||||
|
||||
window.bus_data_panel.set_bus_type("rtex")
|
||||
assert _enabled(window.bus_data_panel.rtex_box)
|
||||
assert not _enabled(window.bus_data_panel.ecat_box)
|
||||
|
||||
window.bus_data_panel.set_bus_type("unknown")
|
||||
assert not _enabled(window.bus_data_panel.ecat_box)
|
||||
assert not _enabled(window.bus_data_panel.rtex_box)
|
||||
|
||||
|
||||
def test_bus_data_panels_are_disabled_while_disconnected(make_window):
|
||||
window = _show(make_window())
|
||||
window.bus_data_panel.set_bus_type("ecat")
|
||||
window.bus_data_panel.set_connected(False)
|
||||
assert not _enabled(window.bus_data_panel.ecat_box)
|
||||
|
||||
|
||||
def test_axis_rows_dim_when_the_bus_reports_fewer_drives(make_window):
|
||||
window = _show(make_window())
|
||||
window.axis_panel.set_connected(True)
|
||||
window.axis_panel.set_axis_count(3)
|
||||
assert _enabled(window.axis_panel._rows[2]["enable_button"])
|
||||
assert not _enabled(window.axis_panel._rows[3]["enable_button"])
|
||||
|
||||
|
||||
def test_unknown_connection_modes_report_instead_of_pretending(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window = _show(make_window())
|
||||
panel = window.connection_panel
|
||||
index = panel.mode_combo.findData("com")
|
||||
panel.mode_combo.setCurrentIndex(index)
|
||||
panel._emit_connect()
|
||||
assert panel.status_label.text() != t("connection.state_open")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- actions
|
||||
|
||||
|
||||
def test_the_selected_axis_follows_a_table_click(make_window):
|
||||
window = _show(make_window())
|
||||
window.axis_panel._on_cell_clicked(3, 0)
|
||||
assert window.axis_panel.selected_axis == 3
|
||||
assert window.axis_panel.axis_index_label.text() == "3"
|
||||
|
||||
|
||||
def test_blank_numeric_fields_fall_back_to_defaults(make_window):
|
||||
from orisys_autocal.motion import DEFAULT_UNITS
|
||||
|
||||
window = _show(make_window())
|
||||
panel = window.axis_panel
|
||||
panel.units_edit.setText("")
|
||||
assert panel.current_params().units == DEFAULT_UNITS
|
||||
panel.units_edit.setText("not a number")
|
||||
assert panel.current_params().units == DEFAULT_UNITS
|
||||
|
||||
|
||||
def test_a_blank_target_does_not_emit_a_move(make_window):
|
||||
window = _show(make_window())
|
||||
emitted = []
|
||||
window.axis_panel.moveAbsRequested.connect(lambda axis, value: emitted.append(value))
|
||||
window.axis_panel.target_edit.setText("")
|
||||
window.axis_panel._on_move_abs()
|
||||
assert emitted == []
|
||||
|
||||
|
||||
def test_applying_parameters_persists_them_in_the_panel(make_window):
|
||||
from orisys_autocal.motion import AxisParams
|
||||
|
||||
window = _show(make_window())
|
||||
panel = window.axis_panel
|
||||
panel.set_selected_axis(1)
|
||||
panel.units_edit.setText("5")
|
||||
panel.speed_edit.setText("250")
|
||||
panel.accel_edit.setText("2500")
|
||||
panel._on_apply()
|
||||
stored = panel.stored_params()[1]
|
||||
assert isinstance(stored, AxisParams)
|
||||
assert stored.units == 5.0
|
||||
|
||||
|
||||
# --------------------------------------------------------------- preferences
|
||||
|
||||
|
||||
def test_the_window_geometry_round_trips_through_preferences(make_window):
|
||||
# Above the enforced minimum (1400x860): a smaller request would be clamped,
|
||||
# which tests Qt's constraint rather than the preference round-trip.
|
||||
window = _show(make_window(), (1520, 920))
|
||||
window._persist_prefs()
|
||||
geometry = window.prefs["window_geometry"]
|
||||
assert len(geometry) == 4
|
||||
assert geometry[2] == pytest.approx(1520, abs=5)
|
||||
assert geometry[3] == pytest.approx(920, abs=5)
|
||||
|
||||
|
||||
def test_a_malformed_geometry_is_ignored_rather_than_crashing(make_window):
|
||||
window = _show(make_window(window_geometry=["nonsense"]))
|
||||
assert window.width() > 0
|
||||
|
||||
|
||||
def test_axis_parameters_survive_a_persist_reload(make_window):
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
|
||||
window = _show(make_window())
|
||||
window.axis_panel.set_selected_axis(2)
|
||||
window.axis_panel.units_edit.setText("7")
|
||||
window.axis_panel._on_apply()
|
||||
window._persist_prefs()
|
||||
|
||||
reloaded = prefs_mod.load_preferences()
|
||||
assert reloaded["axis_params"]["2"]["units"] == 7.0
|
||||
|
||||
|
||||
def test_the_bus_path_is_remembered(make_window):
|
||||
window = _show(make_window())
|
||||
window.bus_panel.set_bas_path(r"D:\somewhere\ECAT.bas")
|
||||
window._persist_prefs()
|
||||
assert window.prefs["bas_path"] == r"D:\somewhere\ECAT.bas"
|
||||
|
||||
|
||||
def test_sdo_fields_are_remembered(make_window):
|
||||
window = _show(make_window())
|
||||
panel = window.bus_data_panel
|
||||
panel.node_edit.setText("2")
|
||||
panel.index_edit.setText("24608")
|
||||
panel.subindex_edit.setText("1")
|
||||
window._persist_prefs()
|
||||
assert window.prefs["sdo_last"]["node"] == 2
|
||||
assert window.prefs["sdo_last"]["index"] == 24608
|
||||
|
||||
|
||||
# --------------------------------------------------------------- demo session
|
||||
|
||||
|
||||
def test_a_demo_session_fills_the_table_and_gates_motion(make_window, qt_app):
|
||||
"""End to end through the real signal path: connect, initialise, jog."""
|
||||
from orisys_autocal.motion import ConnectionSpec
|
||||
|
||||
window = _show(make_window())
|
||||
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
|
||||
assert _wait_for(lambda: window._last_snapshot.connected, qt_app)
|
||||
|
||||
# Download the shipped ECAT.bas, then wait for the simulated scan.
|
||||
window.bus_panel.set_bas_path(str(_shipped_bas("ECAT.bas")))
|
||||
window._on_download_bas()
|
||||
assert _wait_for(lambda: window._last_snapshot.bus.init_status == 1, qt_app, timeout=8.0)
|
||||
|
||||
assert window._last_snapshot.bus.bus_type == "ecat"
|
||||
assert _enabled(window.bus_data_panel.ecat_box)
|
||||
assert _enabled(window.axis_panel._rows[0]["fwd_button"])
|
||||
# ATYPE is no longer a table column — the seven-column table shows position
|
||||
# and status, and the drive's full frame is on the selected-axis detail line.
|
||||
window.axis_panel.set_selected_axis(0)
|
||||
assert "65" in window.axis_panel._detail_label.text()
|
||||
pump(qt_app, 0.2)
|
||||
|
||||
|
||||
def test_closing_the_window_stops_the_poller(make_window, qt_app):
|
||||
window = _show(make_window())
|
||||
window.close()
|
||||
pump(qt_app, 0.2)
|
||||
assert not window.poller._thread.isRunning()
|
||||
|
||||
|
||||
def _shipped_bas(name: str):
|
||||
from orisys_autocal.ui.bus_panel import default_bas_dir
|
||||
|
||||
return default_bas_dir() / name
|
||||
|
||||
|
||||
def _wait_for(predicate, app, timeout: float = 5.0) -> bool:
|
||||
import time
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.02)
|
||||
return False
|
||||
@@ -0,0 +1,233 @@
|
||||
"""The poller thread: serialisation, failure isolation, and clean teardown.
|
||||
|
||||
The two things that must never happen are a controller call on the GUI thread
|
||||
and two controller calls at once. Both are asserted here against a transport
|
||||
that records how it was used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec, DemoTransport, MotionPoller, MotionService
|
||||
|
||||
|
||||
class RecordingTransport(DemoTransport):
|
||||
"""A *connected* demo platform that records thread and overlap of every call.
|
||||
|
||||
Connected on purpose: the service polls only ``BUS_TYPE`` and ``Bus_InitStatus``
|
||||
without a BAS program, so an unconnected transport would keep every read
|
||||
short and nothing could be observed. A ``_concurrent`` guard asserts no two
|
||||
calls ever overlap rather than merely asserting the maximum sum of started
|
||||
calls, which would be trivially satisfied.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
delay: float = 0.0,
|
||||
unexpected_on: set[str] | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.delay = delay
|
||||
self.unexpected_on = unexpected_on or set()
|
||||
self.thread_ids: set[int] = set()
|
||||
self.max_concurrent = 0
|
||||
self.overlaps: list[str] = []
|
||||
self.calls: list[str] = []
|
||||
self._lock = threading.Lock()
|
||||
self._concurrent = 0
|
||||
# A fake "BAS program has run": the bus reports EtherCAT and ready.
|
||||
self._init_started = None
|
||||
self._preloaded = True
|
||||
|
||||
def open(self, spec) -> None:
|
||||
super().open(ConnectionSpec(mode="demo"))
|
||||
self._bas_loaded = True
|
||||
|
||||
def user_var(self, name: str) -> float:
|
||||
if name == "BUS_TYPE":
|
||||
return 0.0
|
||||
if name == "Bus_InitStatus":
|
||||
return 1.0
|
||||
if name == "Bus_TotalAxisnum":
|
||||
return 5.0
|
||||
return super().user_var(name)
|
||||
|
||||
def _enter(self, name: str) -> None:
|
||||
with self._lock:
|
||||
self._concurrent += 1
|
||||
if self._concurrent > 1:
|
||||
self.overlaps.append(name)
|
||||
self.max_concurrent = max(self.max_concurrent, self._concurrent)
|
||||
self.calls.append(name)
|
||||
self.thread_ids.add(threading.get_ident())
|
||||
if self.delay:
|
||||
time.sleep(self.delay)
|
||||
if name in self.unexpected_on:
|
||||
raise RuntimeError(f"simulated unexpected fault in {name}")
|
||||
|
||||
def _exit(self) -> None:
|
||||
with self._lock:
|
||||
self._concurrent -= 1
|
||||
|
||||
def get_dpos(self, axis: int) -> float:
|
||||
self._enter("get_dpos")
|
||||
try:
|
||||
return super().get_dpos(axis)
|
||||
finally:
|
||||
self._exit()
|
||||
|
||||
def get_mpos(self, axis: int) -> float:
|
||||
self._enter("get_mpos")
|
||||
try:
|
||||
return super().get_mpos(axis)
|
||||
finally:
|
||||
self._exit()
|
||||
|
||||
def get_axis_status(self, axis: int) -> int:
|
||||
self._enter("get_axis_status")
|
||||
try:
|
||||
return super().get_axis_status(axis)
|
||||
finally:
|
||||
self._exit()
|
||||
|
||||
def get_if_idle(self, axis: int) -> int:
|
||||
self._enter("get_if_idle")
|
||||
try:
|
||||
return super().get_if_idle(axis)
|
||||
finally:
|
||||
self._exit()
|
||||
|
||||
|
||||
def _connected_poller(transport: RecordingTransport, *, axes=(0,)) -> MotionPoller:
|
||||
service = MotionService(transport)
|
||||
assert service.connect(ConnectionSpec(mode="demo")).ok
|
||||
return MotionPoller(service, axes=list(axes))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qt_app_session(qt_app):
|
||||
return qt_app
|
||||
|
||||
def _drain(app, seconds: float = 1.0) -> None:
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
time.sleep(0.005)
|
||||
|
||||
|
||||
def _wait_for(predicate, app, timeout: float = 5.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
def test_completed_commands_are_reported_and_none_are_lost(qt_app):
|
||||
transport = RecordingTransport()
|
||||
poller = _connected_poller(transport)
|
||||
try:
|
||||
repo = {"finished": [], "failed": []}
|
||||
poller.commandFinished.connect(lambda token, result: repo["finished"].append(token))
|
||||
poller.commandFailed.connect(lambda token, message: repo["failed"].append(token))
|
||||
|
||||
tokens = [poller.submit(lambda service: None) for _ in range(5)]
|
||||
assert _wait_for(lambda: len(repo["finished"]) == 5, qt_app)
|
||||
assert sorted(repo["finished"]) == sorted(tokens)
|
||||
assert repo["failed"] == []
|
||||
finally:
|
||||
poller.shutdown()
|
||||
|
||||
|
||||
def test_every_controller_call_happens_on_one_worker_thread(qt_app):
|
||||
transport = RecordingTransport()
|
||||
poller = _connected_poller(transport, axes=(0, 1))
|
||||
try:
|
||||
assert _wait_for(lambda: "get_dpos" in transport.calls, qt_app)
|
||||
poller.submit(lambda service: None)
|
||||
_drain(qt_app, 0.3)
|
||||
assert len(transport.thread_ids) == 1, transport.thread_ids
|
||||
assert threading.get_ident() not in transport.thread_ids, (
|
||||
"a controller call ran on the test's (GUI) thread"
|
||||
)
|
||||
finally:
|
||||
poller.shutdown()
|
||||
|
||||
|
||||
def test_two_controller_calls_never_overlap(qt_app):
|
||||
"""A slow poll plus queued commands must still serialise."""
|
||||
transport = RecordingTransport(delay=0.02)
|
||||
poller = _connected_poller(transport)
|
||||
try:
|
||||
for _ in range(4):
|
||||
poller.submit(lambda service: None)
|
||||
_drain(qt_app, 0.8)
|
||||
assert transport.max_concurrent == 1, (
|
||||
f"{transport.max_concurrent} controller calls overlapped at once"
|
||||
)
|
||||
assert not transport.overlaps, transport.overlaps
|
||||
finally:
|
||||
poller.shutdown()
|
||||
|
||||
|
||||
def test_an_unexpected_fault_is_reported_and_does_not_kill_the_poller(qt_app):
|
||||
"""A non-``ZAuxError`` from inside a command must not stop the timer."""
|
||||
transport = RecordingTransport(unexpected_on={"get_dpos"})
|
||||
poller = _connected_poller(transport)
|
||||
try:
|
||||
failures = []
|
||||
poller.commandFailed.connect(lambda token, message: failures.append((token, message)))
|
||||
|
||||
# The submitted action asks for the position directly, so the fault
|
||||
# surfaces from the command rather than being absorbed by a poll.
|
||||
poller.submit(lambda service: service.transport.get_dpos(0))
|
||||
assert _wait_for(lambda: bool(failures), qt_app)
|
||||
assert failures[0][1]
|
||||
|
||||
# The poller is still alive: later snapshots keep arriving.
|
||||
before = len(transport.calls)
|
||||
_drain(qt_app, 0.4)
|
||||
assert len(transport.calls) > before
|
||||
finally:
|
||||
poller.shutdown()
|
||||
|
||||
|
||||
def test_shutdown_stops_the_thread_and_is_idempotent(qt_app):
|
||||
poller = _connected_poller(RecordingTransport())
|
||||
_drain(qt_app, 0.2)
|
||||
poller.shutdown(timeout_ms=3000)
|
||||
poller.shutdown(timeout_ms=3000) # must not raise or hang
|
||||
|
||||
|
||||
def test_a_refused_command_still_arrives_with_its_reason(qt_app):
|
||||
"""An unconnected service refuses in the guard, without touching the DLL."""
|
||||
poller = MotionPoller(MotionService(RecordingTransport()), axes=[0])
|
||||
try:
|
||||
results = []
|
||||
poller.commandFinished.connect(lambda token, result: results.append(result))
|
||||
poller.submit(lambda service: service.jog(0, True))
|
||||
assert _wait_for(lambda: bool(results), qt_app)
|
||||
assert results[0].ok is False
|
||||
assert results[0].message
|
||||
finally:
|
||||
poller.shutdown()
|
||||
|
||||
|
||||
def test_connect_and_disconnect_round_trip_through_the_poller(qt_app):
|
||||
poller = MotionPoller(MotionService(RecordingTransport()), axes=[0])
|
||||
try:
|
||||
states = []
|
||||
poller.connectionChanged.connect(states.append)
|
||||
poller.connect_controller(ConnectionSpec(mode="demo"))
|
||||
assert _wait_for(lambda: poller.service.connected, qt_app)
|
||||
poller.disconnect_controller()
|
||||
assert _wait_for(lambda: not poller.service.connected, qt_app)
|
||||
finally:
|
||||
poller.shutdown()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Unit-to-millimetre conversion.
|
||||
|
||||
The reference program carries the bench measurement in a comment::
|
||||
|
||||
# 轴0(X)/轴1(Y) 实测: UNITS=10 时 1000用户单位 = 2mm → 每毫米 5000 脉冲
|
||||
# 当量5 → 1000单位=1mm,不是100单位=1mm
|
||||
|
||||
These are the assertions that keep that comment honest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion.units import (
|
||||
PULSES_PER_MM,
|
||||
mm_to_user_units,
|
||||
safe_float,
|
||||
user_units_to_mm,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("units", "user_units", "expected_mm"),
|
||||
[
|
||||
(10.0, 1000.0, 2.0), # the measured case from the vendor comment
|
||||
(5.0, 1000.0, 1.0), # ...and the second one
|
||||
(10.0, 0.0, 0.0),
|
||||
(10.0, 5000.0, 10.0),
|
||||
(10.0, -1000.0, -2.0), # negative travel must stay negative
|
||||
],
|
||||
)
|
||||
def test_conversion_matches_the_bench_measurement(units, user_units, expected_mm):
|
||||
assert user_units_to_mm(user_units, units=units) == pytest.approx(expected_mm)
|
||||
|
||||
|
||||
def test_default_units_is_the_platform_value():
|
||||
assert user_units_to_mm(1000.0) == pytest.approx(2.0)
|
||||
assert PULSES_PER_MM == 5000.0
|
||||
|
||||
|
||||
def test_conversion_round_trips():
|
||||
for units in (1.0, 5.0, 10.0, 37.5):
|
||||
millimetres = user_units_to_mm(1234.5, units=units)
|
||||
assert mm_to_user_units(millimetres, units=units) == pytest.approx(1234.5)
|
||||
|
||||
|
||||
def test_a_zero_or_negative_scale_is_not_a_number():
|
||||
"""A bad constant must be visibly unusable, not a plausible number."""
|
||||
assert math.isnan(user_units_to_mm(1000.0, pulses_per_mm=0.0))
|
||||
assert math.isnan(user_units_to_mm(1000.0, pulses_per_mm=-1.0))
|
||||
assert math.isnan(mm_to_user_units(1.0, units=0.0))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
["", " ", "abc", "1,5", None, "nan", "inf", "-inf"],
|
||||
)
|
||||
def test_unusable_entries_fall_back(text):
|
||||
assert safe_float(text, 42.0) == 42.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["0", "0.0", " 0 "])
|
||||
def test_zero_falls_back_because_it_would_divide(text):
|
||||
"""A pulse equivalent of zero must never reach the controller."""
|
||||
assert safe_float(text, 7.0) == 7.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("text", "expected"), [("5", 5.0), (" 2.5 ", 2.5), ("-3", -3.0)])
|
||||
def test_usable_entries_are_parsed(text, expected):
|
||||
assert safe_float(text, 42.0) == expected
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dependency-free offscreen check of the AutoCal window.
|
||||
|
||||
Builds the real window with the simulated platform and asserts the invariants
|
||||
that are easy to break and hard to notice: theme tokens present, layout fitting
|
||||
inside parents, controls gated by connection and bus state, locales complete.
|
||||
|
||||
python apps/autocal/tools/panel_check.py
|
||||
|
||||
No controller, no DLL and no display are needed. Mirrors
|
||||
``apps/studio/tools/ui_check.py``, but cannot import it: that module imports
|
||||
PyQt5, and AutoCal's environment has none.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
for path in (APP_ROOT / "src", APP_ROOT.parents[1] / "shared" / "src"):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
os.environ.setdefault("ORISYS_APP_ID", "autocal")
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
|
||||
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
|
||||
if _fonts.is_dir():
|
||||
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
|
||||
|
||||
RESOLUTIONS = ((1440, 900), (1920, 1080))
|
||||
|
||||
|
||||
class Checker:
|
||||
"""Collects failures instead of asserting, so one run reports everything."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.failures: list[str] = []
|
||||
self.checks = 0
|
||||
|
||||
def that(self, condition: bool, message: str) -> None:
|
||||
self.checks += 1
|
||||
if not condition:
|
||||
self.failures.append(message)
|
||||
|
||||
def report(self) -> int:
|
||||
print()
|
||||
if self.failures:
|
||||
print(f"FAILED — {len(self.failures)} of {self.checks} checks:")
|
||||
for failure in self.failures:
|
||||
print(f" - {failure}")
|
||||
return 1
|
||||
print(f"OK — {self.checks} checks passed")
|
||||
return 0
|
||||
|
||||
|
||||
def _visible_descendants(widget):
|
||||
for child in widget.findChildren(object):
|
||||
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
|
||||
continue
|
||||
if child.isVisible():
|
||||
yield child
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from PySide6 import __version__ as pyside_version
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.theme import build_qss, load_colors
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec
|
||||
from orisys_autocal.ui.axis_panel import COLUMNS
|
||||
from orisys_autocal.ui.bus_panel import default_bas_dir
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
|
||||
checker = Checker()
|
||||
app = QApplication.instance() or QApplication([])
|
||||
print(f"Qt binding: PySide6 {pyside_version}")
|
||||
|
||||
# ---- theme tokens -----------------------------------------------------
|
||||
for theme in ("dark", "light"):
|
||||
colors = load_colors(theme)
|
||||
qss = build_qss(theme)
|
||||
checker.that(bool(colors), f"{theme}: no colour tokens")
|
||||
checker.that(len(qss) > 500, f"{theme}: the stylesheet looks empty")
|
||||
checker.that(
|
||||
len(build_qss(theme)) == len(qss), f"{theme}: the stylesheet is not stable"
|
||||
)
|
||||
|
||||
# ---- locale completeness ---------------------------------------------
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
|
||||
prefs = load_preferences()
|
||||
for language in ("zh_CN", "en_US"):
|
||||
set_locale(language)
|
||||
checker.that(t("app.title") != "app.title", f"{language}: app.title missing")
|
||||
checker.that(t("axes.title") != "axes.title", f"{language}: axes.title missing")
|
||||
checker.that(
|
||||
t("bus.state_ok") != "bus.state_ok", f"{language}: bus.state_ok missing"
|
||||
)
|
||||
set_locale("zh_CN")
|
||||
|
||||
# ---- window ----------------------------------------------------------
|
||||
from orisys_autocal.motion import DemoTransport
|
||||
|
||||
for theme in ("dark", "light"):
|
||||
window = AutoCalMainWindow(prefs={**prefs, "theme": theme})
|
||||
window.poller.service._transport = DemoTransport() # noqa: SLF001
|
||||
app.setStyleSheet(build_qss(theme))
|
||||
print(f"\n--- theme={theme} ---")
|
||||
|
||||
for width, height in RESOLUTIONS:
|
||||
window.resize(width, height)
|
||||
window.show()
|
||||
app.processEvents()
|
||||
# Every tab, not just the default one: a page that overflows only
|
||||
# when its tab is selected is exactly the defect this catches.
|
||||
for key in window.workspace.page_keys():
|
||||
window.workspace.set_current_key(key)
|
||||
app.processEvents()
|
||||
oversize = []
|
||||
for child in _visible_descendants(window):
|
||||
if _inside_scroll_area(child):
|
||||
continue # a rail's content is meant to exceed its viewport
|
||||
parent = child.parentWidget()
|
||||
if parent is None or not parent.isVisible():
|
||||
continue
|
||||
geometry = child.geometry()
|
||||
if (
|
||||
geometry.width() > parent.width() + 1
|
||||
or geometry.height() > parent.height() + 1
|
||||
):
|
||||
oversize.append(
|
||||
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
|
||||
f" > {parent.width()}x{parent.height()}"
|
||||
)
|
||||
checker.that(
|
||||
not oversize, f"{theme} {width}x{height} tab={key}: {oversize}"
|
||||
)
|
||||
window.workspace.set_current_key("axes")
|
||||
print(f" {width}x{height}: every tab fits")
|
||||
|
||||
# ---- the permanent shell ----------------------------------------
|
||||
checker.that(
|
||||
window.workspace.page_keys() == ["axes", "sequence", "sensor", "surface", "points"],
|
||||
f"unexpected tab set: {window.workspace.page_keys()}",
|
||||
)
|
||||
checker.that(
|
||||
window.status_strip.isVisible(),
|
||||
"the motion status strip is not visible",
|
||||
)
|
||||
checker.that(
|
||||
window.status_strip.height() <= 48,
|
||||
f"the status strip is {window.status_strip.height()}px tall",
|
||||
)
|
||||
for key in window.workspace.page_keys():
|
||||
window.workspace.set_current_key(key)
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
window.status_strip.isVisible(),
|
||||
f"the status strip vanished on tab {key}",
|
||||
)
|
||||
checker.that(
|
||||
bool(window.workspace.tabText(window.workspace.currentIndex())),
|
||||
f"tab {key} has no label",
|
||||
)
|
||||
window.workspace.set_current_key("axes")
|
||||
|
||||
# The readout rail must show all five drives without scrolling.
|
||||
readout_rows = len(window.axis_readout._rows)
|
||||
checker.that(readout_rows == 5, f"the readout rail shows {readout_rows} axes")
|
||||
checker.that(
|
||||
window.axis_readout.width() >= 200,
|
||||
f"the readout rail is only {window.axis_readout.width()}px wide",
|
||||
)
|
||||
|
||||
# The sequence tab: three zones, a persisted profile, and a point list
|
||||
# whose coordinates are generated rather than typed.
|
||||
sequence = window.sequence_panel
|
||||
checker.that(
|
||||
sequence.profile_keys()
|
||||
== [
|
||||
"mode",
|
||||
"pitch_mm",
|
||||
"margin_mm",
|
||||
"radius_mm",
|
||||
"clear_height_mm",
|
||||
"approach_speed_mm_s",
|
||||
"press_speed_mm_s",
|
||||
"press_depth_mm",
|
||||
"dwell_s",
|
||||
"settle_window_s",
|
||||
"repeats",
|
||||
"order",
|
||||
],
|
||||
f"sequence profile fields changed: {sequence.profile_keys()}",
|
||||
)
|
||||
checker.that(
|
||||
sequence.validation_checks()
|
||||
== ["on_surface", "within_travel", "contact_confirmable"],
|
||||
f"sequence validation checks changed: {sequence.validation_checks()}",
|
||||
)
|
||||
for name, phase in sequence.pending_phases().items():
|
||||
button = sequence._pending_buttons[name]
|
||||
checker.that(
|
||||
not button.isEnabled() and phase in button.toolTip(),
|
||||
f"sequence control {name} is usable or does not name its phase",
|
||||
)
|
||||
checker.that(
|
||||
sequence.planned_steps() == [],
|
||||
"the sequence tab handed out steps a runner could start",
|
||||
)
|
||||
checker.that(
|
||||
sequence.point_row_count() > 0,
|
||||
"the sequence point list is empty",
|
||||
)
|
||||
checker.that(
|
||||
sequence.table.columnCount() == 8,
|
||||
f"the point list has {sequence.table.columnCount()} columns, expected 8",
|
||||
)
|
||||
|
||||
# The shell: one rail, a run bar, and a health chip.
|
||||
checker.that(
|
||||
window.main_splitter.count() == 2,
|
||||
f"the layout has {window.main_splitter.count()} columns, expected 2",
|
||||
)
|
||||
checker.that(
|
||||
window.left_rail.widget() is window.rail_content,
|
||||
"the setup rail is not scrollable",
|
||||
)
|
||||
checker.that(
|
||||
window.run_bar.isVisible(),
|
||||
"the run bar is not visible",
|
||||
)
|
||||
checker.that(
|
||||
not window.run_bar.abort_button.isEnabled(),
|
||||
"abort is enabled with no run in progress",
|
||||
)
|
||||
for key in window.workspace.page_keys():
|
||||
window.workspace.set_current_key(key)
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
window.run_bar.isVisible(),
|
||||
f"the run bar vanished on tab {key}",
|
||||
)
|
||||
window.workspace.set_current_key("axes")
|
||||
checker.that(
|
||||
bool(window.status_strip.chip_health.text()),
|
||||
"the poll-health chip has no text",
|
||||
)
|
||||
checker.that(
|
||||
"QStatusBar" in window.statusBar().styleSheet(),
|
||||
"the status bar is not using its own theme token",
|
||||
)
|
||||
|
||||
# A collapsed bus-data group must not cost the page any height.
|
||||
checker.that(
|
||||
not window.bus_data_panel.isVisible(),
|
||||
"the bus-data panel is expanded by default",
|
||||
)
|
||||
print(" shell: strip, readout rail and sequence profile verified")
|
||||
|
||||
panel = window.axis_panel
|
||||
checker.that(panel.axis_count == 5, "the platform is not five axes")
|
||||
checker.that(
|
||||
panel.table.columnCount() == len(COLUMNS) + 1,
|
||||
"the axis table lost a column",
|
||||
)
|
||||
# Every header must fit its column: a clipped header is how a table stops
|
||||
# being readable without anyone noticing.
|
||||
for index in range(panel.table.columnCount()):
|
||||
header = panel.table.horizontalHeaderItem(index).text()
|
||||
width = panel.table.columnWidth(index)
|
||||
needed = panel.table.fontMetrics().horizontalAdvance(header) + 12
|
||||
checker.that(
|
||||
width >= needed,
|
||||
f"axis column {index} ({header!r}) is {width}px, needs {needed}px",
|
||||
)
|
||||
checker.that(
|
||||
len(COLUMNS) == 7,
|
||||
f"the axis table should show seven data columns, has {len(COLUMNS)}",
|
||||
)
|
||||
checker.that(
|
||||
window.axis_panel._detail_label.text(),
|
||||
"the selected-axis detail line is empty",
|
||||
)
|
||||
headers = [
|
||||
panel.table.horizontalHeaderItem(index).text()
|
||||
for index in range(panel.table.columnCount())
|
||||
]
|
||||
checker.that(
|
||||
all(headers) and headers[0] == t("axes.axis"),
|
||||
f"axis table headers are not localised: {headers}",
|
||||
)
|
||||
checker.that(
|
||||
all(
|
||||
panel.table.item(row, 0) is not None for row in range(panel.axis_count)
|
||||
),
|
||||
"an axis row has no data cell",
|
||||
)
|
||||
|
||||
# Motion must be unreachable before a bus is initialised.
|
||||
checker.that(
|
||||
not panel._rows[0]["fwd_button"].isEnabled(),
|
||||
"jog is enabled before the bus is initialised",
|
||||
)
|
||||
|
||||
# Bus data follows the reported bus family. ``isEnabledTo`` rather than
|
||||
# ``isEnabled``: the panel now lives in a collapsed group, and Qt reports
|
||||
# a hidden ancestor as disabling everything inside it.
|
||||
box = window.bus_data_group
|
||||
window.bus_data_panel.set_connected(True)
|
||||
window.bus_data_panel.set_bus_type("ecat")
|
||||
checker.that(
|
||||
window.bus_data_panel.ecat_box.isEnabledTo(box)
|
||||
and not window.bus_data_panel.rtex_box.isEnabledTo(box),
|
||||
"the SDO/RTEX panels are not gated by bus type",
|
||||
)
|
||||
|
||||
# Expanding the group must reveal the panel, not resize the page.
|
||||
before = window.size()
|
||||
box.setChecked(True)
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
window.bus_data_panel.isVisible(), "expanding the group did not show the panel"
|
||||
)
|
||||
box.setChecked(False)
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
window.size() == before, "toggling bus data resized the window"
|
||||
)
|
||||
|
||||
# A full simulated session: connect, initialise from the shipped BAS.
|
||||
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
|
||||
_wait_for(app, lambda: window._last_snapshot.connected, 6.0)
|
||||
checker.that(
|
||||
window._last_snapshot.connected, "the simulated controller did not connect"
|
||||
)
|
||||
|
||||
window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas"))
|
||||
window._on_download_bas()
|
||||
ok = _wait_for(app, lambda: window._last_snapshot.bus.init_status == 1, 8.0)
|
||||
checker.that(ok, "the simulated bus did not report initialised")
|
||||
if ok:
|
||||
checker.that(
|
||||
window._last_snapshot.bus.bus_type == "ecat",
|
||||
"the bus type was not decoded from BUS_TYPE",
|
||||
)
|
||||
checker.that(
|
||||
window._last_snapshot.bus.total_axes == 5,
|
||||
"the bus did not report five axes",
|
||||
)
|
||||
checker.that(
|
||||
panel._rows[0]["fwd_button"].isEnabled(),
|
||||
"jog stayed disabled after the bus was initialised",
|
||||
)
|
||||
checker.that(
|
||||
"65" in window.axis_panel._detail_label.text(),
|
||||
"ATYPE was not read back on the detail line (expected 65, position mode)",
|
||||
)
|
||||
print(
|
||||
f" session: bus={window._last_snapshot.bus.bus_type} "
|
||||
f"axes={window._last_snapshot.bus.total_axes} "
|
||||
f"{panel.enabled_count_label.text()}"
|
||||
)
|
||||
# The strip must be reporting the live session, from whatever tab.
|
||||
window.workspace.set_current_key("surface")
|
||||
app.processEvents()
|
||||
strip = window.status_strip
|
||||
checker.that(
|
||||
strip.chip_connection.text() == t("connection.state_open"),
|
||||
f"strip connection chip says {strip.chip_connection.text()!r}",
|
||||
)
|
||||
checker.that(
|
||||
strip.chip_bus.text() == t("bus.type_ecat"),
|
||||
f"strip bus chip says {strip.chip_bus.text()!r}",
|
||||
)
|
||||
checker.that(
|
||||
strip.chip_init.text() == t("bus.state_ok"),
|
||||
f"strip init chip says {strip.chip_init.text()!r}",
|
||||
)
|
||||
checker.that(
|
||||
t("axes.enabled_count", on=5, total=5) == strip.chip_axes.text()
|
||||
or "5" in strip.chip_axes.text(),
|
||||
f"strip axis chip says {strip.chip_axes.text()!r}",
|
||||
)
|
||||
checker.that(
|
||||
window.axis_readout._rows[0]["position"].text().startswith("0.000"),
|
||||
f"readout row 0 shows {window.axis_readout._rows[0]['position'].text()!r}",
|
||||
)
|
||||
window.workspace.set_current_key("axes")
|
||||
|
||||
window.close()
|
||||
app.processEvents()
|
||||
|
||||
return checker.report()
|
||||
|
||||
|
||||
def _inside_scroll_area(widget) -> bool:
|
||||
"""Whether ``widget`` is scroll-area content rather than a laid-out child."""
|
||||
from PySide6.QtWidgets import QAbstractScrollArea
|
||||
|
||||
parent = widget.parentWidget()
|
||||
while parent is not None:
|
||||
if isinstance(parent, QAbstractScrollArea):
|
||||
return True
|
||||
parent = parent.parentWidget()
|
||||
return False
|
||||
|
||||
|
||||
def _wait_for(app, predicate, timeout: float) -> bool:
|
||||
import time
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.02)
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the AutoCal window offscreen to a PNG, for review.
|
||||
|
||||
python apps/autocal/tools/panel_snapshot.py --theme light --out shot.png
|
||||
|
||||
Optionally drives a simulated session first, so the shot shows live values:
|
||||
|
||||
python apps/autocal/tools/panel_snapshot.py --demo --language en_US --out shot.png
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
for path in (APP_ROOT / "src", APP_ROOT.parents[1] / "shared" / "src"):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
os.environ.setdefault("ORISYS_APP_ID", "autocal")
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
|
||||
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
|
||||
if _fonts.is_dir():
|
||||
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--theme", default="dark", choices=("dark", "light"))
|
||||
parser.add_argument("--language", default="zh_CN", choices=("zh_CN", "en_US"))
|
||||
parser.add_argument("--size", default="1600x900", help="WIDTHxHEIGHT")
|
||||
parser.add_argument("--out", default="autocal.png")
|
||||
parser.add_argument(
|
||||
"--tab",
|
||||
default="axes",
|
||||
help="workspace tab to show: axes, sequence, sensor, surface, points",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--compare",
|
||||
action="store_true",
|
||||
help="render every tab, named <out stem>_<tab>.<ext>, so the layout can be "
|
||||
"reviewed in one pass",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--demo",
|
||||
action="store_true",
|
||||
help="drive a simulated session so the shot shows live values",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
width, height = (int(part) for part in args.size.lower().split("x"))
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.theme import build_qss
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec, DemoTransport
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
from orisys_autocal.ui.bus_panel import default_bas_dir
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
|
||||
set_locale(args.language)
|
||||
app = QApplication.instance() or QApplication([])
|
||||
app.setStyleSheet(build_qss(args.theme))
|
||||
|
||||
prefs = load_preferences()
|
||||
prefs["theme"] = args.theme
|
||||
prefs["language"] = args.language
|
||||
prefs["show_diagnostics"] = False
|
||||
|
||||
window = AutoCalMainWindow(prefs=prefs)
|
||||
window.poller.service._transport = DemoTransport() # noqa: SLF001
|
||||
window.resize(width, height)
|
||||
window.show()
|
||||
app.processEvents()
|
||||
|
||||
if args.demo:
|
||||
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
|
||||
_wait_for(app, lambda: window._last_snapshot.connected, 6.0)
|
||||
window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas"))
|
||||
window._on_download_bas()
|
||||
_wait_for(app, lambda: window._last_snapshot.bus.init_status == 1, 8.0)
|
||||
window.axis_panel.set_selected_axis(0)
|
||||
window.axis_panel.target_edit.setText("1000")
|
||||
window._on_move_abs(0, 1000.0)
|
||||
_wait_for(app, lambda: window._last_snapshot.axis(0).dpos != 0, 4.0)
|
||||
|
||||
app.processEvents()
|
||||
|
||||
if args.compare:
|
||||
return _render_every_tab(window, app, args, width, height)
|
||||
|
||||
window.workspace.set_current_key(args.tab)
|
||||
app.processEvents()
|
||||
output = Path(args.out).resolve()
|
||||
if not window.grab().save(str(output)):
|
||||
print(f"could not write {output}", file=sys.stderr)
|
||||
return 1
|
||||
print(
|
||||
f"wrote {output} ({width}x{height}, theme={args.theme}, "
|
||||
f"lang={args.language}, tab={args.tab})"
|
||||
)
|
||||
|
||||
window.close()
|
||||
app.processEvents()
|
||||
return 0
|
||||
|
||||
|
||||
def _render_every_tab(window, app, args, width: int, height: int) -> int:
|
||||
"""One image per workspace tab, so the whole layout can be reviewed at once."""
|
||||
base = Path(args.out).resolve()
|
||||
failures = 0
|
||||
for key in window.workspace.page_keys():
|
||||
window.workspace.set_current_key(key)
|
||||
app.processEvents()
|
||||
target = base.with_name(f"{base.stem}_{key}{base.suffix or '.png'}")
|
||||
if window.grab().save(str(target)):
|
||||
print(f"wrote {target} ({width}x{height}, tab={key})")
|
||||
else:
|
||||
print(f"could not write {target}", file=sys.stderr)
|
||||
failures += 1
|
||||
window.close()
|
||||
app.processEvents()
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
def _wait_for(app, predicate, timeout: float) -> bool:
|
||||
import time
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.02)
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user