201 lines
9.9 KiB
Markdown
201 lines
9.9 KiB
Markdown
# 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 is GPLv3-or-commercial; the studio app moved to PySide6
|
||
for the same reason and no longer uses it.)
|
||
|
||
```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.
|
||
|
||
One contract in `shared/src/orisys_app_shared/zmotion/client.py` is worth
|
||
knowing before touching it: `ZAux_Execute` **must** be given a reply buffer.
|
||
Passing `NULL` with a zero length is a write through a null pointer inside the
|
||
DLL — a native crash the worker thread cannot catch — and the buffer is also
|
||
how a refused online command explains itself, because `ZAux_Execute` returns 0
|
||
for a `RUN` whose file the controller does not have.
|
||
|
||
## Bring-up sequence
|
||
|
||
The vendor sequence, with the checks AutoCal's own state machine adds. P1 of
|
||
`docs/workflow.md` is this list in the workflow's vocabulary.
|
||
|
||
1. **Connect** (`eth` + the controller's IP). On success the app adopts any BAS
|
||
program the controller is already running from a previous session instead of
|
||
claiming the bus is uninitialised; `BUS_TYPE` is read immediately, and
|
||
`Bus_InitStatus` on the first poll.
|
||
2. **Download only if the bus type is unknown.** `ECAT.bas` / `RTEX.bas` are
|
||
chosen from `config/bas/` (the vendor requires a non-Chinese path) and
|
||
written to ROM. A controller that still has the program needs no download.
|
||
3. **Wait for `Bus_InitStatus`** — `−1` running, `1` success, `0` failed —
|
||
inside the vendor's 25 s window, then a timeout message naming the ZDevelop
|
||
diagnostic (`?*ETHERCAT`).
|
||
4. **Re-initialise** re-runs the program without re-downloading it:
|
||
`STOPTASK <task>` then `RUN "<file>",<task>`. The task number is
|
||
`bas_task` in `preference.json` (default **4**, the task the vendor
|
||
reference uses); the file name comes from this session's download, or from the
|
||
remembered `bas_path` when the session adopted a running program (the
|
||
controller does not report the name). The controller answers a `RUN` for a
|
||
file it does not have with **code 0** and the refusal in its reply text, so
|
||
that text is checked and shown rather than reported as an initialisation in
|
||
progress.
|
||
5. **Enable, then jog.** Motion stays refused until step 3 reports success.
|
||
|
||
### Connection and error codes
|
||
|
||
`ZAux_*` returns two unrelated families of code, and they need different answers:
|
||
|
||
| Range | Family | What the operator should do |
|
||
|---|---|---|
|
||
| `0` | success | — |
|
||
| `20000–29999` | PC-library fault (handshake, socket, address) | check the cable/segment and the IP, make sure nothing else holds the connection, then retry. Frequently transient: a first open on a cold route can fail and succeed on the next attempt, which is why an Ethernet connect makes up to 3 attempts before reporting. |
|
||
| `30000+` | the auxiliary block `zauxdll2.h` documents | the message names it (`无应答`, `参数错误`, …) |
|
||
| anything else | the controller's own code | look it up in ZDevelop's table |
|
||
|
||
A **host-library** code is never sent to ZDevelop's table, because that table
|
||
cannot contain it. Observed at the bench: `20008` from `ZAux_OpenEth` on a first,
|
||
transient attempt that succeeded immediately afterwards.
|
||
|
||
## 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, preview_snapshot.py, layout_mockup.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 # 170 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
|
||
```
|
||
|
||
`preview_snapshot.py` is the one tool that can photograph the **3D pane**, which
|
||
`panel_snapshot.py` cannot: the offscreen platform has no OpenGL context at all, so
|
||
the pane is empty in those shots. It needs the real platform plugin and a visible
|
||
window, and it writes either the pane alone or the whole page with the pane
|
||
composited in. What the view draws, and what each colour means, is in
|
||
[docs/finger-preview.md](docs/finger-preview.md).
|
||
|
||
`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 1440×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 PySide6 and no
|
||
other binding, and the controller interface must stay importable headless. The
|
||
studio app enforces the same rules for itself in
|
||
`apps/studio/tests/test_binding_purity.py`.
|
||
|
||
## 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.
|