# 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. ## Capture workflow [Offline force-feedback scaffold](docs/force-control-scaffold.zh.md). The 标定采集 page is one queue, not two panels — a single point is a queue with one row. `Ready` lists the six facts a run needs and the **primary button follows the step**, always stating why it is disabled. Alignment is only a request: the move goes through the guarded pose-move path, and only a completed move unlocks capture. Recording options (video, codec, lossless frames) are chosen before the session and written once into `session.json`; an unavailable codec is disabled with its reason, never silently substituted. [docs/workflow.md](docs/workflow.md) has the detail. ## Run it PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no licence purchase (PyQt5 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/`. `ORISYS_ZMOTION_DLL_DIR` overrides it, and the override is authoritative: a directory without `zauxdll.dll` makes the app refuse to connect rather than quietly load another build. 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. 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 ` then `RUN "",`. 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, then retry; a first open on a cold route is often transient, which is why an Ethernet connect makes up to 3 attempts. | | `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. - **A pose move is refused before the first axis moves** if any part of it is wrong: a target past the P0 limit — millimetres for x/y/z, **degrees** for yaw/pitch, so a 400° stage turn cannot pass a 100 mm check — an axis mapping that is incomplete, points outside the drive range, or claims one drive twice, or a rotary role on an axis with no user-units-per-degree calibration. A sequence that stopped halfway would leave the machine in a pose nobody simulated, so every check runs first. - **A move is stepped, not looped.** The drives are commanded one at a time from the poll snapshots; a step that stalls (idle but short of its target), times out or raises an alarm aborts the rest. That is also what makes **stop** work — it is an ordinary command, so it runs at the next poll tick, and the queue is cancelled before it is sent so no later axis is commanded. - **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/ ``` `capture_controller.py` is the capture page's Qt-free step machine (queue, `Ready`, one primary action, remeasure/skip/stop, poll); `recording_options.py` turns the video choice into a `RecordConfig` and probes which codecs this machine can write. `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 # 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\panel_snapshot.py --setup-dialog bus --out setup.png python .\apps\autocal\tools\layout_mockup.py --out layout.png # proposed layout, drawn python -m pytest apps\autocal\tests # offscreen suite ``` Connection and bus initialisation are used once per machine start-up, so they live in a **modeless device-setup dialog** (`ui/device_setup.py`), opened from the status strip, its chips, or 文件 → 设备设置. The rail carries what is read while working: the axis readout and the collapsed SDO/RTEX monitor. `preview_snapshot.py` is the only tool that can photograph the **3D pane** (the offscreen platform has no OpenGL context, so the pane is empty there). It needs the real platform plugin and a visible window. See [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 without building the panels. `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, `shared/` may import PySide6 and no other binding, 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.