autocal layout first design

This commit is contained in:
ykcheungab
2026-09-20 21:15:39 +08:00
parent 07d7c3bad6
commit e276b5de0f
92 changed files with 18071 additions and 64 deletions
@@ -0,0 +1,45 @@
"""Typed, side-effect-free client for the ZMotion ZMC controller DLLs.
Only the calls the AutoCal platform control needs are wrapped here, rather than
copying the vendor's 5381-line ``zmcdll/zauxdllPython.py``. The vendor wrapper
is transcribed method by method -- its Chinese docstrings are kept because they
document the parameter contract (the English reference for the same calls would
be the ``ZAUXDLL``/``ZAux_Direct_*`` API documentation).
Differences from the vendor file that matter:
* **No import side effects.** Importing this package does not probe, load, or
``print()`` anything about the native library. The DLL is resolved and loaded
on first :func:`load_dll`, so a machine with no controller still imports the
whole app.
* **No path guessing at import time.** See :mod:`.loader`.
* **Errors are values, not tuples.** Every wrapper returns a
:class:`ZmotionResult` (or :class:`ZmotionValue` for reads), so a caller can
never silently treat a failed read's zero as data.
No Qt binding is imported here, deliberately: this module is usable from a
headless test process and from either binding.
"""
from __future__ import annotations
from .client import (
ZAuxError,
ZmotionClient,
ZmotionDllNotFound,
ZmotionResult,
ZmotionValue,
)
from .loader import ZAUXDLL_DLL, ZMOTION_DLL, load_dll, resolve_dll_dir
__all__ = [
"ZAUXDLL_DLL",
"ZMOTION_DLL",
"ZAuxError",
"ZmotionClient",
"ZmotionDllNotFound",
"ZmotionResult",
"ZmotionValue",
"load_dll",
"resolve_dll_dir",
]
@@ -0,0 +1,468 @@
"""Typed wrappers around the ZMotion ``ZAux_*`` controller API.
Every wrapper returns :class:`ZmotionResult` (commands) or :class:`ZmotionValue`
(reads) instead of the vendor's ``(code, ctypes_value)`` pairs, so a failed read
can never be mistaken for data. The ~25 calls here are exactly the ones the
AutoCal platform control uses; see ``ZAUXDLL2.h``
(``shared/resources/zmotion/x64/``) for the underlying contract.
Loading is lazy: constructing :class:`ZmotionClient` does not touch the native
library, so importing the app on a machine with no controller (or no DLL at all)
is always safe. Call :meth:`ZmotionClient.load` explicitly, or let
:meth:`ZmotionClient.connect` do it.
"""
from __future__ import annotations
import ctypes
from dataclasses import dataclass
from typing import Any # noqa: F401 (kept for ZmotionValue.value's annotation)
from .loader import ZmotionDllNotFound, load_dll, resolve_dll_dir
__all__ = [
"ZAuxError",
"ZmotionClient",
"ZmotionDllNotFound",
"ZmotionResult",
"ZmotionValue",
]
class ZAuxError(RuntimeError):
"""A ``ZAux_*`` call returned a non-zero code.
Carries the numeric code so callers can map it to a localised message
without re-parsing a string.
"""
def __init__(self, code: int, detail: str = "") -> None:
self.code = int(code)
self.detail = detail
message = f"ZMotion error {self.code}"
if detail:
message = f"{message}: {detail}"
super().__init__(message)
@dataclass(frozen=True)
class ZmotionResult:
"""Outcome of a command that returns only a status code."""
code: int
@property
def ok(self) -> bool:
return self.code == 0
def raise_for_status(self, detail: str = "") -> "ZmotionResult":
if not self.ok:
raise ZAuxError(self.code, detail)
return self
@dataclass(frozen=True)
class ZmotionValue:
"""Outcome of a read: a status code plus the value it produced."""
code: int
value: Any
@property
def ok(self) -> bool:
return self.code == 0
@property
def value_or_none(self) -> Any | None:
"""The value when the read succeeded, otherwise ``None``.
Callers that want a default must say so themselves; silently using a
failed read's zero is what makes a wrong number look like a
measurement.
"""
return self.value if self.ok else None
def raise_for_status(self, detail: str = "") -> "ZmotionValue":
if not self.ok:
raise ZAuxError(self.code, detail)
return self
class ZmotionClient:
"""The controller connection, one instance per process.
Not thread-safe: the underlying DLL handle is shared state. AutoCal owns one
client inside a single poller thread (see
``orisys_autocal.motion.poller``) and never calls it from two threads.
"""
def __init__(self) -> None:
self._dll: ctypes.CDLL | None = None
self._handle = ctypes.c_void_p()
self._dll_dir: str | None = None
# ------------------------------------------------------------------ state
@property
def loaded(self) -> bool:
return self._dll is not None
@property
def connected(self) -> bool:
return self._handle.value is not None
@property
def dll_dir(self) -> str | None:
"""Directory the runtime was loaded from, for logs and About boxes."""
return self._dll_dir
def load(self) -> "ZmotionClient":
"""Resolve and load the native runtime. Idempotent."""
if self._dll is None:
resolved = resolve_dll_dir()
self._dll = load_dll(resolved)
self._dll_dir = str(resolved)
return self
def _require_handle(self) -> ctypes.c_void_p:
if self._handle.value is None:
raise ZAuxError(-2, "no controller connection is open")
return self._handle
# ------------------------------------------------------------ connection
def open_eth(self, ip: str) -> ZmotionResult:
"""Connect to a controller by IPv4 address (string form)."""
self.load()
self._handle = ctypes.c_void_p()
ret = self._dll.ZAux_OpenEth( # type: ignore[union-attr]
ip.encode("utf-8"), ctypes.pointer(self._handle)
)
if ret != 0:
self._handle = ctypes.c_void_p()
return ZmotionResult(int(ret))
def open_com(self, com_id: int) -> ZmotionResult:
"""Connect over a serial port by index."""
self.load()
self._handle = ctypes.c_void_p()
ret = self._dll.ZAux_OpenCom( # type: ignore[union-attr]
ctypes.c_int(com_id), ctypes.pointer(self._handle)
)
if ret != 0:
self._handle = ctypes.c_void_p()
return ZmotionResult(int(ret))
def open_pci(self, card_num: int) -> ZmotionResult:
"""Connect to a PCI/PCIe card by card index."""
self.load()
self._handle = ctypes.c_void_p()
ret = self._dll.ZAux_OpenPci( # type: ignore[union-attr]
ctypes.c_int(card_num), ctypes.pointer(self._handle)
)
if ret != 0:
self._handle = ctypes.c_void_p()
return ZmotionResult(int(ret))
def fast_open(self, conn_type: int, connect_string: str, timeout_ms: int) -> ZmotionResult:
"""Open a local/simulated controller (``ZAux_FastOpen``)."""
self.load()
self._handle = ctypes.c_void_p()
ret = self._dll.ZAux_FastOpen( # type: ignore[union-attr]
ctypes.c_int(conn_type),
connect_string.encode("utf-8"),
ctypes.c_uint32(timeout_ms),
ctypes.pointer(self._handle),
)
if ret != 0:
self._handle = ctypes.c_void_p()
return ZmotionResult(int(ret))
def close(self) -> ZmotionResult:
"""Close the connection. Safe to call when nothing is open."""
if self._dll is None or self._handle.value is None:
self._handle = ctypes.c_void_p()
return ZmotionResult(0)
ret = self._dll.ZAux_Close(self._handle) # type: ignore[union-attr]
self._handle = ctypes.c_void_p()
return ZmotionResult(int(ret))
def search_eth(self, buffer_length: int = 10240, timeout_ms: int = 200) -> ZmotionValue:
"""Scan the local network segment for controllers.
Returns the whitespace-separated address list as text. Does not need an
open connection.
"""
self.load()
buffer = (ctypes.c_char * int(buffer_length))()
ret = self._dll.ZAux_SearchEthlist( # type: ignore[union-attr]
buffer, ctypes.c_uint32(buffer_length), ctypes.c_uint32(timeout_ms)
)
text = buffer.value.decode("utf-8", errors="ignore") if buffer.value else ""
return ZmotionValue(int(ret), text.strip())
# ----------------------------------------------------------- BAS / command
def bas_download(self, filename: str, run_mode: int = 1) -> ZmotionResult:
"""Download a ``.bas`` program to the controller ROM.
``run_mode=1`` downloads to ROM, which makes the controller run the
program's task after the configured delay (see ``ECAT.bas``).
"""
ret = self._dll.ZAux_BasDown( # type: ignore[union-attr]
self._require_handle(), filename.encode("utf-8"), ctypes.c_uint32(run_mode)
)
return ZmotionResult(int(ret))
def execute(self, command: str) -> ZmotionValue:
"""Run an online command string (e.g. ``RUN "ECAT.bas",4``)."""
ret = self._dll.ZAux_Execute( # type: ignore[union-attr]
self._require_handle(),
command.encode("utf-8"),
None,
ctypes.c_uint32(0),
)
return ZmotionValue(int(ret), command)
def get_user_var(self, name: str) -> ZmotionValue:
"""Read a global variable from the running ``.bas`` program.
The vendor wrapper returns a ``c_float`` here while other reads return a
plain float; both are normalised to a Python float.
"""
value = ctypes.c_float()
ret = self._dll.ZAux_Direct_GetUserVar( # type: ignore[union-attr]
self._require_handle(), name.encode("utf-8"), ctypes.byref(value)
)
return ZmotionValue(int(ret), float(value.value))
# --------------------------------------------------------------- bus state
def bus_node_count(self, slot: int = 0) -> ZmotionValue:
"""Number of slave nodes the controller found on the bus."""
value = ctypes.c_uint32()
ret = self._dll.ZAux_BusCmd_GetNodeNum( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(slot), ctypes.byref(value)
)
return ZmotionValue(int(ret), int(value.value))
def bus_home_status(self, axis: int) -> ZmotionValue:
"""Driver homing status: 1 once homing has completed."""
value = ctypes.c_int()
ret = self._dll.ZAux_BusCmd_GetHomeStatus( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
)
return ZmotionValue(int(ret), int(value.value))
def bus_datum(self, axis: int, home_mode: int) -> ZmotionResult:
"""Start driver homing with a CiA-402 homing method."""
ret = self._dll.ZAux_BusCmd_Datum( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.c_int(home_mode)
)
return ZmotionResult(int(ret))
def bus_set_datum_offset(self, axis: int, value: float) -> ZmotionResult:
"""Set the homing offset (machine units)."""
ret = self._dll.ZAux_BusCmd_SetDatumOffpos( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value)
)
return ZmotionResult(int(ret))
def bus_drive_clear(self, axis: int, mode: int = 0) -> ZmotionResult:
"""Clear a drive fault/alert."""
ret = self._dll.ZAux_BusCmd_DriveClear( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.c_int(mode)
)
return ZmotionResult(int(ret))
def sdo_read(
self, slot: int, node: int, index: int, subindex: int, data_type: int
) -> ZmotionValue:
"""Read an EtherCAT object-dictionary entry.
``data_type``: 1=int8, 2=int16, 3=int32, 4=uint8, 5=uint16, 6=uint32.
"""
value = ctypes.c_int()
ret = self._dll.ZAux_BusCmd_SDORead( # type: ignore[union-attr]
self._require_handle(),
ctypes.c_int(slot),
ctypes.c_int(node),
ctypes.c_int(index),
ctypes.c_int(subindex),
ctypes.c_int(data_type),
ctypes.byref(value),
)
return ZmotionValue(int(ret), int(value.value))
def sdo_write(
self,
slot: int,
node: int,
index: int,
subindex: int,
data_type: int,
value: int,
) -> ZmotionResult:
"""Write an EtherCAT object-dictionary entry. See :meth:`sdo_read`."""
ret = self._dll.ZAux_BusCmd_SDOWrite( # type: ignore[union-attr]
self._require_handle(),
ctypes.c_int(slot),
ctypes.c_int(node),
ctypes.c_int(index),
ctypes.c_int(subindex),
ctypes.c_int(data_type),
ctypes.c_int(value),
)
return ZmotionResult(int(ret))
def rtex_read(self, axis: int, parameter: int) -> ZmotionValue:
"""Read an RTEX drive parameter."""
value = ctypes.c_float()
ret = self._dll.ZAux_BusCmd_RtexRead( # type: ignore[union-attr]
self._require_handle(),
ctypes.c_int(axis),
ctypes.c_int(parameter),
ctypes.byref(value),
)
return ZmotionValue(int(ret), float(value.value))
def rtex_write(self, axis: int, parameter: int, value: float) -> ZmotionResult:
"""Write an RTEX drive parameter."""
ret = self._dll.ZAux_BusCmd_RtexWrite( # type: ignore[union-attr]
self._require_handle(),
ctypes.c_int(axis),
ctypes.c_int(parameter),
ctypes.c_float(value),
)
return ZmotionResult(int(ret))
# ------------------------------------------------------------------- axis
def set_axis_enable(self, axis: int, value: int) -> ZmotionResult:
"""Servo enable (1) / disable (0) for one axis."""
ret = self._dll.ZAux_Direct_SetAxisEnable( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.c_int(value)
)
return ZmotionResult(int(ret))
def get_axis_enable(self, axis: int) -> ZmotionValue:
value = ctypes.c_int()
ret = self._dll.ZAux_Direct_GetAxisEnable( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
)
return ZmotionValue(int(ret), int(value.value))
def set_units(self, axis: int, value: float) -> ZmotionResult:
"""Pulse equivalent (user units per pulse)."""
ret = self._dll.ZAux_Direct_SetUnits( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value)
)
return ZmotionResult(int(ret))
def get_units(self, axis: int) -> ZmotionValue:
value = ctypes.c_float()
ret = self._dll.ZAux_Direct_GetUnits( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
)
return ZmotionValue(int(ret), float(value.value))
def set_speed(self, axis: int, value: float) -> ZmotionResult:
ret = self._dll.ZAux_Direct_SetSpeed( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value)
)
return ZmotionResult(int(ret))
def get_speed(self, axis: int) -> ZmotionValue:
value = ctypes.c_float()
ret = self._dll.ZAux_Direct_GetSpeed( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
)
return ZmotionValue(int(ret), float(value.value))
def set_accel(self, axis: int, value: float) -> ZmotionResult:
ret = self._dll.ZAux_Direct_SetAccel( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value)
)
return ZmotionResult(int(ret))
def get_accel(self, axis: int) -> ZmotionValue:
value = ctypes.c_float()
ret = self._dll.ZAux_Direct_GetAccel( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
)
return ZmotionValue(int(ret), float(value.value))
def set_creep(self, axis: int, value: float) -> ZmotionResult:
"""Creep (slow homing) speed."""
ret = self._dll.ZAux_Direct_SetCreep( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value)
)
return ZmotionResult(int(ret))
def get_atype(self, axis: int) -> ZmotionValue:
"""Axis type (65=position, 66=speed, 67=torque)."""
value = ctypes.c_int()
ret = self._dll.ZAux_Direct_GetAtype( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
)
return ZmotionValue(int(ret), int(value.value))
def get_dpos(self, axis: int) -> ZmotionValue:
"""Commanded position, in user units."""
value = ctypes.c_float()
ret = self._dll.ZAux_Direct_GetDpos( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
)
return ZmotionValue(int(ret), float(value.value))
def get_mpos(self, axis: int) -> ZmotionValue:
"""Measured (feedback) position, in user units."""
value = ctypes.c_float()
ret = self._dll.ZAux_Direct_GetMpos( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
)
return ZmotionValue(int(ret), float(value.value))
def get_axis_status(self, axis: int) -> ZmotionValue:
"""Axis status word. Bit 6 is the homing-in-progress bit."""
value = ctypes.c_int()
ret = self._dll.ZAux_Direct_GetAxisStatus( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
)
return ZmotionValue(int(ret), int(value.value))
def get_if_idle(self, axis: int) -> ZmotionValue:
"""0 while the axis is moving, -1 when it has stopped."""
value = ctypes.c_int()
ret = self._dll.ZAux_Direct_GetIfIdle( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
)
return ZmotionValue(int(ret), int(value.value))
def single_vmove(self, axis: int, direction: int) -> ZmotionResult:
"""Continuous jog: ``direction`` 1 for forward, -1 for reverse."""
ret = self._dll.ZAux_Direct_Single_Vmove( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.c_int(direction)
)
return ZmotionResult(int(ret))
def single_move(self, axis: int, distance: float) -> ZmotionResult:
"""Move by a distance relative to the current position."""
ret = self._dll.ZAux_Direct_Single_Move( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.c_float(distance)
)
return ZmotionResult(int(ret))
def single_move_abs(self, axis: int, position: float) -> ZmotionResult:
"""Move to an absolute position."""
ret = self._dll.ZAux_Direct_Single_MoveAbs( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.c_float(position)
)
return ZmotionResult(int(ret))
def single_cancel(self, axis: int, mode: int = 2) -> ZmotionResult:
"""Stop the axis. ``mode=2`` is a decelerating stop."""
ret = self._dll.ZAux_Direct_Single_Cancel( # type: ignore[union-attr]
self._require_handle(), ctypes.c_int(axis), ctypes.c_int(mode)
)
return ZmotionResult(int(ret))
@@ -0,0 +1,143 @@
"""Resolve and load the vendored ZMotion runtime DLLs.
Resolution order:
1. ``ORISYS_ZMOTION_DLL_DIR`` — an explicit override, e.g. a newer
controller-support CD.
2. ``<exe dir>/_internal/zmotion/x64`` and ``<exe dir>/zmotion/x64`` — a frozen
(PyInstaller) bundle, matching how ``build/studio`` places branding.
3. ``shared/resources/zmotion/x64`` in the source tree — the vendored copy
(see ``shared/resources/zmotion/x64/README.md`` for provenance).
Nothing here runs at import time; :func:`load_dll` is called by the app once a
connection is actually requested.
"""
from __future__ import annotations
import ctypes
import os
import sys
from pathlib import Path
ZAUXDLL_DLL = "zauxdll.dll"
ZMOTION_DLL = "zmotion.dll"
#: Environment variable that overrides the vendored DLL directory.
DLL_DIR_ENV = "ORISYS_ZMOTION_DLL_DIR"
class ZmotionDllNotFound(OSError):
"""The ZMotion runtime could not be located.
Carries every path that was searched, so the message can tell an operator
what to install or which environment variable to set instead of just
naming the missing file.
"""
def __init__(self, name: str, searched: list[Path]) -> None:
self.name = name
self.searched = searched
listing = "\n".join(f" - {path}" for path in searched)
super().__init__(
f"{name} not found. Searched:\n{listing}\n"
f"Install the ZMotion runtime, or set {DLL_DIR_ENV} to the folder "
f"that contains it."
)
def _source_tree_dir() -> Path:
# .../orisys-app/shared/src/orisys_app_shared/zmotion/loader.py -> shared/
return Path(__file__).resolve().parents[3] / "resources" / "zmotion" / "x64"
def _frozen_dirs() -> list[Path]:
if not getattr(sys, "frozen", False):
return []
exe_dir = Path(sys.executable).resolve().parent
return [exe_dir / "_internal" / "zmotion" / "x64", exe_dir / "zmotion" / "x64"]
def candidate_dll_dirs() -> list[Path]:
"""Every directory that will be searched for the runtime DLLs, in order.
An explicit ``ORISYS_ZMOTION_DLL_DIR`` is authoritative: it is the only
candidate, so a typo fails loudly instead of quietly loading a different
build of the runtime than the operator asked for. On a motion controller,
silently using the wrong DLL version is exactly the kind of failure that
looks like a working machine until it moves.
"""
override = os.environ.get(DLL_DIR_ENV, "").strip()
if override:
return [Path(override).expanduser()]
candidates: list[Path] = []
candidates.extend(_frozen_dirs())
candidates.append(_source_tree_dir())
seen: set[Path] = set()
unique: list[Path] = []
for path in candidates:
resolved = path.resolve() if path.exists() else path
if resolved not in seen:
seen.add(resolved)
unique.append(resolved)
return unique
def resolve_dll_dir() -> Path:
"""Return the first candidate directory that holds ``zauxdll.dll``.
Raises:
ZmotionDllNotFound: no candidate directory contains the runtime.
"""
searched = candidate_dll_dirs()
for path in searched:
if (path / ZAUXDLL_DLL).is_file():
return path
raise ZmotionDllNotFound(ZAUXDLL_DLL, searched)
def _prepare_search_path(dll_dir: Path) -> None:
"""Make ``dll_dir`` available to the loader for dependent DLLs.
``zauxdll.dll`` links against ``zmotion.dll`` beside it, so the directory
must be on the search path *before* ``zauxdll.dll`` is loaded. Both the
process ``PATH`` and the loader's own DLL directory are set: the former is
what the vendor wrapper relied on, the latter is the documented Win32
mechanism.
"""
dll_dir_str = str(dll_dir)
path_entries = os.environ.get("PATH", "").split(os.pathsep)
if dll_dir_str not in path_entries:
os.environ["PATH"] = dll_dir_str + os.pathsep + os.environ.get("PATH", "")
if sys.platform == "win32":
try:
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.SetDllDirectoryW(ctypes.c_wchar_p(dll_dir_str))
except (OSError, AttributeError): # pragma: no cover - defensive
# Not fatal: the PATH entry above is usually enough, and the real
# failure (if any) surfaces as a clearer error from load_dll.
pass
def load_dll(dll_dir: Path | None = None) -> ctypes.CDLL:
"""Load ``zauxdll.dll`` with its directory on the search path.
Args:
dll_dir: directory to load from; resolved automatically when omitted.
Returns:
The loaded CDLL.
Raises:
ZmotionDllNotFound: the runtime is not present.
OSError: the DLL is present but the platform loader rejected it.
"""
resolved = dll_dir or resolve_dll_dir()
dll_path = resolved / ZAUXDLL_DLL
if not dll_path.is_file():
raise ZmotionDllNotFound(ZAUXDLL_DLL, [resolved])
_prepare_search_path(resolved)
return ctypes.CDLL(str(dll_path))