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,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"
+126
View File
@@ -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])