autocal layout first design
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
"""Orisys AutoCal — internal 5-axis calibration platform control app."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
REQUIRES_SHARED_API = "0.1"
|
||||
|
||||
#: Window / QApplication name.
|
||||
APP_DISPLAY_NAME = "Orisys AutoCal"
|
||||
|
||||
#: The Qt binding this app uses. It is PySide6 and only PySide6: LGPLv3, so an
|
||||
#: internal closed-source tool needs no licence purchase, and it is the
|
||||
#: maintained Qt-for-Python binding (PySide2 is discontinued upstream).
|
||||
QT_BINDING = "PySide6"
|
||||
@@ -0,0 +1,126 @@
|
||||
"""AutoCal entrypoint: logging, locale, theme, then the window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# The app id must be set before anything imports orisys_app_shared.paths, because
|
||||
# the config/output roots are resolved on first use and cached by callers.
|
||||
os.environ.setdefault("ORISYS_APP_ID", "autocal")
|
||||
|
||||
|
||||
def _force_utf8_streams() -> None:
|
||||
"""Make stdout/stderr UTF-8 where the platform lets us.
|
||||
|
||||
The controller's error strings and every panel label are Chinese, and a
|
||||
Windows console defaults to a legacy code page that cannot encode them.
|
||||
A failed reconfigure is not fatal — the log file is UTF-8 regardless.
|
||||
"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if reconfigure is None:
|
||||
continue
|
||||
try:
|
||||
reconfigure(encoding="utf-8", errors="replace")
|
||||
except (ValueError, OSError): # pragma: no cover - stream already detached
|
||||
pass
|
||||
|
||||
|
||||
def _pyside_version() -> str:
|
||||
import PySide6
|
||||
|
||||
return getattr(PySide6, "__version__", "unknown")
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(prog="orisys-autocal", description=__doc__)
|
||||
parser.add_argument(
|
||||
"--demo",
|
||||
action="store_true",
|
||||
help="start on the simulated platform (no hardware, no controller DLL)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
default="INFO",
|
||||
choices=("DEBUG", "INFO", "WARNING", "ERROR"),
|
||||
help="console and file log level",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
|
||||
_force_utf8_streams()
|
||||
|
||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||
from orisys_app_shared.compat import check_shared_compat
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.logging import init_logging, install_exception_hooks
|
||||
from orisys_app_shared.theme import build_qss
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from . import REQUIRES_SHARED_API, __version__ as app_version
|
||||
from .motion import ConnectionSpec, DemoTransport
|
||||
from .preferences import load_preferences
|
||||
from .ui.window import AutoCalMainWindow
|
||||
|
||||
init_logging("autocal", level=getattr(logging, args.log_level))
|
||||
install_exception_hooks()
|
||||
check_shared_compat(REQUIRES_SHARED_API)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
prefs = load_preferences()
|
||||
language = str(prefs.get("language") or "zh_CN")
|
||||
theme = str(prefs.get("theme") or "dark")
|
||||
set_locale(language)
|
||||
|
||||
app = QApplication(sys.argv[:1])
|
||||
app.setApplicationName("Orisys AutoCal")
|
||||
app.setApplicationVersion(app_version)
|
||||
app.setStyleSheet(build_qss(theme))
|
||||
|
||||
transport = None
|
||||
if args.demo:
|
||||
transport = DemoTransport()
|
||||
connection = dict(prefs.get("connection") or {})
|
||||
connection["mode"] = "demo"
|
||||
prefs["connection"] = connection
|
||||
|
||||
window = AutoCalMainWindow(prefs=prefs)
|
||||
if transport is not None:
|
||||
window.poller.service._transport = transport # noqa: SLF001 - deliberate swap
|
||||
window.show()
|
||||
|
||||
logger.info(
|
||||
"AutoCal started (version=%s shared=%s api=%s binding=PySide6 %s "
|
||||
"theme=%s language=%s demo=%s)",
|
||||
app_version,
|
||||
shared_version,
|
||||
API_VERSION,
|
||||
_pyside_version(),
|
||||
theme,
|
||||
language,
|
||||
args.demo,
|
||||
)
|
||||
if args.demo:
|
||||
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
|
||||
|
||||
code = int(app.exec())
|
||||
|
||||
# Leave the process immediately. The ZMotion runtime keeps native threads of
|
||||
# its own, so a normal interpreter shutdown can hang after the window closes;
|
||||
# everything durable was already flushed by closeEvent and the log handlers.
|
||||
logging.shutdown()
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.flush()
|
||||
except Exception: # noqa: BLE001 - a closed stream must not block exit
|
||||
pass
|
||||
os._exit(code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Platform control: models, guards, polling, and the Qt worker bridge."""
|
||||
|
||||
from .models import (
|
||||
INIT_FAILED,
|
||||
INIT_OK,
|
||||
INIT_RUNNING,
|
||||
INIT_TIMEOUT_S,
|
||||
AxisParams,
|
||||
AxisState,
|
||||
AxisStatusBits,
|
||||
BusStatus,
|
||||
BusType,
|
||||
ConnectionMode,
|
||||
ConnectionSpec,
|
||||
MotionResult,
|
||||
Snapshot,
|
||||
)
|
||||
from .poller import MotionPoller
|
||||
from .service import PLATFORM_AXES, MotionService
|
||||
from .transport import DemoTransport, NativeTransport, ZmcTransport
|
||||
from .units import (
|
||||
DEFAULT_ACCEL,
|
||||
DEFAULT_SPEED,
|
||||
DEFAULT_UNITS,
|
||||
PULSES_PER_MM,
|
||||
mm_to_user_units,
|
||||
safe_float,
|
||||
user_units_to_mm,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_ACCEL",
|
||||
"DEFAULT_SPEED",
|
||||
"DEFAULT_UNITS",
|
||||
"INIT_FAILED",
|
||||
"INIT_OK",
|
||||
"INIT_RUNNING",
|
||||
"INIT_TIMEOUT_S",
|
||||
"PLATFORM_AXES",
|
||||
"PULSES_PER_MM",
|
||||
"AxisParams",
|
||||
"AxisState",
|
||||
"AxisStatusBits",
|
||||
"BusStatus",
|
||||
"BusType",
|
||||
"ConnectionMode",
|
||||
"ConnectionSpec",
|
||||
"DemoTransport",
|
||||
"MotionPoller",
|
||||
"MotionResult",
|
||||
"MotionService",
|
||||
"NativeTransport",
|
||||
"Snapshot",
|
||||
"ZmcTransport",
|
||||
"mm_to_user_units",
|
||||
"safe_float",
|
||||
"user_units_to_mm",
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Translate controller return codes into something an operator can act on.
|
||||
|
||||
Only the codes the vendor actually documents are given a meaning here. The
|
||||
`sdk header <../../resources/zmotion/x64/zauxdll2.h>`_ defines the auxiliary
|
||||
error block at offset 30000:
|
||||
|
||||
#define ERR_NOACK ERR_AUX_OFFSET //无应答
|
||||
#define ERR_ACKERROR (ERR_AUX_OFFSET+1) //应答错误
|
||||
#define ERR_AUX_PARAERR (ERR_AUX_OFFSET+2) //参数错误
|
||||
#define ERR_AUX_NOTSUPPORT (ERR_AUX_OFFSET+3) //不支持
|
||||
#define ERR_AUX_FILE_ERROR (ERR_AUX_OFFSET+4) //参数错误
|
||||
#define ERR_AUX_OS_ERR (ERR_AUX_OFFSET+5) //
|
||||
|
||||
Everything else is the controller's own number. :func:`describe` deliberately
|
||||
does **not** invent a meaning for those: it names the number and points at
|
||||
ZDevelop, which is where the firmware's code table lives. A guessed message on
|
||||
a motion controller is worse than "unknown".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Auxiliary (host-side) error block offset, from ``zauxdll2.h``.
|
||||
ERR_AUX_OFFSET = 30000
|
||||
|
||||
#: Meaning per code, as ``(english, chinese)``.
|
||||
MESSAGES: dict[int, tuple[str, str]] = {
|
||||
ERR_AUX_OFFSET: ("No response from the controller", "控制器无应答"),
|
||||
ERR_AUX_OFFSET + 1: ("The controller rejected the command", "应答错误"),
|
||||
ERR_AUX_OFFSET + 2: ("Wrong parameter", "参数错误"),
|
||||
ERR_AUX_OFFSET + 3: ("Not supported by this model or firmware", "不支持该功能"),
|
||||
ERR_AUX_OFFSET + 4: ("File error", "文件错误"),
|
||||
ERR_AUX_OFFSET + 5: ("Operating-system error in the library", "库内操作系统错误"),
|
||||
}
|
||||
|
||||
#: The last-resort advice appended to every unknown code.
|
||||
UNKNOWN_HINT = (
|
||||
"Look this number up in ZDevelop's error table (控制器错误码表), "
|
||||
"or run the same command from ZDevelop to see the decoded fault."
|
||||
)
|
||||
|
||||
|
||||
def describe(code: int, *, language: str = "zh_CN") -> str:
|
||||
"""A human-readable message for a controller return code.
|
||||
|
||||
Args:
|
||||
code: the value returned by a ``ZAux_*`` call.
|
||||
language: ``zh_CN`` for Chinese, anything else for English.
|
||||
|
||||
Returns:
|
||||
``"0"`` is reported as success; known codes get a translated message;
|
||||
unknown codes keep their number visible rather than being guessed at.
|
||||
"""
|
||||
code = int(code)
|
||||
if code == 0:
|
||||
return "成功" if language == "zh_CN" else "OK"
|
||||
|
||||
index = 1 if language == "zh_CN" else 0
|
||||
known = MESSAGES.get(code)
|
||||
if known is None:
|
||||
# An unknown code that still resolves to an auxiliary slot is more
|
||||
# useful than a bare integer, but only when it is documented.
|
||||
logger.debug("Undocumented ZMotion return code %s", code)
|
||||
prefix = "错误码" if language == "zh_CN" else "Error code"
|
||||
return f"{prefix} {code}"
|
||||
return known[index]
|
||||
|
||||
|
||||
def describe_with_hint(code: int, *, language: str = "zh_CN") -> str:
|
||||
"""As :func:`describe`, plus the ZDevelop pointer for unknown codes."""
|
||||
message = describe(code, language=language)
|
||||
if int(code) == 0 or int(code) in MESSAGES:
|
||||
return message
|
||||
return f"{message} — {UNKNOWN_HINT}"
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Motion-control domain model.
|
||||
|
||||
Plain frozen dataclasses and no Qt: the service layer and the tests both use
|
||||
these, and neither needs a GUI to do it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
#: Which physical transport the controller is reached over.
|
||||
ConnectionMode = Literal["eth", "com", "pci", "local", "demo"]
|
||||
|
||||
#: Bus families the ``.bas`` programs report through ``BUS_TYPE``.
|
||||
BusType = Literal["unknown", "ecat", "rtex"]
|
||||
|
||||
#: The vendor reference's window between "BAS downloaded" and a final status.
|
||||
INIT_TIMEOUT_S = 25.0
|
||||
|
||||
#: ``Bus_InitStatus`` values, from ``ECAT.bas``.
|
||||
INIT_RUNNING = -1
|
||||
INIT_FAILED = 0
|
||||
INIT_OK = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConnectionSpec:
|
||||
"""How to reach the controller."""
|
||||
|
||||
mode: ConnectionMode
|
||||
ip: str = ""
|
||||
com_id: int = 0
|
||||
card_num: int = 0
|
||||
local_string: str = "0"
|
||||
|
||||
def describe(self) -> str:
|
||||
if self.mode == "eth":
|
||||
return self.ip or "(no IP)"
|
||||
if self.mode == "com":
|
||||
return f"COM{self.com_id}"
|
||||
if self.mode == "pci":
|
||||
return f"PCI card {self.card_num}"
|
||||
if self.mode == "local":
|
||||
return f"local {self.local_string}"
|
||||
return "demo (no hardware)"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AxisParams:
|
||||
"""Per-axis parameters as entered by the operator."""
|
||||
|
||||
units: float = 10.0
|
||||
speed: float = 1000.0
|
||||
accel: float = 10000.0
|
||||
creep: float = 10.0
|
||||
home_mode: int = 0
|
||||
home_high: float = 50.0
|
||||
home_low: float = 10.0
|
||||
home_offset: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AxisStatusBits:
|
||||
"""Decoded ``ZAux_Direct_GetAxisStatus`` word.
|
||||
|
||||
Only the bits the AutoCal panels display are named. Bit 6 is the one the
|
||||
vendor reference tests (``status & 64``) to decide that homing is running.
|
||||
"""
|
||||
|
||||
raw: int = 0
|
||||
|
||||
@property
|
||||
def home_in_progress(self) -> bool:
|
||||
"""Bit 6 — set while the drive is executing a homing move."""
|
||||
return bool(self.raw & 64)
|
||||
|
||||
@property
|
||||
def moving(self) -> bool:
|
||||
"""Bit 1 — the axis is producing motion."""
|
||||
return bool(self.raw & 2)
|
||||
|
||||
@property
|
||||
def alarm(self) -> bool:
|
||||
"""Bit 9 — the drive is reporting a fault."""
|
||||
return bool(self.raw & 512)
|
||||
|
||||
def active_bits(self) -> list[int]:
|
||||
"""Every set bit position, for the status column's tooltip."""
|
||||
return [bit for bit in range(32) if self.raw & (1 << bit)]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AxisState:
|
||||
"""One axis as read back from the controller."""
|
||||
|
||||
axis: int
|
||||
enabled: bool = False
|
||||
atype: int = 0
|
||||
units: float = 0.0
|
||||
speed: float = 0.0
|
||||
accel: float = 0.0
|
||||
dpos: float = 0.0
|
||||
mpos: float = 0.0
|
||||
status: AxisStatusBits = field(default_factory=AxisStatusBits)
|
||||
idle: bool = True
|
||||
home_status: int = 0
|
||||
read_ok: bool = True
|
||||
error: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BusStatus:
|
||||
"""Bus-level state, from the ``.bas`` program's globals."""
|
||||
|
||||
bus_type: BusType = "unknown"
|
||||
init_status: int = INIT_RUNNING
|
||||
node_count: int = 0
|
||||
total_axes: int = 0
|
||||
bas_path: str = ""
|
||||
bas_loaded: bool = False
|
||||
init_started_at: float | None = None
|
||||
message: str = ""
|
||||
|
||||
@property
|
||||
def initialized(self) -> bool:
|
||||
return self.init_status == INIT_OK
|
||||
|
||||
@property
|
||||
def supports_sdo(self) -> bool:
|
||||
return self.bus_type == "ecat"
|
||||
|
||||
@property
|
||||
def supports_rtex(self) -> bool:
|
||||
return self.bus_type == "rtex"
|
||||
|
||||
def timed_out(self, now: float) -> bool:
|
||||
"""True while a BAS run started long ago has still not reported."""
|
||||
if self.init_status != INIT_RUNNING or self.init_started_at is None:
|
||||
return False
|
||||
return (now - self.init_started_at) > INIT_TIMEOUT_S
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Snapshot:
|
||||
"""Everything the UI needs for one refresh."""
|
||||
|
||||
connected: bool = False
|
||||
spec: ConnectionSpec | None = None
|
||||
bus: BusStatus = field(default_factory=BusStatus)
|
||||
axes: tuple[AxisState, ...] = ()
|
||||
error: str = ""
|
||||
|
||||
def axis(self, index: int) -> AxisState | None:
|
||||
for state in self.axes:
|
||||
if state.axis == index:
|
||||
return state
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MotionResult:
|
||||
"""Outcome of a command, with a reason when it was refused or failed."""
|
||||
|
||||
ok: bool
|
||||
code: int = 0
|
||||
message: str = ""
|
||||
|
||||
@classmethod
|
||||
def success(cls, message: str = "") -> "MotionResult":
|
||||
return cls(ok=True, code=0, message=message)
|
||||
|
||||
@classmethod
|
||||
def refused(cls, message: str) -> "MotionResult":
|
||||
"""The command was not sent: a guard rejected it before any motion."""
|
||||
return cls(ok=False, code=0, message=message)
|
||||
|
||||
@classmethod
|
||||
def failed(cls, code: int, message: str) -> "MotionResult":
|
||||
return cls(ok=False, code=int(code), message=message)
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Owns the controller connection on a worker thread.
|
||||
|
||||
Why a thread and not a ``QTimer`` in the GUI thread: ``ZAux_BasDown`` (a ROM
|
||||
write) and a bus scan block for seconds, and the bus-init sequence blocks for
|
||||
three; a synchronous call on the GUI thread freezes the window for that whole
|
||||
time. The Qt signal boundary here is the *only* way the UI reaches the
|
||||
controller, which also means there is exactly one thread making DLL calls —
|
||||
the ZMotion handle is not safe to share.
|
||||
|
||||
Contract:
|
||||
|
||||
* Creating the poller touches no hardware and loads no DLL.
|
||||
* :meth:`MotionPoller.submit` queues a service call; the result comes back on
|
||||
``commandFinished``/``commandFailed``, and commands run in submission order.
|
||||
* Only one service call is in flight at once, because the worker is a single
|
||||
thread and each due step performs one control action.
|
||||
* A call that raises is reported, not fatal: the timer keeps running and the
|
||||
next snapshot still arrives.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from PySide6.QtCore import (
|
||||
QMetaObject,
|
||||
QObject,
|
||||
Qt,
|
||||
QThread,
|
||||
QTimer,
|
||||
Signal,
|
||||
Slot,
|
||||
)
|
||||
|
||||
from .models import Snapshot
|
||||
from .service import PLATFORM_AXES, MotionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Default UI refresh interval, matching the reference program's 100 ms timer.
|
||||
DEFAULT_INTERVAL_MS = 100
|
||||
|
||||
|
||||
class _Worker(QObject):
|
||||
"""Lives on the poller thread. Every controller call happens here."""
|
||||
|
||||
snapshotReady = Signal(object)
|
||||
commandFinished = Signal(int, object)
|
||||
commandFailed = Signal(int, str)
|
||||
connected = Signal(object)
|
||||
disconnected = Signal()
|
||||
|
||||
def __init__(self, service: MotionService, axes: Iterable[int] | None) -> None:
|
||||
super().__init__()
|
||||
self._service = service
|
||||
self._axes = list(axes) if axes is not None else None
|
||||
# Both are created in ``warm_up``, which runs on the worker thread: a
|
||||
# QTimer must be created, started and stopped from the thread that owns
|
||||
# it, and this object is constructed on the GUI thread.
|
||||
self._timer: QTimer | None = None
|
||||
self._timer_loop: QTimer | None = None
|
||||
self._interval_ms = DEFAULT_INTERVAL_MS
|
||||
self._pending: list[tuple[int, Callable[[], Any]]] = []
|
||||
self._counter = 0
|
||||
self._last_snapshot_at = 0.0
|
||||
|
||||
# ------------------------------------------------------------ thread setup
|
||||
|
||||
@Slot()
|
||||
def warm_up(self) -> None:
|
||||
"""Create the timers and start polling.
|
||||
|
||||
Invoked on the worker thread, once, at thread start-up — and taking no
|
||||
arguments precisely so it can be, because marshalling an argument across
|
||||
the meta-object system differs between the Qt bindings.
|
||||
"""
|
||||
if self._timer is None:
|
||||
self._timer = QTimer(self)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
|
||||
# A zero-interval timer that always stays active. Its only job is to
|
||||
# keep the thread's event loop turning (``QThread.exec`` returns as
|
||||
# soon as no events are left, even though the thread stays alive), so
|
||||
# a queued ``submit`` is delivered even while polling is stopped.
|
||||
self._timer_loop = QTimer(self)
|
||||
self._timer_loop.timeout.connect(lambda: None)
|
||||
self._timer_loop.start(0)
|
||||
|
||||
self._timer.setInterval(self._interval_ms)
|
||||
if not self._timer.isActive():
|
||||
self._timer.start()
|
||||
|
||||
def start_polling(self, interval_ms: int = DEFAULT_INTERVAL_MS) -> None:
|
||||
"""Start (or retune) the poll timer. Must run on the worker thread."""
|
||||
self._interval_ms = max(20, int(interval_ms))
|
||||
if self._timer is None:
|
||||
self.warm_up()
|
||||
assert self._timer is not None
|
||||
self._timer.setInterval(self._interval_ms)
|
||||
if not self._timer.isActive():
|
||||
self._timer.start()
|
||||
|
||||
@Slot()
|
||||
def stop_polling(self) -> None:
|
||||
if self._timer is not None:
|
||||
self._timer.stop()
|
||||
if self._timer_loop is not None:
|
||||
self._timer_loop.stop()
|
||||
|
||||
@Slot()
|
||||
def cleanup(self) -> None:
|
||||
"""Destroy the timers on the thread that owns them.
|
||||
|
||||
A QTimer stopped or deleted from another thread warns ("Timers cannot be
|
||||
stopped from another thread") and leaves the timestamp bookkeeping in an
|
||||
undefined state. Since these are parented to the worker, they would
|
||||
otherwise be destroyed with it, from whichever thread drops the last
|
||||
reference to the worker.
|
||||
"""
|
||||
for timer in (self._timer, self._timer_loop):
|
||||
if timer is not None:
|
||||
timer.stop()
|
||||
timer.deleteLater()
|
||||
self._timer = None
|
||||
self._timer_loop = None
|
||||
|
||||
def set_axes(self, axes: Iterable[int]) -> None:
|
||||
self._axes = list(axes)
|
||||
|
||||
def set_language(self, language: str) -> None:
|
||||
self._service.set_language(language)
|
||||
|
||||
# ------------------------------------------------------------------ queue
|
||||
|
||||
def submit(self, action: Callable[[MotionService], Any]) -> int:
|
||||
"""Queue ``action``; returns the id its result will be reported under.
|
||||
|
||||
Queuing is thread-safe (list append under the GIL); the *work* is not
|
||||
done here. ``invokeMethod`` with a queued connection is what runs
|
||||
:meth:`_tick` on the worker thread, so no controller call ever happens
|
||||
on the caller's thread — which matters twice over, because the DLL is
|
||||
single-threaded and a blocking call would freeze the window.
|
||||
"""
|
||||
self._counter += 1
|
||||
token = self._counter
|
||||
self._pending.append((token, action))
|
||||
QMetaObject.invokeMethod(self, "_tick", Qt.ConnectionType.QueuedConnection)
|
||||
return token
|
||||
|
||||
@Slot()
|
||||
def _tick(self) -> None:
|
||||
"""One poll cycle: run at most one queued command, then publish state.
|
||||
|
||||
One control action per step, then a snapshot. Draining the whole queue
|
||||
here would make a slow BAS download delay every later command with no
|
||||
feedback in between.
|
||||
|
||||
Qt looks this slot up by name (see ``submit``), so it keeps both the
|
||||
decorator and the leading underscore.
|
||||
"""
|
||||
if self._pending:
|
||||
token, action = self._pending.pop(0)
|
||||
try:
|
||||
result = action(self._service)
|
||||
except Exception as exc: # noqa: BLE001 - report, never kill the thread
|
||||
logger.exception("Controller command %s failed", token)
|
||||
self.commandFailed.emit(token, self._describe(exc))
|
||||
else:
|
||||
self.commandFinished.emit(token, result)
|
||||
|
||||
self._emit_snapshot()
|
||||
|
||||
def _emit_snapshot(self) -> None:
|
||||
try:
|
||||
snapshot = self._service.poll(self._axes)
|
||||
except Exception as exc: # noqa: BLE001 - a poll must never stop the timer
|
||||
logger.exception("Polling the controller failed")
|
||||
snapshot = Snapshot(
|
||||
connected=self._service.connected,
|
||||
spec=self._service.spec,
|
||||
bus=self._service.bus,
|
||||
error=self._describe(exc),
|
||||
)
|
||||
self.snapshotReady.emit(snapshot)
|
||||
|
||||
def _describe(self, exc: Exception) -> str:
|
||||
return self._service.describe_error(exc)
|
||||
|
||||
|
||||
class MotionPoller(QObject):
|
||||
"""GUI-thread facade over the worker."""
|
||||
|
||||
snapshotReady = Signal(object)
|
||||
commandFinished = Signal(int, object)
|
||||
commandFailed = Signal(int, str)
|
||||
connectionChanged = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: MotionService | None = None,
|
||||
*,
|
||||
axes: Iterable[int] | None = None,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.service = service or MotionService()
|
||||
self._axes = list(axes) if axes is not None else list(
|
||||
range(self.service.axis_count or PLATFORM_AXES)
|
||||
)
|
||||
self._thread = QThread()
|
||||
self._worker = _Worker(self.service, self._axes)
|
||||
self._worker.moveToThread(self._thread)
|
||||
self._worker.snapshotReady.connect(self.snapshotReady)
|
||||
self._worker.commandFinished.connect(self.commandFinished)
|
||||
self._worker.commandFailed.connect(self.commandFailed)
|
||||
self._worker.connected.connect(lambda _spec: self.connectionChanged.emit(True))
|
||||
self._worker.disconnected.connect(lambda: self.connectionChanged.emit(False))
|
||||
# Kicks the worker's event loop into life on the new thread.
|
||||
self._thread.started.connect(self._on_thread_started)
|
||||
self._thread.start()
|
||||
self._interval_ms = DEFAULT_INTERVAL_MS
|
||||
|
||||
def _on_thread_started(self) -> None:
|
||||
"""Ask the worker to build its timers, on its own thread.
|
||||
|
||||
``QMetaObject.invokeMethod`` with a queued connection is used rather
|
||||
than calling :meth:`_Worker.warm_up` here: this slot runs on the GUI
|
||||
thread (the receiver is ``self``), and a QTimer created here would then
|
||||
belong to the GUI thread while being started and stopped from the
|
||||
worker — which Qt refuses at run time.
|
||||
"""
|
||||
QMetaObject.invokeMethod(
|
||||
self._worker, "warm_up", Qt.ConnectionType.QueuedConnection
|
||||
)
|
||||
|
||||
@property
|
||||
def interval_ms(self) -> int:
|
||||
return self._interval_ms
|
||||
|
||||
def set_interval_ms(self, value: int) -> None:
|
||||
"""Change the refresh rate. Callable from the GUI thread."""
|
||||
self._interval_ms = max(20, int(value))
|
||||
self._submit_void(lambda worker: worker.start_polling(self._interval_ms))
|
||||
|
||||
def set_axes(self, axes: Iterable[int]) -> None:
|
||||
self._axes = list(axes)
|
||||
self._submit_void(lambda worker: worker.set_axes(self._axes))
|
||||
|
||||
def set_language(self, language: str) -> None:
|
||||
self._submit_void(lambda worker: worker.set_language(language))
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Stop the periodic state poll.
|
||||
|
||||
The connection is untouched: pausing only stops asking. The event loop
|
||||
keeps turning, so queued commands and shutdown still work while paused.
|
||||
"""
|
||||
self._paused = True
|
||||
self._submit_void(lambda worker: worker.stop_polling())
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume the periodic state poll after :meth:`pause`."""
|
||||
self._paused = False
|
||||
self._submit_void(lambda worker: worker.start_polling(self._interval_ms))
|
||||
|
||||
@property
|
||||
def paused(self) -> bool:
|
||||
return self._paused
|
||||
|
||||
#: Whether the caller has paused the poll (see :meth:`pause`).
|
||||
_paused = False
|
||||
|
||||
def _submit_void(self, action: Callable[[_Worker], None]) -> None:
|
||||
"""Hand a fire-and-forget call to the worker through its event loop."""
|
||||
self._worker.submit(lambda _service: action(self._worker))
|
||||
|
||||
def submit(self, action: Callable[[MotionService], Any]) -> int:
|
||||
"""Queue a service call. Returns the id it will be reported under."""
|
||||
return self._worker.submit(action)
|
||||
|
||||
def connect_controller(self, spec) -> int:
|
||||
return self.submit(lambda service: service.connect(spec))
|
||||
|
||||
def disconnect_controller(self) -> int:
|
||||
def _disconnect(service: MotionService):
|
||||
# Stop the axes before closing the handle: the reference program
|
||||
# closes immediately and leaves the platform coasting.
|
||||
service.stop_all()
|
||||
service.disconnect()
|
||||
return None
|
||||
|
||||
return self.submit(_disconnect)
|
||||
|
||||
def shutdown(self, timeout_ms: int = 5000) -> None:
|
||||
"""Stop polling and join the thread. Safe to call more than once."""
|
||||
if not self._thread.isRunning():
|
||||
return
|
||||
# Both hops must run on the worker thread, because that is where the
|
||||
# timers were created. ``cleanup`` is blocking on purpose: it has to
|
||||
# complete *before* ``quit`` can take the event loop down, and waiting
|
||||
# for it also proves the worker has stopped touching the controller.
|
||||
QMetaObject.invokeMethod(
|
||||
self._worker, "cleanup", Qt.ConnectionType.BlockingQueuedConnection
|
||||
)
|
||||
self._thread.quit()
|
||||
if not self._thread.wait(timeout_ms):
|
||||
logger.warning(
|
||||
"Poller thread did not stop within %sms; it will be left to the "
|
||||
"process exit path",
|
||||
timeout_ms,
|
||||
)
|
||||
@@ -0,0 +1,670 @@
|
||||
"""Platform control logic: connection, bus init, axis commands.
|
||||
|
||||
Pure Python and Qt-free, so the whole guard/state-machine layer is testable
|
||||
without a GUI and without hardware. :mod:`~orisys_autocal.motion.poller` drives
|
||||
it from a worker thread; the UI only ever calls these methods.
|
||||
|
||||
Guards live here rather than in the UI on purpose. A motion command is refused
|
||||
for a stated reason — bus not initialised, axis not idle, non-numeric input —
|
||||
and :meth:`MotionService.jog` and friends return that reason as a
|
||||
``MotionResult`` instead of silently doing nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
import os
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from orisys_app_shared.zmotion import ZAuxError
|
||||
|
||||
from . import errors
|
||||
from .models import (
|
||||
INIT_FAILED,
|
||||
INIT_RUNNING,
|
||||
INIT_TIMEOUT_S,
|
||||
AxisParams,
|
||||
AxisState,
|
||||
AxisStatusBits,
|
||||
BusStatus,
|
||||
BusType,
|
||||
ConnectionSpec,
|
||||
MotionResult,
|
||||
Snapshot,
|
||||
)
|
||||
from .transport import DemoTransport, ZmcTransport, NativeTransport
|
||||
from .units import (
|
||||
DEFAULT_ACCEL,
|
||||
DEFAULT_SPEED,
|
||||
DEFAULT_UNITS,
|
||||
PULSES_PER_MM,
|
||||
safe_float,
|
||||
user_units_to_mm,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: The platform has five axes; the bus may report fewer if a drive is missing.
|
||||
PLATFORM_AXES = 5
|
||||
|
||||
#: ``BUS_TYPE`` values written by the vendor ``.bas`` programs.
|
||||
_BUS_TYPE_NAMES: dict[int, BusType] = {0: "ecat", 1: "rtex"}
|
||||
|
||||
|
||||
class MotionService:
|
||||
"""The controller session: one connection, five axes, one state machine."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transport: ZmcTransport | None = None,
|
||||
*,
|
||||
axis_count: int = PLATFORM_AXES,
|
||||
units_per_mm: float = DEFAULT_UNITS,
|
||||
pulses_per_mm: float = PULSES_PER_MM,
|
||||
language: str = "zh_CN",
|
||||
) -> None:
|
||||
self._transport: ZmcTransport = transport or NativeTransport()
|
||||
self.axis_count = int(axis_count)
|
||||
self._units_per_mm = safe_float(units_per_mm, DEFAULT_UNITS)
|
||||
self._pulses_per_mm = pulses_per_mm
|
||||
self.language = language
|
||||
|
||||
self._spec: ConnectionSpec | None = None
|
||||
self._bus = BusStatus()
|
||||
self._params: dict[int, AxisParams] = {
|
||||
axis: AxisParams() for axis in range(self.axis_count)
|
||||
}
|
||||
self._bas_loaded = False
|
||||
self._init_started_at: float | None = None
|
||||
self._init_timeout = INIT_TIMEOUT_S
|
||||
self._home_started: dict[int, float] = {}
|
||||
self._home_done: dict[int, bool] = {}
|
||||
|
||||
# ------------------------------------------------------------- properties
|
||||
|
||||
@property
|
||||
def transport(self) -> ZmcTransport:
|
||||
return self._transport
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._spec is not None
|
||||
|
||||
@property
|
||||
def spec(self) -> ConnectionSpec | None:
|
||||
return self._spec
|
||||
|
||||
@property
|
||||
def bus(self) -> BusStatus:
|
||||
return self._bus
|
||||
|
||||
@property
|
||||
def bas_loaded(self) -> bool:
|
||||
return self._bas_loaded
|
||||
|
||||
def params(self, axis: int) -> AxisParams:
|
||||
return self._params.get(axis, AxisParams())
|
||||
|
||||
def set_language(self, language: str) -> None:
|
||||
self.language = language
|
||||
|
||||
def _msg(self, code: int) -> str:
|
||||
return errors.describe_with_hint(code, language=self.language)
|
||||
|
||||
def describe_error(self, exc: Exception) -> str:
|
||||
"""Localised text for a failed call, for the UI and the poller.
|
||||
|
||||
A ``ZAuxError`` carries a controller code that :mod:`.errors` can
|
||||
translate; anything else (a missing DLL, a socket error) is reported
|
||||
as-is, which is where the "searched these paths" detail arrives.
|
||||
"""
|
||||
code = getattr(exc, "code", 0)
|
||||
return self._msg(code) if code else str(exc)
|
||||
|
||||
# ------------------------------------------------------------ connect/init
|
||||
|
||||
def connect(self, spec: ConnectionSpec) -> MotionResult:
|
||||
"""Open the controller.
|
||||
|
||||
A previous connection is closed first: switching transport type without
|
||||
closing leaks the DLL handle, which the reference program does.
|
||||
"""
|
||||
if self._spec is not None:
|
||||
self.disconnect()
|
||||
try:
|
||||
self._transport.open(spec)
|
||||
except (ZAuxError, OSError, ValueError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
self._spec = None
|
||||
message = self._msg(code) if code else str(exc)
|
||||
logger.warning("Connect to %s failed: %s", spec.describe(), exc)
|
||||
return MotionResult.failed(code, message)
|
||||
|
||||
self._spec = spec
|
||||
logger.info("Connected: %s via %s", spec.describe(), self._transport.source)
|
||||
# A controller may already have a BAS program running from a previous
|
||||
# session; adopt it rather than claiming the bus is uninitialised.
|
||||
self._adopt_existing_bas()
|
||||
return MotionResult.success(f"已连接 {spec.describe()}" if self._is_zh else f"Connected {spec.describe()}")
|
||||
|
||||
@property
|
||||
def _is_zh(self) -> bool:
|
||||
return self.language == "zh_CN"
|
||||
|
||||
def _adopt_existing_bas(self) -> None:
|
||||
try:
|
||||
bus_type = self._transport.user_var("BUS_TYPE")
|
||||
except (ZAuxError, OSError):
|
||||
self._bas_loaded = False
|
||||
self._bus = BusStatus(bus_type="unknown", init_status=INIT_RUNNING)
|
||||
return
|
||||
self._bas_loaded = True
|
||||
self._bus = BusStatus(
|
||||
bus_type=_BUS_TYPE_NAMES.get(int(bus_type), "unknown"),
|
||||
init_status=INIT_RUNNING,
|
||||
bas_loaded=True,
|
||||
)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
try:
|
||||
self._transport.close()
|
||||
except (ZAuxError, OSError): # pragma: no cover - close is best-effort
|
||||
logger.exception("Closing the controller connection failed")
|
||||
self._spec = None
|
||||
self._bas_loaded = False
|
||||
self._init_started_at = None
|
||||
self._home_started.clear()
|
||||
self._home_done.clear()
|
||||
self._bus = BusStatus()
|
||||
|
||||
def download_bas(self, filename: str, run_mode: int = 1) -> MotionResult:
|
||||
"""Download a ``.bas`` program to the controller and start its scan.
|
||||
|
||||
``run_mode=1`` writes to ROM, so the controller re-runs the program on
|
||||
power-up as well; the vendor ``ECAT.bas`` delays 3 s, scans the slot,
|
||||
then sets ``Bus_InitStatus``. The UI follows that through
|
||||
:func:`poll`.
|
||||
"""
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not filename or not os.path.isfile(filename):
|
||||
return MotionResult.refused(
|
||||
self._text(f"文件不存在: {filename}", f"File not found: {filename}")
|
||||
)
|
||||
try:
|
||||
self._transport.bas_download(filename, run_mode)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
self._bas_loaded = False
|
||||
self._init_started_at = None
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
|
||||
self._bas_loaded = True
|
||||
self._init_started_at = time.monotonic()
|
||||
self._bus = BusStatus(
|
||||
bus_type=self._bus.bus_type,
|
||||
init_status=INIT_RUNNING,
|
||||
bas_path=filename,
|
||||
bas_loaded=True,
|
||||
init_started_at=self._init_started_at,
|
||||
)
|
||||
# Read BUS_TYPE so the SDO/RTEX panels enable for the right family.
|
||||
try:
|
||||
bus_type = self._transport.user_var("BUS_TYPE")
|
||||
self._bus = _replace_bus_type(self._bus, _BUS_TYPE_NAMES.get(int(bus_type), "unknown"))
|
||||
except (ZAuxError, OSError):
|
||||
pass
|
||||
return MotionResult.success(
|
||||
self._text(
|
||||
"BAS 已下载,控制器将延时扫描总线",
|
||||
"BAS downloaded; the controller will scan the bus after its delay",
|
||||
)
|
||||
)
|
||||
|
||||
def reinit_bus(self) -> MotionResult:
|
||||
"""Re-run the BAS program's init task without re-downloading it."""
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._bas_loaded or not self._bus.bas_path:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
"Bas文件未加载,请先下载 ECAT.bas 或 RTEX.bas",
|
||||
"No BAS program loaded; download ECAT.bas or RTEX.bas first",
|
||||
)
|
||||
)
|
||||
if self._bus.init_status == INIT_RUNNING and not self._bus.timed_out(time.monotonic()):
|
||||
return MotionResult.refused(
|
||||
self._text("总线正在初始化,请稍候", "Bus initialisation is already running")
|
||||
)
|
||||
|
||||
script = os.path.basename(self._bus.bas_path)
|
||||
try:
|
||||
self._transport.execute("STOPTASK 2")
|
||||
self._transport.execute(f'RUN "{script}",4')
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
|
||||
self._init_started_at = time.monotonic()
|
||||
self._bus = BusStatus(
|
||||
bus_type=self._bus.bus_type,
|
||||
init_status=INIT_RUNNING,
|
||||
bas_path=self._bus.bas_path,
|
||||
bas_loaded=True,
|
||||
init_started_at=self._init_started_at,
|
||||
)
|
||||
return MotionResult.success(
|
||||
self._text("正在重新初始化总线", "Re-initialising the bus")
|
||||
)
|
||||
|
||||
def _text(self, zh: str, en: str) -> str:
|
||||
return zh if self._is_zh else en
|
||||
|
||||
# ----------------------------------------------------------------- polling
|
||||
|
||||
def poll(self, axes: Iterable[int] | None = None) -> Snapshot:
|
||||
"""Read bus and axis state for one UI refresh.
|
||||
|
||||
Never raises: a controller that has gone away produces a snapshot with
|
||||
``error`` set and the last known values, because a poll runs on a timer
|
||||
and an exception there would either kill the timer or spam it.
|
||||
"""
|
||||
if not self.connected:
|
||||
return Snapshot(connected=False, spec=self._spec, bus=self._bus)
|
||||
|
||||
wanted = list(axes) if axes is not None else list(range(self.axis_count))
|
||||
error = ""
|
||||
try:
|
||||
self._refresh_bus()
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
error = self._msg(code) if code else str(exc)
|
||||
logger.warning("Bus poll failed: %s", exc)
|
||||
|
||||
states: list[AxisState] = []
|
||||
for axis in wanted:
|
||||
try:
|
||||
states.append(self._read_axis(axis))
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
states.append(
|
||||
AxisState(
|
||||
axis=axis,
|
||||
read_ok=False,
|
||||
error=self._msg(code) if code else str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
return Snapshot(
|
||||
connected=True,
|
||||
spec=self._spec,
|
||||
bus=self._bus,
|
||||
axes=tuple(states),
|
||||
error=error,
|
||||
)
|
||||
|
||||
def _refresh_bus(self) -> None:
|
||||
now = time.monotonic()
|
||||
bus = self._bus
|
||||
init_started = self._init_started_at
|
||||
|
||||
# BUS_TYPE is present as soon as a BAS program has run at all.
|
||||
bus_type = bus.bus_type
|
||||
try:
|
||||
raw_type = self._transport.user_var("BUS_TYPE")
|
||||
bus_type = _BUS_TYPE_NAMES.get(int(raw_type), "unknown")
|
||||
self._bas_loaded = True
|
||||
except (ZAuxError, OSError):
|
||||
# Between "downloaded" and the program's first instruction the
|
||||
# globals do not exist yet, and they may never appear if the
|
||||
# program failed to start. That is the timeout case, not a reason
|
||||
# to forget that a download happened.
|
||||
if init_started is None:
|
||||
self._bas_loaded = False
|
||||
|
||||
if not self._bas_loaded:
|
||||
self._bus = BusStatus(
|
||||
bus_type=bus_type, init_status=INIT_RUNNING, bas_loaded=False
|
||||
)
|
||||
return
|
||||
|
||||
init_status = bus.init_status
|
||||
node_count = bus.node_count
|
||||
total_axes = bus.total_axes
|
||||
message = ""
|
||||
|
||||
try:
|
||||
init_status = int(self._transport.user_var("Bus_InitStatus"))
|
||||
except (ZAuxError, OSError) as exc:
|
||||
if init_started is not None and (now - init_started) > self._init_timeout:
|
||||
# The vendor's own 25 s window has elapsed without the program
|
||||
# publishing its globals: report a timeout, with the diagnostic
|
||||
# the reference program prints, rather than raising on a timer.
|
||||
init_status = INIT_FAILED
|
||||
message = self._text(
|
||||
"初始化超时:读不到 BUS_TYPE/Bus_InitStatus,"
|
||||
"请用 ZDevelop 查看 ?*ETHERCAT",
|
||||
"Initialisation timed out: BUS_TYPE/Bus_InitStatus unreadable. "
|
||||
"Check ?*ETHERCAT in ZDevelop.",
|
||||
)
|
||||
logger.warning("Bus init timed out after %.0fs: %s", self._init_timeout, exc)
|
||||
self._init_started_at = None
|
||||
self._bas_loaded = False
|
||||
else:
|
||||
self._bus = BusStatus(
|
||||
bus_type=bus_type,
|
||||
init_status=INIT_RUNNING,
|
||||
bas_path=bus.bas_path,
|
||||
bas_loaded=True,
|
||||
init_started_at=init_started,
|
||||
)
|
||||
return
|
||||
|
||||
if init_status != INIT_RUNNING:
|
||||
self._init_started_at = None
|
||||
try:
|
||||
node_count = self._transport.bus_node_count(0)
|
||||
total_axes = int(round(self._transport.user_var("Bus_TotalAxisnum")))
|
||||
except (ZAuxError, OSError):
|
||||
pass
|
||||
|
||||
self._bus = BusStatus(
|
||||
bus_type=bus_type,
|
||||
init_status=init_status,
|
||||
node_count=node_count,
|
||||
total_axes=total_axes,
|
||||
bas_path=bus.bas_path,
|
||||
bas_loaded=True,
|
||||
init_started_at=self._init_started_at,
|
||||
message=message,
|
||||
)
|
||||
|
||||
def _read_axis(self, axis: int) -> AxisState:
|
||||
transport = self._transport
|
||||
status_raw = transport.get_axis_status(axis)
|
||||
idle = transport.get_if_idle(axis) == -1
|
||||
status = AxisStatusBits(status_raw)
|
||||
|
||||
# Track the homing edge the same way the reference program does: bit 6
|
||||
# set means homing is running, and the falling edge means ask the drive
|
||||
# whether it actually completed. Between those two, report the last
|
||||
# known answer rather than claiming homing either way.
|
||||
home_status = 1 if self._home_done.get(axis, False) else 0
|
||||
if status.home_in_progress:
|
||||
self._home_started[axis] = self._home_started.get(axis, time.monotonic())
|
||||
self._home_done[axis] = False
|
||||
home_status = 0
|
||||
elif axis in self._home_started:
|
||||
del self._home_started[axis]
|
||||
try:
|
||||
home_status = int(transport.bus_home_status(axis))
|
||||
except (ZAuxError, OSError):
|
||||
home_status = 0
|
||||
self._home_done[axis] = home_status == 1
|
||||
|
||||
params = self.params(axis)
|
||||
return AxisState(
|
||||
axis=axis,
|
||||
enabled=transport.get_axis_enable(axis) != 0,
|
||||
atype=transport.get_atype(axis),
|
||||
units=transport.get_units(axis),
|
||||
speed=transport.get_speed(axis),
|
||||
accel=transport.get_accel(axis),
|
||||
dpos=transport.get_dpos(axis),
|
||||
mpos=transport.get_mpos(axis),
|
||||
status=status,
|
||||
idle=idle,
|
||||
home_status=home_status,
|
||||
read_ok=True,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------- axis setup
|
||||
|
||||
def set_axis_params(self, axis: int, params: AxisParams) -> MotionResult:
|
||||
"""Apply units/speed/accel to the controller and remember them."""
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._known_axis(axis):
|
||||
return MotionResult.refused(self._axis_range_message(axis))
|
||||
try:
|
||||
self._transport.set_units(axis, params.units)
|
||||
self._transport.set_speed(axis, params.speed)
|
||||
self._transport.set_accel(axis, params.accel)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
self._params[axis] = params
|
||||
return MotionResult.success()
|
||||
|
||||
def set_axis_enable(self, axis: int, enabled: bool) -> MotionResult:
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._known_axis(axis):
|
||||
return MotionResult.refused(self._axis_range_message(axis))
|
||||
try:
|
||||
self._transport.set_axis_enable(axis, enabled)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
return MotionResult.success(
|
||||
self._text(f"轴{axis} 已{'使能' if enabled else '关闭'}",
|
||||
f"Axis {axis} {'enabled' if enabled else 'disabled'}")
|
||||
)
|
||||
|
||||
def clear_alarm(self, axis: int) -> MotionResult:
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._known_axis(axis):
|
||||
return MotionResult.refused(self._axis_range_message(axis))
|
||||
try:
|
||||
self._transport.bus_drive_clear(axis, 0)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
return MotionResult.success(self._text("已清除报警", "Alarm cleared"))
|
||||
|
||||
# ------------------------------------------------------------------ motion
|
||||
|
||||
def _motion_guard(self, axis: int) -> MotionResult | None:
|
||||
"""Return the refusal for a motion command, or ``None`` if it may run."""
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._known_axis(axis):
|
||||
return MotionResult.refused(self._axis_range_message(axis))
|
||||
if not self._bus.initialized:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
"总线未初始化完成,禁止运动",
|
||||
"Bus is not initialised; motion is refused",
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
def jog(self, axis: int, forward: bool) -> MotionResult:
|
||||
refusal = self._motion_guard(axis)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
try:
|
||||
self._transport.jog(axis, forward)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
return MotionResult.success(
|
||||
self._text("正转" if forward else "反转", "Forward" if forward else "Reverse")
|
||||
)
|
||||
|
||||
def move_abs(self, axis: int, target: float) -> MotionResult:
|
||||
return self._move(axis, target, absolute=True)
|
||||
|
||||
def move_rel(self, axis: int, distance: float) -> MotionResult:
|
||||
return self._move(axis, distance, absolute=False)
|
||||
|
||||
def _move(self, axis: int, value: float, *, absolute: bool) -> MotionResult:
|
||||
refusal = self._motion_guard(axis)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
||||
return MotionResult.refused(
|
||||
self._text("目标位置请输入数字", "Enter a numeric position")
|
||||
)
|
||||
try:
|
||||
if absolute:
|
||||
self._transport.move_abs(axis, float(value))
|
||||
else:
|
||||
self._transport.move_rel(axis, float(value))
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
return MotionResult.success()
|
||||
|
||||
def stop(self, axis: int) -> MotionResult:
|
||||
"""Stop one axis (decelerating stop, mode 2)."""
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._known_axis(axis):
|
||||
return MotionResult.refused(self._axis_range_message(axis))
|
||||
try:
|
||||
self._transport.stop(axis, 2)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
return MotionResult.success(self._text("已停止", "Stopped"))
|
||||
|
||||
def stop_all(self) -> list[MotionResult]:
|
||||
"""Stop every axis. Used before disconnecting."""
|
||||
return [self.stop(axis) for axis in range(self.axis_count)]
|
||||
|
||||
def home(self, axis: int) -> MotionResult:
|
||||
"""Start driver homing (EtherCAT only, as the vendor example notes).
|
||||
|
||||
Speeds and offset are written before the mode: the controller latches
|
||||
them when homing starts, so setting them afterwards has no effect on
|
||||
the move already in progress.
|
||||
"""
|
||||
refusal = self._motion_guard(axis)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
if not self._bus.supports_sdo:
|
||||
return MotionResult.refused(
|
||||
self._text("rtex不支持回零", "Homing is not supported over RTEX")
|
||||
)
|
||||
params = self.params(axis)
|
||||
if params.home_mode <= 0:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
f"轴{axis} 回零模式未设置(0 不是有效模式)",
|
||||
f"Axis {axis} has no homing mode set (0 is not a valid mode)",
|
||||
)
|
||||
)
|
||||
try:
|
||||
self._transport.set_speed(axis, params.home_high)
|
||||
self._transport.set_creep(axis, params.home_low)
|
||||
self._transport.bus_set_datum_offset(axis, params.home_offset)
|
||||
self._transport.bus_datum(axis, params.home_mode)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
self._home_started[axis] = time.monotonic()
|
||||
self._home_done[axis] = False
|
||||
return MotionResult.success(self._text("回零已启动", "Homing started"))
|
||||
|
||||
def _known_axis(self, axis: int) -> bool:
|
||||
return 0 <= int(axis) < self.axis_count
|
||||
|
||||
def _axis_range_message(self, axis: int) -> str:
|
||||
return self._text(
|
||||
f"轴号超出范围 (0-{self.axis_count - 1})",
|
||||
f"Axis out of range (0-{self.axis_count - 1})",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- bus data
|
||||
|
||||
def sdo_read(
|
||||
self, node: int, index: int, subindex: int, data_type: int
|
||||
) -> tuple[MotionResult, int]:
|
||||
"""Read an EtherCAT object; returns the result and the value read."""
|
||||
refusal = self._bus_data_guard("ecat")
|
||||
if refusal is not None:
|
||||
return refusal, 0
|
||||
try:
|
||||
value = self._transport.sdo_read(node, index, subindex, data_type)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc)), 0
|
||||
return MotionResult.success(), int(value)
|
||||
|
||||
def sdo_write(
|
||||
self, node: int, index: int, subindex: int, data_type: int, value: int
|
||||
) -> MotionResult:
|
||||
return self._bus_write(
|
||||
"ecat",
|
||||
lambda: self._transport.sdo_write(node, index, subindex, data_type, int(value)),
|
||||
)
|
||||
|
||||
def rtex_read(self, axis: int, parameter: int) -> tuple[MotionResult, float]:
|
||||
refusal = self._bus_data_guard("rtex")
|
||||
if refusal is not None:
|
||||
return refusal, 0.0
|
||||
try:
|
||||
value = self._transport.rtex_read(axis, parameter)
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc)), 0.0
|
||||
return MotionResult.success(), float(value)
|
||||
|
||||
def rtex_write(self, axis: int, parameter: int, value: float) -> MotionResult:
|
||||
return self._bus_write(
|
||||
"rtex", lambda: self._transport.rtex_write(axis, parameter, float(value))
|
||||
)
|
||||
|
||||
def _bus_data_guard(self, family: BusType) -> MotionResult | None:
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if self._bus.bus_type == "unknown":
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
"总线类型未知,请先初始化总线",
|
||||
"Bus type is unknown; initialise the bus first",
|
||||
)
|
||||
)
|
||||
if self._bus.bus_type != family:
|
||||
other = "ETHERCAT" if family == "rtex" else "RTEX"
|
||||
return MotionResult.refused(
|
||||
self._text(f"非{other}模块", f"This is not a {other} module")
|
||||
)
|
||||
return None
|
||||
|
||||
def _bus_write(self, family: BusType, action: Callable[[], None]) -> MotionResult:
|
||||
refusal = self._bus_data_guard(family)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
try:
|
||||
action()
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
return MotionResult.success()
|
||||
|
||||
# ------------------------------------------------------------------ display
|
||||
|
||||
def to_mm(self, axis: int, user_units: float) -> float:
|
||||
"""Displayed millimetres for a reading. Derived, never a measurement."""
|
||||
units = self.params(axis).units or self._units_per_mm
|
||||
return user_units_to_mm(
|
||||
user_units, units=units, pulses_per_mm=self._pulses_per_mm
|
||||
)
|
||||
|
||||
|
||||
def _replace_bus_type(bus: BusStatus, bus_type: BusType) -> BusStatus:
|
||||
return BusStatus(
|
||||
bus_type=bus_type,
|
||||
init_status=bus.init_status,
|
||||
node_count=bus.node_count,
|
||||
total_axes=bus.total_axes,
|
||||
bas_path=bus.bas_path,
|
||||
bas_loaded=bus.bas_loaded,
|
||||
init_started_at=bus.init_started_at,
|
||||
message=bus.message,
|
||||
)
|
||||
@@ -0,0 +1,467 @@
|
||||
"""The seam between the platform-control logic and the controller DLL.
|
||||
|
||||
:class:`ZmcTransport` is what :mod:`~orisys_autocal.motion.service` talks to.
|
||||
Contract: every method **raises** :class:`~orisys_app_shared.zmotion.ZAuxError`
|
||||
when the controller returns non-zero, and returns plain Python values (never
|
||||
ctypes objects) when it succeeds. That lets the service have one error path and
|
||||
lets the tests inject a fake that fails on demand.
|
||||
|
||||
Three implementations:
|
||||
|
||||
* :class:`NativeTransport` — the real controller, via the vendored DLL.
|
||||
* :class:`DemoTransport` — a self-consistent simulated platform, so the whole
|
||||
UI can be exercised with no hardware and no DLL.
|
||||
* ``FakeTransport`` in the app's test suite — the same protocol, driven by the
|
||||
test rather than by a simulation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from orisys_app_shared.zmotion import ZAuxError, ZmotionClient
|
||||
|
||||
from .errors import ERR_AUX_OFFSET
|
||||
from .models import BusType, ConnectionSpec
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ZmcTransport(Protocol):
|
||||
"""What the service needs from a controller connection."""
|
||||
|
||||
#: Human-readable source, e.g. the DLL directory or ``"demo"``.
|
||||
source: str
|
||||
|
||||
def open(self, spec: ConnectionSpec) -> None: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
def search_eth(self) -> list[str]: ...
|
||||
|
||||
def bas_download(self, filename: str, run_mode: int = 1) -> None: ...
|
||||
|
||||
def execute(self, command: str) -> None: ...
|
||||
|
||||
def user_var(self, name: str) -> float: ...
|
||||
|
||||
def bus_node_count(self, slot: int = 0) -> int: ...
|
||||
|
||||
def bus_home_status(self, axis: int) -> int: ...
|
||||
|
||||
def bus_datum(self, axis: int, home_mode: int) -> None: ...
|
||||
|
||||
def bus_set_datum_offset(self, axis: int, value: float) -> None: ...
|
||||
|
||||
def bus_drive_clear(self, axis: int, mode: int = 0) -> None: ...
|
||||
|
||||
def sdo_read(
|
||||
self, node: int, index: int, subindex: int, data_type: int, slot: int = 0
|
||||
) -> int: ...
|
||||
|
||||
def sdo_write(
|
||||
self,
|
||||
node: int,
|
||||
index: int,
|
||||
subindex: int,
|
||||
data_type: int,
|
||||
value: int,
|
||||
slot: int = 0,
|
||||
) -> None: ...
|
||||
|
||||
def rtex_read(self, axis: int, parameter: int) -> float: ...
|
||||
|
||||
def rtex_write(self, axis: int, parameter: int, value: float) -> None: ...
|
||||
|
||||
def set_axis_enable(self, axis: int, enabled: bool) -> None: ...
|
||||
|
||||
def set_units(self, axis: int, value: float) -> None: ...
|
||||
|
||||
def get_units(self, axis: int) -> float: ...
|
||||
|
||||
def set_speed(self, axis: int, value: float) -> None: ...
|
||||
|
||||
def get_speed(self, axis: int) -> float: ...
|
||||
|
||||
def set_accel(self, axis: int, value: float) -> None: ...
|
||||
|
||||
def get_accel(self, axis: int) -> float: ...
|
||||
|
||||
def set_creep(self, axis: int, value: float) -> None: ...
|
||||
|
||||
def get_atype(self, axis: int) -> int: ...
|
||||
|
||||
def get_axis_enable(self, axis: int) -> int: ...
|
||||
|
||||
def get_dpos(self, axis: int) -> float: ...
|
||||
|
||||
def get_mpos(self, axis: int) -> float: ...
|
||||
|
||||
def get_axis_status(self, axis: int) -> int: ...
|
||||
|
||||
def get_if_idle(self, axis: int) -> int: ...
|
||||
|
||||
def jog(self, axis: int, forward: bool) -> None: ...
|
||||
|
||||
def move_abs(self, axis: int, position: float) -> None: ...
|
||||
|
||||
def move_rel(self, axis: int, distance: float) -> None: ...
|
||||
|
||||
def stop(self, axis: int, mode: int = 2) -> None: ...
|
||||
|
||||
|
||||
class NativeTransport:
|
||||
"""The real controller, through the vendored ZMotion runtime."""
|
||||
|
||||
def __init__(self, client: ZmotionClient | None = None) -> None:
|
||||
self._client = client or ZmotionClient()
|
||||
|
||||
@property
|
||||
def client(self) -> ZmotionClient:
|
||||
return self._client
|
||||
|
||||
@property
|
||||
def source(self) -> str:
|
||||
return self._client.dll_dir or "zauxdll.dll (not loaded)"
|
||||
|
||||
def open(self, spec: ConnectionSpec) -> None:
|
||||
if spec.mode == "eth":
|
||||
result = self._client.open_eth(spec.ip)
|
||||
elif spec.mode == "com":
|
||||
result = self._client.open_com(spec.com_id)
|
||||
elif spec.mode == "pci":
|
||||
result = self._client.open_pci(spec.card_num)
|
||||
elif spec.mode == "local":
|
||||
result = self._client.fast_open(5, spec.local_string, 1000)
|
||||
else:
|
||||
raise ValueError(f"{spec.mode!r} is not a native connection mode")
|
||||
result.raise_for_status(f"open {spec.describe()}")
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def search_eth(self) -> list[str]:
|
||||
value = self._client.search_eth()
|
||||
# A scan that finds nothing is not an error: the reference program
|
||||
# lists the configured defaults and moves on.
|
||||
if not value.ok:
|
||||
return []
|
||||
return [item for item in str(value.value).split() if item]
|
||||
|
||||
def bas_download(self, filename: str, run_mode: int = 1) -> None:
|
||||
self._client.bas_download(filename, run_mode).raise_for_status("BAS download")
|
||||
|
||||
def execute(self, command: str) -> None:
|
||||
self._client.execute(command).raise_for_status(command)
|
||||
|
||||
def user_var(self, name: str) -> float:
|
||||
return float(self._client.get_user_var(name).raise_for_status(name).value)
|
||||
|
||||
def bus_node_count(self, slot: int = 0) -> int:
|
||||
return int(self._client.bus_node_count(slot).raise_for_status().value)
|
||||
|
||||
def bus_home_status(self, axis: int) -> int:
|
||||
return int(self._client.bus_home_status(axis).raise_for_status().value)
|
||||
|
||||
def bus_datum(self, axis: int, home_mode: int) -> None:
|
||||
self._client.bus_datum(axis, home_mode).raise_for_status("datum")
|
||||
|
||||
def bus_set_datum_offset(self, axis: int, value: float) -> None:
|
||||
self._client.bus_set_datum_offset(axis, value).raise_for_status()
|
||||
|
||||
def bus_drive_clear(self, axis: int, mode: int = 0) -> None:
|
||||
self._client.bus_drive_clear(axis, mode).raise_for_status()
|
||||
|
||||
def sdo_read(
|
||||
self, node: int, index: int, subindex: int, data_type: int, slot: int = 0
|
||||
) -> int:
|
||||
return int(
|
||||
self._client.sdo_read(slot, node, index, subindex, data_type)
|
||||
.raise_for_status("SDO read")
|
||||
.value
|
||||
)
|
||||
|
||||
def sdo_write(
|
||||
self,
|
||||
node: int,
|
||||
index: int,
|
||||
subindex: int,
|
||||
data_type: int,
|
||||
value: int,
|
||||
slot: int = 0,
|
||||
) -> None:
|
||||
self._client.sdo_write(slot, node, index, subindex, data_type, value).raise_for_status(
|
||||
"SDO write"
|
||||
)
|
||||
|
||||
def rtex_read(self, axis: int, parameter: int) -> float:
|
||||
return float(self._client.rtex_read(axis, parameter).raise_for_status().value)
|
||||
|
||||
def rtex_write(self, axis: int, parameter: int, value: float) -> None:
|
||||
self._client.rtex_write(axis, parameter, value).raise_for_status()
|
||||
|
||||
def set_axis_enable(self, axis: int, enabled: bool) -> None:
|
||||
self._client.set_axis_enable(axis, 1 if enabled else 0).raise_for_status()
|
||||
|
||||
def set_units(self, axis: int, value: float) -> None:
|
||||
self._client.set_units(axis, value).raise_for_status()
|
||||
|
||||
def get_units(self, axis: int) -> float:
|
||||
return float(self._client.get_units(axis).raise_for_status().value)
|
||||
|
||||
def set_speed(self, axis: int, value: float) -> None:
|
||||
self._client.set_speed(axis, value).raise_for_status()
|
||||
|
||||
def get_speed(self, axis: int) -> float:
|
||||
return float(self._client.get_speed(axis).raise_for_status().value)
|
||||
|
||||
def set_accel(self, axis: int, value: float) -> None:
|
||||
self._client.set_accel(axis, value).raise_for_status()
|
||||
|
||||
def get_accel(self, axis: int) -> float:
|
||||
return float(self._client.get_accel(axis).raise_for_status().value)
|
||||
|
||||
def set_creep(self, axis: int, value: float) -> None:
|
||||
self._client.set_creep(axis, value).raise_for_status()
|
||||
|
||||
def get_atype(self, axis: int) -> int:
|
||||
return int(self._client.get_atype(axis).raise_for_status().value)
|
||||
|
||||
def get_axis_enable(self, axis: int) -> int:
|
||||
return int(self._client.get_axis_enable(axis).raise_for_status().value)
|
||||
|
||||
def get_dpos(self, axis: int) -> float:
|
||||
return float(self._client.get_dpos(axis).raise_for_status().value)
|
||||
|
||||
def get_mpos(self, axis: int) -> float:
|
||||
return float(self._client.get_mpos(axis).raise_for_status().value)
|
||||
|
||||
def get_axis_status(self, axis: int) -> int:
|
||||
return int(self._client.get_axis_status(axis).raise_for_status().value)
|
||||
|
||||
def get_if_idle(self, axis: int) -> int:
|
||||
return int(self._client.get_if_idle(axis).raise_for_status().value)
|
||||
|
||||
def jog(self, axis: int, forward: bool) -> None:
|
||||
self._client.single_vmove(axis, 1 if forward else -1).raise_for_status()
|
||||
|
||||
def move_abs(self, axis: int, position: float) -> None:
|
||||
self._client.single_move_abs(axis, position).raise_for_status()
|
||||
|
||||
def move_rel(self, axis: int, distance: float) -> None:
|
||||
self._client.single_move(axis, distance).raise_for_status()
|
||||
|
||||
def stop(self, axis: int, mode: int = 2) -> None:
|
||||
self._client.single_cancel(axis, mode).raise_for_status()
|
||||
|
||||
|
||||
class DemoTransport:
|
||||
"""A self-consistent simulated 5-axis platform.
|
||||
|
||||
Not a mock: it keeps position, enable state and motion state so the UI's
|
||||
polling, jogging and stop behaviour are all exercised without hardware.
|
||||
``BUS_TYPE`` reports EtherCAT so the SDO panel is live too.
|
||||
"""
|
||||
|
||||
#: Where jogging stops, in user units — keeps a demo jog bounded.
|
||||
TRAVEL_LIMIT = 5000.0
|
||||
|
||||
def __init__(self, *, axes: int = 5) -> None:
|
||||
self._axes = int(axes)
|
||||
self._enabled = {axis: True for axis in range(self._axes)}
|
||||
self._units = {axis: 10.0 for axis in range(self._axes)}
|
||||
self._speed = {axis: 1000.0 for axis in range(self._axes)}
|
||||
self._accel = {axis: 10000.0 for axis in range(self._axes)}
|
||||
self._dpos = {axis: 0.0 for axis in range(self._axes)}
|
||||
self._moving_until = {axis: 0.0 for axis in range(self._axes)}
|
||||
self._homing_until = {axis: 0.0 for axis in range(self._axes)}
|
||||
self._home_done = {axis: False for axis in range(self._axes)}
|
||||
self._bas_loaded = False
|
||||
self._init_started: float | None = None
|
||||
self._opened = False
|
||||
self._sdo: dict[tuple[int, int, int], int] = {}
|
||||
|
||||
@property
|
||||
def source(self) -> str:
|
||||
return "demo (simulated platform, no hardware)"
|
||||
|
||||
def open(self, spec: ConnectionSpec) -> None:
|
||||
if spec.mode != "demo":
|
||||
raise ZAuxError(ERR_DEMO_UNSUPPORTED, "DemoTransport only serves demo mode")
|
||||
self._opened = True
|
||||
|
||||
def close(self) -> None:
|
||||
self._opened = False
|
||||
|
||||
def search_eth(self) -> list[str]:
|
||||
return ["127.0.0.1"]
|
||||
|
||||
def bas_download(self, filename: str, run_mode: int = 1) -> None:
|
||||
if not self._opened:
|
||||
raise ZAuxError(ERR_NOT_OPEN, "not connected")
|
||||
self._bas_loaded = True
|
||||
# ECAT.bas delays 3 s before scanning; mimic the delay so the UI's
|
||||
# "initialising" state is visible rather than instantaneous.
|
||||
self._init_started = time.monotonic() + DEMO_INIT_DELAY_S
|
||||
|
||||
def execute(self, command: str) -> None:
|
||||
if not self._opened:
|
||||
raise ZAuxError(ERR_NOT_OPEN, "not connected")
|
||||
if "RUN" in command.upper() and self._bas_loaded:
|
||||
self._init_started = time.monotonic() + DEMO_INIT_DELAY_S
|
||||
|
||||
def user_var(self, name: str) -> float:
|
||||
if not self._bas_loaded:
|
||||
# Mirrors the real controller: unknown/absent variable.
|
||||
raise ZAuxError(ERR_AUX_OFFSET + 2, f"{name} is not defined")
|
||||
if name == "BUS_TYPE":
|
||||
return 0.0
|
||||
if name == "Bus_TotalAxisnum":
|
||||
return float(self._axes)
|
||||
if name == "Bus_InitStatus":
|
||||
if self._init_started is not None and time.monotonic() >= self._init_started:
|
||||
return 1.0
|
||||
return -1.0
|
||||
raise ZAuxError(ERR_AUX_OFFSET + 2, f"{name} is not defined")
|
||||
|
||||
def _require_axis(self, axis: int) -> None:
|
||||
if not (0 <= axis < self._axes):
|
||||
raise ZAuxError(ERR_AUX_OFFSET + 2, f"axis {axis} does not exist")
|
||||
|
||||
def bus_node_count(self, slot: int = 0) -> int:
|
||||
return self._axes
|
||||
|
||||
def bus_home_status(self, axis: int) -> int:
|
||||
self._require_axis(axis)
|
||||
return 1 if self._home_done[axis] else 0
|
||||
|
||||
def bus_datum(self, axis: int, home_mode: int) -> None:
|
||||
self._require_axis(axis)
|
||||
self._homing_until[axis] = time.monotonic() + DEMO_HOME_S
|
||||
|
||||
def bus_set_datum_offset(self, axis: int, value: float) -> None:
|
||||
self._require_axis(axis)
|
||||
|
||||
def bus_drive_clear(self, axis: int, mode: int = 0) -> None:
|
||||
self._require_axis(axis)
|
||||
|
||||
def sdo_read(
|
||||
self, node: int, index: int, subindex: int, data_type: int, slot: int = 0
|
||||
) -> int:
|
||||
return self._sdo.get((node, index, subindex), 0)
|
||||
|
||||
def sdo_write(
|
||||
self,
|
||||
node: int,
|
||||
index: int,
|
||||
subindex: int,
|
||||
data_type: int,
|
||||
value: int,
|
||||
slot: int = 0,
|
||||
) -> None:
|
||||
self._sdo[(node, index, subindex)] = int(value)
|
||||
|
||||
def rtex_read(self, axis: int, parameter: int) -> float:
|
||||
raise ZAuxError(ERR_AUX_OFFSET + 3, "RTEX is not available in demo mode")
|
||||
|
||||
def rtex_write(self, axis: int, parameter: int, value: float) -> None:
|
||||
raise ZAuxError(ERR_AUX_OFFSET + 3, "RTEX is not available in demo mode")
|
||||
|
||||
def set_axis_enable(self, axis: int, enabled: bool) -> None:
|
||||
self._require_axis(axis)
|
||||
self._enabled[axis] = bool(enabled)
|
||||
|
||||
def set_units(self, axis: int, value: float) -> None:
|
||||
self._require_axis(axis)
|
||||
self._units[axis] = float(value)
|
||||
|
||||
def get_units(self, axis: int) -> float:
|
||||
self._require_axis(axis)
|
||||
return self._units[axis]
|
||||
|
||||
def set_speed(self, axis: int, value: float) -> None:
|
||||
self._require_axis(axis)
|
||||
self._speed[axis] = float(value)
|
||||
|
||||
def get_speed(self, axis: int) -> float:
|
||||
self._require_axis(axis)
|
||||
return self._speed[axis]
|
||||
|
||||
def set_accel(self, axis: int, value: float) -> None:
|
||||
self._require_axis(axis)
|
||||
self._accel[axis] = float(value)
|
||||
|
||||
def get_accel(self, axis: int) -> float:
|
||||
self._require_axis(axis)
|
||||
return self._accel[axis]
|
||||
|
||||
def set_creep(self, axis: int, value: float) -> None:
|
||||
self._require_axis(axis)
|
||||
|
||||
def get_atype(self, axis: int) -> int:
|
||||
self._require_axis(axis)
|
||||
return 65 # position mode, as ECAT.bas configures
|
||||
|
||||
def get_axis_enable(self, axis: int) -> int:
|
||||
self._require_axis(axis)
|
||||
return 1 if self._enabled[axis] else 0
|
||||
|
||||
def get_dpos(self, axis: int) -> float:
|
||||
self._require_axis(axis)
|
||||
return self._dpos[axis]
|
||||
|
||||
def get_mpos(self, axis: int) -> float:
|
||||
self._require_axis(axis)
|
||||
# Follow the command position with a small lag while moving.
|
||||
return self._dpos[axis] * 0.999
|
||||
|
||||
def get_axis_status(self, axis: int) -> int:
|
||||
self._require_axis(axis)
|
||||
status = 0
|
||||
if self._homing_until[axis] > time.monotonic():
|
||||
status |= 64 # homing in progress
|
||||
if self._moving_until[axis] > time.monotonic():
|
||||
status |= 2 # moving
|
||||
return status
|
||||
|
||||
def get_if_idle(self, axis: int) -> int:
|
||||
self._require_axis(axis)
|
||||
moving = self._moving_until[axis] > time.monotonic()
|
||||
homing = self._homing_until[axis] > time.monotonic()
|
||||
return 0 if (moving or homing) else -1
|
||||
|
||||
def jog(self, axis: int, forward: bool) -> None:
|
||||
self._require_axis(axis)
|
||||
self._moving_until[axis] = time.monotonic() + DEMO_JOG_S
|
||||
|
||||
def move_abs(self, axis: int, position: float) -> None:
|
||||
self._require_axis(axis)
|
||||
if not math.isfinite(position):
|
||||
raise ZAuxError(ERR_AUX_OFFSET + 2, "position is not finite")
|
||||
self._dpos[axis] = float(position)
|
||||
self._moving_until[axis] = time.monotonic() + DEMO_MOVE_S
|
||||
|
||||
def move_rel(self, axis: int, distance: float) -> None:
|
||||
self._require_axis(axis)
|
||||
self.move_abs(axis, self._dpos[axis] + float(distance))
|
||||
|
||||
def stop(self, axis: int, mode: int = 2) -> None:
|
||||
self._require_axis(axis)
|
||||
self._moving_until[axis] = 0.0
|
||||
self._homing_until[axis] = 0.0
|
||||
self._home_done[axis] = True
|
||||
|
||||
def bus_type(self) -> BusType:
|
||||
return "ecat"
|
||||
|
||||
|
||||
#: Demo timing constants (seconds), chosen so a human sees each state.
|
||||
DEMO_INIT_DELAY_S = 1.5
|
||||
DEMO_JOG_S = 1.0
|
||||
DEMO_MOVE_S = 0.8
|
||||
DEMO_HOME_S = 2.0
|
||||
|
||||
#: Demo-only failure codes, kept outside the documented vendor range on purpose.
|
||||
ERR_NOT_OPEN = 30500
|
||||
ERR_DEMO_UNSUPPORTED = 30501
|
||||
@@ -0,0 +1,72 @@
|
||||
"""User-unit to millimetre conversion.
|
||||
|
||||
The numbers come from bench measurement on the platform, transcribed from the
|
||||
vendor reference program's comments::
|
||||
|
||||
# 轴0(X)/轴1(Y) 实测: UNITS=10 时 1000用户单位 = 2mm → 每毫米 5000 脉冲
|
||||
# 当量5 → 1000单位=1mm,不是100单位=1mm
|
||||
|
||||
so ``PULSES_PER_MM = 5000`` is the calibration constant and ``UNITS = 10`` is
|
||||
the pulse equivalent the platform is configured with. The displayed millimetre
|
||||
value is therefore *derived*, not measured: the UI labels it as such, and no
|
||||
code path may treat it as a calibrated length.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
#: Measured: 5000 pulses per millimetre (see the module docstring).
|
||||
PULSES_PER_MM = 5000.0
|
||||
|
||||
#: Pulse equivalent the platform is configured with by default.
|
||||
DEFAULT_UNITS = 10.0
|
||||
|
||||
#: Move-parameter defaults, matching the reference program's UI initialisation.
|
||||
DEFAULT_SPEED = 1000.0
|
||||
DEFAULT_ACCEL = 10000.0
|
||||
|
||||
|
||||
def safe_float(text: object, fallback: float) -> float:
|
||||
"""Parse ``text`` as a finite float, falling back on anything unusable.
|
||||
|
||||
A blank, non-numeric, zero or non-finite entry must not silently become a
|
||||
pulse equivalent of zero — on a motion controller that is a divide-by-zero
|
||||
waiting to happen, or an unbounded move.
|
||||
"""
|
||||
try:
|
||||
value = float(str(text).strip())
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
if not math.isfinite(value) or value == 0.0:
|
||||
return fallback
|
||||
return value
|
||||
|
||||
|
||||
def user_units_to_mm(
|
||||
value: float, *, units: float = DEFAULT_UNITS, pulses_per_mm: float = PULSES_PER_MM
|
||||
) -> float:
|
||||
"""Convert a controller user-unit value to millimetres.
|
||||
|
||||
Args:
|
||||
value: a ``DPOS``/``MPOS`` reading or move distance in user units.
|
||||
units: pulse equivalent in force on the axis.
|
||||
pulses_per_mm: the platform's calibration constant.
|
||||
|
||||
Returns:
|
||||
Millimetres. Not finite when the inputs are not usable, so a caller
|
||||
that forgets to validate sees an obvious NaN rather than a plausible
|
||||
wrong number.
|
||||
"""
|
||||
if pulses_per_mm <= 0:
|
||||
return math.nan
|
||||
return float(value) * float(units) / float(pulses_per_mm)
|
||||
|
||||
|
||||
def mm_to_user_units(
|
||||
value: float, *, units: float = DEFAULT_UNITS, pulses_per_mm: float = PULSES_PER_MM
|
||||
) -> float:
|
||||
"""Inverse of :func:`user_units_to_mm` (for an mm-entered move)."""
|
||||
if units <= 0:
|
||||
return math.nan
|
||||
return float(value) * float(pulses_per_mm) / float(units)
|
||||
@@ -0,0 +1,179 @@
|
||||
"""AutoCal's preference schema and storage.
|
||||
|
||||
The shared store keys off whatever schema it is handed, so AutoCal gets its own
|
||||
key set rather than inheriting the studio's camera/plot/3D keys. The values here
|
||||
are the ones an operator would otherwise retype at every start-up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared import prefs as _prefs
|
||||
from orisys_app_shared.paths import preference_path
|
||||
|
||||
from .motion.service import PLATFORM_AXES
|
||||
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
|
||||
|
||||
#: Connection modes, in the order the combo offers them.
|
||||
CONNECTION_MODES = ("eth", "com", "pci", "local", "demo")
|
||||
|
||||
#: Addresses the controller is normally reached on, tried first by a scan.
|
||||
DEFAULT_CONTROLLER_IPS = ("192.168.0.11", "127.0.0.1")
|
||||
|
||||
#: Grid modes the sequence profile offers (workflow P5).
|
||||
SURFACE_MODES = ("flat", "curved")
|
||||
|
||||
#: Point visiting orders (workflow P5).
|
||||
POINT_ORDERS = ("row", "serpentine", "distance")
|
||||
|
||||
#: The design target is a well-lit bench, so light is the default; dark stays
|
||||
#: selectable for a dim room.
|
||||
DEFAULT_THEME = "light"
|
||||
|
||||
|
||||
def _default_sequence_profile() -> dict[str, Any]:
|
||||
"""The point-generation and run parameters of workflow P5.
|
||||
|
||||
One profile is global for now; named, saved sequences come with the runner.
|
||||
The numbers are starting points an operator confirms at the bench, not
|
||||
measured values — in particular ``home_mode``-style unknowns are left at 0 so
|
||||
nothing is silently inherited from another machine.
|
||||
"""
|
||||
return {
|
||||
# --- grid shape
|
||||
"mode": "flat",
|
||||
"pitch_mm": 2.0,
|
||||
"margin_mm": 1.0,
|
||||
"radius_mm": 0.0,
|
||||
# --- motion
|
||||
"clear_height_mm": 5.0,
|
||||
"approach_speed_mm_s": 2.0,
|
||||
"press_speed_mm_s": 0.5,
|
||||
"press_depth_mm": 0.2,
|
||||
"dwell_s": 0.5,
|
||||
# --- measurement
|
||||
"settle_window_s": 0.5,
|
||||
"repeats": 3,
|
||||
# --- order
|
||||
"order": "serpentine",
|
||||
}
|
||||
|
||||
|
||||
def _default_safety_limits() -> dict[str, float]:
|
||||
"""Workflow P0: the limits that must have a value before anything moves.
|
||||
|
||||
``0`` means "not set" for the ones where a real value is required, and the
|
||||
app reports that at the P0 gate rather than treating it as unlimited.
|
||||
"""
|
||||
return {
|
||||
"max_travel_mm": 100.0,
|
||||
"max_descent_mm": 10.0,
|
||||
"max_press_depth_mm": 1.0,
|
||||
"follow_error_mm": 0.5,
|
||||
#: How far the axis may descend looking for contact before the run is
|
||||
#: aborted (workflow P7, no-contact abort).
|
||||
"contact_search_mm": 3.0,
|
||||
}
|
||||
|
||||
|
||||
def _default_sensor_setup() -> dict[str, Any]:
|
||||
"""Workflow P3/P4: what is under test, what touches it, and the transform.
|
||||
|
||||
``sensor_to_platform`` stays empty until the transform is established by
|
||||
probing known features; the residual travels with it so a re-fixtured sensor
|
||||
cannot silently reuse a stale transform.
|
||||
"""
|
||||
return {
|
||||
"sensor_type": "box",
|
||||
"config_ref": "",
|
||||
"geometry_id": "",
|
||||
#: Which axis presses, and the probe tip offset from the platform's
|
||||
#: reference point. Empty offset means "not set", which blocks P5.
|
||||
"press_axis": 2,
|
||||
"probe_tip_offset_mm": [0.0, 0.0, 0.0],
|
||||
"probe_tip_set": False,
|
||||
#: 4x4 row-major sensor -> platform transform, plus how well it fitted.
|
||||
"sensor_to_platform": [],
|
||||
"transform_residual_mm": 0.0,
|
||||
"transform_feature_count": 0,
|
||||
}
|
||||
|
||||
|
||||
def _default_axis_params() -> dict[str, dict[str, float | int]]:
|
||||
"""One parameter block per platform axis.
|
||||
|
||||
``home_mode`` starts at 0, which is **not** a valid CiA-402 homing mode:
|
||||
the operator must choose one for the drive in front of them rather than
|
||||
inherit a number from another machine. The reference program pre-filled 17,
|
||||
which is a specific drive's mode, so it is deliberately not copied here.
|
||||
"""
|
||||
return {
|
||||
str(axis): {
|
||||
"units": DEFAULT_UNITS,
|
||||
"speed": DEFAULT_SPEED,
|
||||
"accel": DEFAULT_ACCEL,
|
||||
"creep": 10.0,
|
||||
"home_mode": 0,
|
||||
"home_high": 50.0,
|
||||
"home_low": 10.0,
|
||||
"home_offset": 0.0,
|
||||
}
|
||||
for axis in range(PLATFORM_AXES)
|
||||
}
|
||||
|
||||
|
||||
def default_preferences() -> dict[str, Any]:
|
||||
"""AutoCal's default preference document."""
|
||||
return {
|
||||
"language": "zh_CN",
|
||||
"theme": DEFAULT_THEME,
|
||||
"window_geometry": None,
|
||||
"connection": {
|
||||
"mode": "eth",
|
||||
"ip": DEFAULT_CONTROLLER_IPS[0],
|
||||
"com_id": 0,
|
||||
"card_num": 0,
|
||||
"local_string": "0",
|
||||
},
|
||||
# Remembered so "download the BAS program" is one click after the first
|
||||
# time, instead of re-picking the file every session.
|
||||
"bas_path": "",
|
||||
#: Displayed millimetres are derived from this; it is a bench-measured
|
||||
#: constant, not a calibrated length (see motion/units.py).
|
||||
"units_per_mm": DEFAULT_UNITS,
|
||||
"state_poll_ms": 100,
|
||||
"axis_params": _default_axis_params(),
|
||||
#: {node, index, subindex, type} of the last SDO read/written, so the
|
||||
#: operator can repeat a drive-setting check quickly.
|
||||
"sdo_last": {"node": 0, "index": 24704, "subindex": 0, "type": 0},
|
||||
"rtex_last": {"axis": 0, "parameter": 0},
|
||||
"show_diagnostics": False,
|
||||
# --- workflow blocks (see apps/autocal/docs/workflow.md) --------------
|
||||
#: P0: limits enforced from P2 onward.
|
||||
"safety_limits": _default_safety_limits(),
|
||||
#: P3/P4: sensor under test, probe tip, sensor<->platform transform.
|
||||
"sensor_setup": _default_sensor_setup(),
|
||||
#: P5: grid generation and run parameters.
|
||||
"sequence_profile": _default_sequence_profile(),
|
||||
}
|
||||
|
||||
|
||||
def load_preferences() -> dict[str, Any]:
|
||||
"""Stored preferences with AutoCal's defaults filling the gaps."""
|
||||
prefs = _prefs.load_preferences(default_preferences())
|
||||
# A stored document from an older build may miss a newly added axis block.
|
||||
axis_params = prefs.setdefault("axis_params", {})
|
||||
for axis, values in _default_axis_params().items():
|
||||
axis_params.setdefault(axis, values)
|
||||
return prefs
|
||||
|
||||
|
||||
def save_preferences(prefs: dict[str, Any]) -> Path:
|
||||
return _prefs.save_preferences(prefs, default_preferences())
|
||||
|
||||
|
||||
def path() -> Path:
|
||||
"""Where AutoCal's ``preference.json`` lives (for the About box)."""
|
||||
return preference_path()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""AutoCal's windowed user interface."""
|
||||
|
||||
from .axis_panel import AxisPanel
|
||||
from .brand_bar import BrandBar
|
||||
from .bus_data_panel import BusDataPanel
|
||||
from .bus_panel import BusPanel
|
||||
from .connection_panel import ConnectionPanel
|
||||
from .window import AutoCalMainWindow
|
||||
|
||||
__all__ = [
|
||||
"AutoCalMainWindow",
|
||||
"AxisPanel",
|
||||
"BrandBar",
|
||||
"BusDataPanel",
|
||||
"BusPanel",
|
||||
"ConnectionPanel",
|
||||
]
|
||||
@@ -0,0 +1,611 @@
|
||||
"""Axis parameters, live state, and motion commands for all five axes.
|
||||
|
||||
Two halves, both always visible because they answer different questions:
|
||||
|
||||
* the **parameter form** edits one axis at a time (units, speed, accel, homing)
|
||||
and applies it to the controller;
|
||||
* the **state table** shows every axis at once — enable, position in user units
|
||||
and millimetres, motion, homing — with that axis's actions in the last column.
|
||||
|
||||
Keeping the whole platform in one table is a deliberate change from the vendor
|
||||
program, which acts on whichever axis number happens to be typed in a box. On a
|
||||
five-axis platform, acting on the wrong axis is the expensive mistake.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion import (
|
||||
DEFAULT_ACCEL,
|
||||
DEFAULT_SPEED,
|
||||
DEFAULT_UNITS,
|
||||
AxisParams,
|
||||
AxisState,
|
||||
Snapshot,
|
||||
safe_float,
|
||||
user_units_to_mm,
|
||||
)
|
||||
|
||||
#: Data columns in the state table, in display order.
|
||||
#:
|
||||
#: Seven, not thirteen. Matching the full frame of a drive here made the middle
|
||||
#: columns illegible at a real window width, and a table nobody can read protects
|
||||
#: nobody. What does not fit is shown for the *selected* axis on the detail line
|
||||
#: below the table — see :meth:`AxisPanel._detail_text`.
|
||||
COLUMNS = (
|
||||
"axes.axis",
|
||||
"axes.enable",
|
||||
"axes.units",
|
||||
"axes.speed",
|
||||
"axes.dpos",
|
||||
"axes.dpos_mm",
|
||||
"axes.status",
|
||||
)
|
||||
|
||||
#: The actions column follows the data columns.
|
||||
ACTIONS_COLUMN = len(COLUMNS)
|
||||
|
||||
#: Row height in pixels. Must fit the per-row buttons: a QTableWidget does not
|
||||
#: derive its row height from a cell widget, and the shared stylesheet gives a
|
||||
#: button 28 px of minimum height plus 16 px of padding (44 px total) unless it
|
||||
#: carries the ``rail`` property, which the theme defines as the compact variant.
|
||||
ROW_HEIGHT = 36
|
||||
|
||||
#: How many axis rows the panel insists on showing without scrolling. Set to all
|
||||
#: of them: on a five-axis platform a clipped fifth row reads as a missing drive.
|
||||
#: This is also what makes the splitter give the panel the height it needs — Qt
|
||||
#: distributes a splitter from the children's minimum sizes, so a smaller value
|
||||
#: here means a scrollbar instead of five visible axes.
|
||||
MIN_VISIBLE_ROWS = 5
|
||||
|
||||
#: Height of the small parameter fields. Pinned so the parameter form keeps to
|
||||
#: two rows: the fields' natural height pushes that form to ~136 px, which costs
|
||||
#: one and a half axis rows in the table below it.
|
||||
FIELD_HEIGHT = 28
|
||||
|
||||
#: Column widths by index. With the project's thirteen columns the widths had to
|
||||
#: shrink below the headers; seven can each be comfortable.
|
||||
_COLUMN_WIDTHS = {
|
||||
0: 46,
|
||||
1: 96,
|
||||
2: 100,
|
||||
3: 100,
|
||||
4: 130,
|
||||
5: 130,
|
||||
6: 130,
|
||||
ACTIONS_COLUMN: 230,
|
||||
}
|
||||
|
||||
|
||||
def parse_optional_float(text: str, fallback: float) -> float:
|
||||
"""A numeric field's value, or the fallback when it is blank/unusable."""
|
||||
if str(text).strip() == "":
|
||||
return fallback
|
||||
return safe_float(text, fallback)
|
||||
|
||||
|
||||
class AxisPanel(QGroupBox):
|
||||
"""Parameters plus live state for the platform's axes."""
|
||||
|
||||
paramsApplied = Signal(int, object)
|
||||
enableToggled = Signal(int, bool)
|
||||
jogRequested = Signal(int, bool)
|
||||
stopRequested = Signal(int)
|
||||
stopAllRequested = Signal()
|
||||
moveAbsRequested = Signal(int, float)
|
||||
moveRelRequested = Signal(int, float)
|
||||
homeRequested = Signal(int)
|
||||
clearAlarmRequested = Signal(int)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
axis_count: int,
|
||||
params: dict[int, AxisParams] | None = None,
|
||||
*,
|
||||
units_per_mm: float = DEFAULT_UNITS,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("groupBox")
|
||||
self.axis_count = int(axis_count)
|
||||
self._params = dict(params or {})
|
||||
self._units_per_mm = units_per_mm
|
||||
self._connected = False
|
||||
self._bus_ready = False
|
||||
self._available_axes = 0
|
||||
self._selected_axis = 0
|
||||
self._snapshot = Snapshot()
|
||||
self._rows: list[dict] = []
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
outer.setSpacing(6)
|
||||
|
||||
outer.addWidget(self._build_params())
|
||||
outer.addWidget(self._build_target_row())
|
||||
# Stretch factor 1: the table is the only part of this panel that may
|
||||
# grow, so every spare pixel goes to a further axis row rather than to
|
||||
# padding around the parameter form.
|
||||
outer.addWidget(self._build_table(), 1)
|
||||
outer.addWidget(self._build_actions())
|
||||
|
||||
self.retranslate()
|
||||
self._reread_params()
|
||||
# Controls start disabled and a snapshot enables them. Qt's default for
|
||||
# a new widget is enabled, and whether a poll happens to arrive before
|
||||
# the window is shown is a race — so the safe state is set explicitly
|
||||
# here rather than inferred from the first snapshot.
|
||||
self._refresh_availability()
|
||||
|
||||
# ------------------------------------------------------------------ widgets
|
||||
|
||||
def _build_params(self) -> QWidget:
|
||||
box = QWidget(self)
|
||||
grid = QGridLayout(box)
|
||||
grid.setContentsMargins(0, 0, 0, 0)
|
||||
grid.setHorizontalSpacing(8)
|
||||
grid.setVerticalSpacing(4)
|
||||
|
||||
def label(key: str) -> QLabel:
|
||||
widget = QLabel(box)
|
||||
widget.setObjectName("hintLabel")
|
||||
return widget
|
||||
|
||||
def edit(width: int) -> QLineEdit:
|
||||
widget = QLineEdit(box)
|
||||
widget.setFixedWidth(width)
|
||||
widget.setFixedHeight(FIELD_HEIGHT)
|
||||
return widget
|
||||
|
||||
self.axis_label = label("axes.axis")
|
||||
self.axis_combo = QWidget(box)
|
||||
self.axis_index_label = QLabel("0", box)
|
||||
self.axis_index_label.setObjectName("statusValue")
|
||||
|
||||
self.units_label = label("axes.units")
|
||||
self.units_edit = edit(80)
|
||||
self.speed_label = label("axes.speed")
|
||||
self.speed_edit = edit(80)
|
||||
self.accel_label = label("axes.accel")
|
||||
self.accel_edit = edit(90)
|
||||
|
||||
self.home_mode_label = label("axes.home_mode")
|
||||
self.home_mode_edit = edit(60)
|
||||
self.home_high_label = label("axes.home_high")
|
||||
self.home_high_edit = edit(60)
|
||||
self.home_low_label = label("axes.home_low")
|
||||
self.home_low_edit = edit(60)
|
||||
self.home_offset_label = label("axes.home_offset")
|
||||
self.home_offset_edit = edit(60)
|
||||
|
||||
self.apply_button = QPushButton(box)
|
||||
self.apply_button.setObjectName("primaryButton")
|
||||
self.apply_button.setProperty("rail", True)
|
||||
self.apply_button.setFixedHeight(FIELD_HEIGHT)
|
||||
self.apply_button.clicked.connect(self._on_apply)
|
||||
self.home_button = QPushButton(box)
|
||||
self.home_button.setProperty("rail", True)
|
||||
self.home_button.setFixedHeight(FIELD_HEIGHT)
|
||||
self.home_button.clicked.connect(lambda: self.homeRequested.emit(self.selected_axis))
|
||||
self.clear_alarm_button = QPushButton(box)
|
||||
self.clear_alarm_button.setProperty("rail", True)
|
||||
self.clear_alarm_button.setFixedHeight(FIELD_HEIGHT)
|
||||
self.clear_alarm_button.clicked.connect(
|
||||
lambda: self.clearAlarmRequested.emit(self.selected_axis)
|
||||
)
|
||||
|
||||
grid.addWidget(self.axis_label, 0, 0)
|
||||
grid.addWidget(self.axis_index_label, 0, 1)
|
||||
grid.addWidget(self.units_label, 0, 2)
|
||||
grid.addWidget(self.units_edit, 0, 3)
|
||||
grid.addWidget(self.speed_label, 0, 4)
|
||||
grid.addWidget(self.speed_edit, 0, 5)
|
||||
grid.addWidget(self.accel_label, 0, 6)
|
||||
grid.addWidget(self.accel_edit, 0, 7)
|
||||
|
||||
grid.addWidget(self.home_mode_label, 1, 0)
|
||||
grid.addWidget(self.home_mode_edit, 1, 1)
|
||||
grid.addWidget(self.home_high_label, 1, 2)
|
||||
grid.addWidget(self.home_high_edit, 1, 3)
|
||||
grid.addWidget(self.home_low_label, 1, 4)
|
||||
grid.addWidget(self.home_low_edit, 1, 5)
|
||||
grid.addWidget(self.home_offset_label, 1, 6)
|
||||
grid.addWidget(self.home_offset_edit, 1, 7)
|
||||
|
||||
grid.addWidget(self.apply_button, 2, 0, 1, 2)
|
||||
grid.addWidget(self.home_button, 2, 2, 1, 2)
|
||||
grid.addWidget(self.clear_alarm_button, 2, 4, 1, 2)
|
||||
return box
|
||||
|
||||
def _build_target_row(self) -> QWidget:
|
||||
box = QWidget(self)
|
||||
row = QHBoxLayout(box)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(8)
|
||||
self.target_label = QLabel(box)
|
||||
self.target_edit = QLineEdit(box)
|
||||
self.target_edit.setFixedWidth(120)
|
||||
self.move_abs_button = QPushButton(box)
|
||||
self.move_abs_button.clicked.connect(self._on_move_abs)
|
||||
self.move_rel_button = QPushButton(box)
|
||||
self.move_rel_button.clicked.connect(self._on_move_rel)
|
||||
self.mm_hint = QLabel(box)
|
||||
self.mm_hint.setObjectName("hintLabel")
|
||||
self.mm_hint.setWordWrap(True)
|
||||
row.addWidget(self.target_label)
|
||||
row.addWidget(self.target_edit)
|
||||
row.addWidget(self.move_abs_button)
|
||||
row.addWidget(self.move_rel_button)
|
||||
row.addStretch(1)
|
||||
row.addWidget(self.mm_hint)
|
||||
return box
|
||||
|
||||
def _build_table(self) -> QWidget:
|
||||
self.table = QTableWidget(self.axis_count, len(COLUMNS) + 1, self)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
||||
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self.table.setAlternatingRowColors(True)
|
||||
for column in range(len(COLUMNS)):
|
||||
self.table.setColumnWidth(column, _COLUMN_WIDTHS.get(column, 84))
|
||||
self.table.setColumnWidth(ACTIONS_COLUMN, _COLUMN_WIDTHS[ACTIONS_COLUMN])
|
||||
# The per-row buttons live in a cell widget, and Qt does not size a row
|
||||
# to its cell widgets. Without an explicit section height the row stays
|
||||
# at the font height and the buttons are clipped.
|
||||
self.table.verticalHeader().setDefaultSectionSize(ROW_HEIGHT)
|
||||
# A minimum rather than the full five rows: demanding 5*ROW_HEIGHT plus a
|
||||
# header forces the panel to be taller than its share of a 900 px window,
|
||||
# and the splitter then clips the last drive — which reads as a missing
|
||||
# axis. The table scrolls if it is ever squeezed below this.
|
||||
self.table.setMinimumHeight(40 + ROW_HEIGHT * MIN_VISIBLE_ROWS)
|
||||
self.table.cellClicked.connect(self._on_cell_clicked)
|
||||
|
||||
for row in range(self.axis_count):
|
||||
self._rows.append(self._build_row(row))
|
||||
return self.table
|
||||
|
||||
def _build_row(self, row: int) -> dict:
|
||||
for column in range(len(COLUMNS)):
|
||||
item = QTableWidgetItem("")
|
||||
if column >= 2:
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.table.setItem(row, column, item)
|
||||
|
||||
enable_button = QPushButton(t("axes.enable_off"), self.table)
|
||||
enable_button.setCheckable(True)
|
||||
enable_button.setProperty("rail", True)
|
||||
enable_button.setMinimumWidth(84)
|
||||
enable_button.setFixedHeight(28)
|
||||
enable_button.clicked.connect(
|
||||
lambda checked, axis=row: self.enableToggled.emit(axis, bool(checked))
|
||||
)
|
||||
self.table.setCellWidget(row, 1, enable_button)
|
||||
|
||||
controls = QWidget(self.table)
|
||||
layout = QHBoxLayout(controls)
|
||||
layout.setContentsMargins(2, 2, 2, 2)
|
||||
layout.setSpacing(4)
|
||||
|
||||
def make(key: str, slot) -> QPushButton:
|
||||
button = QPushButton(t(key), controls)
|
||||
# "rail" is the theme's compact button: without it the shared
|
||||
# stylesheet forces 44 px, which no longer fits the row.
|
||||
button.setProperty("rail", True)
|
||||
button.setMinimumWidth(56)
|
||||
button.setFixedHeight(28)
|
||||
button.clicked.connect(lambda _checked=False, a=row: slot(a))
|
||||
layout.addWidget(button)
|
||||
return button
|
||||
|
||||
self.table.setCellWidget(row, ACTIONS_COLUMN, controls)
|
||||
return {
|
||||
"enable_button": enable_button,
|
||||
"fwd_button": make("axes.jog_fwd", lambda axis: self.jogRequested.emit(axis, True)),
|
||||
"rev_button": make("axes.jog_rev", lambda axis: self.jogRequested.emit(axis, False)),
|
||||
"stop_button": make("axes.stop", self.stopRequested.emit),
|
||||
}
|
||||
|
||||
def _build_actions(self) -> QWidget:
|
||||
box = QWidget(self)
|
||||
column = QVBoxLayout(box)
|
||||
column.setContentsMargins(0, 0, 0, 0)
|
||||
column.setSpacing(4)
|
||||
|
||||
self._detail_label = QLabel(t("axes.detail_none", axis=0), box)
|
||||
self._detail_label.setObjectName("hintLabel")
|
||||
self._detail_label.setWordWrap(True)
|
||||
column.addWidget(self._detail_label)
|
||||
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(8)
|
||||
self.stop_all_button = QPushButton(box)
|
||||
self.stop_all_button.setObjectName("dangerButton")
|
||||
self.stop_all_button.clicked.connect(self.stopAllRequested)
|
||||
self.enabled_count_label = QLabel(box)
|
||||
self.enabled_count_label.setObjectName("hintLabel")
|
||||
row.addWidget(self.stop_all_button)
|
||||
row.addStretch(1)
|
||||
row.addWidget(self.enabled_count_label)
|
||||
column.addLayout(row)
|
||||
return box
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
@property
|
||||
def selected_axis(self) -> int:
|
||||
"""The axis the parameter form applies to."""
|
||||
return self._selected_axis
|
||||
|
||||
def set_selected_axis(self, axis: int) -> None:
|
||||
self._selected_axis = max(0, min(int(axis), self.axis_count - 1))
|
||||
self.axis_index_label.setText(str(self._selected_axis))
|
||||
self._reread_params()
|
||||
if hasattr(self, "_detail_label"):
|
||||
self._detail_label.setText(self._detail_text())
|
||||
|
||||
def set_connected(self, connected: bool) -> None:
|
||||
self._connected = bool(connected)
|
||||
self._refresh_availability()
|
||||
|
||||
def set_axis_count(self, count: int) -> None:
|
||||
"""Dim rows for axes the bus did not report (a missing drive)."""
|
||||
self._available_axes = max(1, min(int(count), self.axis_count))
|
||||
self._refresh_availability()
|
||||
self._recolour_rows()
|
||||
|
||||
def _axis_present(self, row: int) -> bool:
|
||||
return not self._available_axes or row < self._available_axes
|
||||
|
||||
def _refresh_availability(self) -> None:
|
||||
"""Recompute every control's enabled state from connection + bus."""
|
||||
connected = self._connected
|
||||
for widget in (
|
||||
self.units_edit,
|
||||
self.speed_edit,
|
||||
self.accel_edit,
|
||||
self.home_mode_edit,
|
||||
self.home_high_edit,
|
||||
self.home_low_edit,
|
||||
self.home_offset_edit,
|
||||
self.target_edit,
|
||||
self.apply_button,
|
||||
self.home_button,
|
||||
self.clear_alarm_button,
|
||||
self.move_abs_button,
|
||||
self.move_rel_button,
|
||||
self.stop_all_button,
|
||||
):
|
||||
widget.setEnabled(connected)
|
||||
|
||||
for row, widgets in enumerate(self._rows):
|
||||
present = self._axis_present(row)
|
||||
widgets["enable_button"].setEnabled(connected and present)
|
||||
widgets["stop_button"].setEnabled(connected and present)
|
||||
# Jogging additionally requires an initialised bus.
|
||||
for key in ("fwd_button", "rev_button"):
|
||||
widgets[key].setEnabled(connected and present and self._bus_ready)
|
||||
|
||||
def _recolour_rows(self) -> None:
|
||||
"""Dim the data cells of axes the bus did not report."""
|
||||
for row in range(self.axis_count):
|
||||
present = self._axis_present(row)
|
||||
for column in range(len(COLUMNS)):
|
||||
item = self.table.item(row, column)
|
||||
if item is not None:
|
||||
item.setForeground(_row_brush(present))
|
||||
|
||||
def update_snapshot(self, snapshot: Snapshot) -> None:
|
||||
"""Render one poll: values, motion state, and the enable buttons."""
|
||||
self._connected = snapshot.connected
|
||||
bus_axes = int(snapshot.bus.total_axes)
|
||||
if bus_axes:
|
||||
self._available_axes = max(1, min(bus_axes, self.axis_count))
|
||||
self._bus_ready = snapshot.bus.initialized
|
||||
|
||||
for row in range(self.axis_count):
|
||||
state = snapshot.axis(row)
|
||||
if state is None:
|
||||
continue
|
||||
values = {
|
||||
0: str(row),
|
||||
1: t("axes.enable_on") if state.enabled else t("axes.enable_off"),
|
||||
2: _number(state.units, 3),
|
||||
3: _number(state.speed, 1),
|
||||
4: _number(state.dpos, 3),
|
||||
5: _millimetres(state.units, state.dpos),
|
||||
6: _status_text(state),
|
||||
}
|
||||
for column, text in values.items():
|
||||
item = self.table.item(row, column)
|
||||
if item is not None:
|
||||
item.setText(text)
|
||||
button = self._rows[row]["enable_button"]
|
||||
button.setChecked(bool(state.enabled))
|
||||
button.setText(t("axes.enable_on") if state.enabled else t("axes.enable_off"))
|
||||
if state.status.home_in_progress:
|
||||
button.setToolTip(t("axes.home_running"))
|
||||
elif state.status.alarm:
|
||||
button.setToolTip(t("action.failed"))
|
||||
else:
|
||||
button.setToolTip("")
|
||||
|
||||
self._snapshot = snapshot
|
||||
detail = self._detail_label
|
||||
detail.setText(self._detail_text())
|
||||
enabled = sum(
|
||||
1
|
||||
for row in range(self.axis_count)
|
||||
if (snapshot.axis(row) is not None and snapshot.axis(row).enabled)
|
||||
)
|
||||
self.enabled_count_label.setText(
|
||||
t("axes.enabled_count", on=enabled, total=self.axis_count)
|
||||
)
|
||||
self._refresh_availability()
|
||||
self._recolour_rows()
|
||||
|
||||
def _detail_text(self) -> str:
|
||||
"""Everything about the selected axis that the seven columns cannot hold.
|
||||
|
||||
The table is deliberately narrow enough to read; this line is where the
|
||||
drive's full frame goes — axis type, acceleration, feedback position, the
|
||||
raw status word, and homing.
|
||||
"""
|
||||
state = self._snapshot.axis(self._selected_axis)
|
||||
if state is None:
|
||||
return t("axes.detail_none", axis=self._selected_axis)
|
||||
return t(
|
||||
"axes.detail",
|
||||
axis=self._selected_axis,
|
||||
atype=state.atype,
|
||||
accel=_number(state.accel, 1),
|
||||
mpos=_number(state.mpos, 3),
|
||||
mpos_mm=_millimetres(state.units, state.mpos),
|
||||
status=state.status.raw,
|
||||
home=_home_text(state),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- actions
|
||||
|
||||
def _on_cell_clicked(self, row: int, _column: int) -> None:
|
||||
self.set_selected_axis(row)
|
||||
|
||||
def _reread_params(self) -> None:
|
||||
params = self._params.get(self._selected_axis, AxisParams())
|
||||
self.units_edit.setText(_number(params.units, 3))
|
||||
self.speed_edit.setText(_number(params.speed, 1))
|
||||
self.accel_edit.setText(_number(params.accel, 1))
|
||||
self.home_mode_edit.setText(str(int(params.home_mode)))
|
||||
self.home_high_edit.setText(_number(params.home_high, 1))
|
||||
self.home_low_edit.setText(_number(params.home_low, 1))
|
||||
self.home_offset_edit.setText(_number(params.home_offset, 3))
|
||||
|
||||
def current_params(self) -> AxisParams:
|
||||
"""The form's values, with unusable entries replaced by defaults."""
|
||||
return AxisParams(
|
||||
units=parse_optional_float(self.units_edit.text(), DEFAULT_UNITS),
|
||||
speed=parse_optional_float(self.speed_edit.text(), DEFAULT_SPEED),
|
||||
accel=parse_optional_float(self.accel_edit.text(), DEFAULT_ACCEL),
|
||||
creep=parse_optional_float(self.home_low_edit.text(), 10.0),
|
||||
home_mode=int(parse_optional_float(self.home_mode_edit.text(), 0)),
|
||||
home_high=parse_optional_float(self.home_high_edit.text(), 50.0),
|
||||
home_low=parse_optional_float(self.home_low_edit.text(), 10.0),
|
||||
home_offset=parse_optional_float(self.home_offset_edit.text(), 0.0),
|
||||
)
|
||||
|
||||
def stored_params(self) -> dict[int, AxisParams]:
|
||||
"""Every axis's parameters, for persistence."""
|
||||
return dict(self._params)
|
||||
|
||||
def _on_apply(self) -> None:
|
||||
params = self.current_params()
|
||||
self._params[self._selected_axis] = params
|
||||
self.paramsApplied.emit(self._selected_axis, params)
|
||||
|
||||
def _on_move_abs(self) -> None:
|
||||
value = self._target_value()
|
||||
if value is not None:
|
||||
self.moveAbsRequested.emit(self.selected_axis, value)
|
||||
|
||||
def _on_move_rel(self) -> None:
|
||||
value = self._target_value()
|
||||
if value is not None:
|
||||
self.moveRelRequested.emit(self.selected_axis, value)
|
||||
|
||||
def _target_value(self) -> float | None:
|
||||
text = self.target_edit.text().strip()
|
||||
if text == "":
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(t("axes.title"))
|
||||
self.axis_label.setText(t("axes.axis"))
|
||||
self.units_label.setText(t("axes.units"))
|
||||
self.speed_label.setText(t("axes.speed"))
|
||||
self.accel_label.setText(t("axes.accel"))
|
||||
self.home_mode_label.setText(t("axes.home_mode"))
|
||||
self.home_high_label.setText(t("axes.home_high"))
|
||||
self.home_low_label.setText(t("axes.home_low"))
|
||||
self.home_offset_label.setText(t("axes.home_offset"))
|
||||
self.apply_button.setText(t("axes.apply"))
|
||||
self.home_button.setText(t("axes.home"))
|
||||
self.clear_alarm_button.setText(t("axes.clear_alarm"))
|
||||
self.target_label.setText(t("axes.target"))
|
||||
self.move_abs_button.setText(t("axes.move_abs"))
|
||||
self.move_rel_button.setText(t("axes.move_rel"))
|
||||
self.stop_all_button.setText(t("axes.stop_all"))
|
||||
self.mm_hint.setText(t("axes.mm_derived_tip"))
|
||||
if hasattr(self, "_detail_label"):
|
||||
self._detail_label.setText(self._detail_text())
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
[t(key) for key in COLUMNS] + [t("axes.controls")]
|
||||
)
|
||||
for row in range(self.axis_count):
|
||||
button = self._rows[row]["enable_button"]
|
||||
button.setText(t("axes.enable_on") if button.isChecked() else t("axes.enable_off"))
|
||||
self._rows[row]["fwd_button"].setText(t("axes.jog_fwd"))
|
||||
self._rows[row]["rev_button"].setText(t("axes.jog_rev"))
|
||||
self._rows[row]["stop_button"].setText(t("axes.stop"))
|
||||
|
||||
|
||||
def _number(value: float, places: int) -> str:
|
||||
try:
|
||||
return f"{float(value):.{places}f}"
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
|
||||
def _millimetres(units: float, user_units: float) -> str:
|
||||
"""The derived millimetre reading, or a dash when it cannot be derived."""
|
||||
if not units:
|
||||
return "—"
|
||||
millimetres = user_units_to_mm(user_units, units=units)
|
||||
if millimetres != millimetres: # NaN
|
||||
return "—"
|
||||
return f"{millimetres:.3f}"
|
||||
|
||||
|
||||
def _home_text(state: AxisState) -> str:
|
||||
if state.status.home_in_progress:
|
||||
return t("axes.home_running")
|
||||
return t("axes.home_ok") if state.home_status == 1 else t("axes.home_pending")
|
||||
|
||||
|
||||
def _status_text(state: AxisState) -> str:
|
||||
"""One column that says what an operator needs to know at a glance.
|
||||
|
||||
Alarm outranks motion outranks homing, because that is the order in which
|
||||
they matter when you are looking at the row for a second.
|
||||
"""
|
||||
if state.status.alarm:
|
||||
return t("status.alarm_short")
|
||||
if not state.idle:
|
||||
return t("axes.moving")
|
||||
return _home_text(state)
|
||||
|
||||
|
||||
def _row_brush(present: bool) -> QBrush:
|
||||
"""Dim a row whose drive the bus did not report."""
|
||||
return QBrush(QColor("#d0d0d0" if present else "#8a8a8a"))
|
||||
@@ -0,0 +1,170 @@
|
||||
"""The permanent axis readout rail.
|
||||
|
||||
Read-only by design. Every per-axis *command* lives in the Axis-debug tab, so
|
||||
there is exactly one place a move can be issued from — two places to jog from is
|
||||
how an operator moves the wrong axis. What this rail adds is the thing the
|
||||
operator needs while working in any other tab: whether each drive is enabled,
|
||||
where it is, and whether it is moving.
|
||||
|
||||
The three global actions are here rather than in the tab because they are the
|
||||
ones you reach for in a hurry, and they must not require finding a tab first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
|
||||
from ..motion import Snapshot, user_units_to_mm
|
||||
|
||||
#: Columns of the compact readout: axis, enable state, position.
|
||||
_COLUMNS = 3
|
||||
|
||||
#: Tone -> colour token, as elsewhere.
|
||||
_TONE_TOKENS = {"ok": "accent", "warn": "warn", "error": "danger", "muted": "text_muted"}
|
||||
|
||||
|
||||
class AxisReadoutPanel(QGroupBox):
|
||||
"""Five axis rows plus the platform-wide actions."""
|
||||
|
||||
enableAllRequested = Signal(bool)
|
||||
stopAllRequested = Signal()
|
||||
clearAllRequested = Signal()
|
||||
|
||||
def __init__(self, axis_count: int, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("groupBox")
|
||||
self.axis_count = int(axis_count)
|
||||
self._theme = theme
|
||||
self._connected = False
|
||||
self._snapshot = Snapshot()
|
||||
self._rows: list[dict] = []
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
outer.setSpacing(6)
|
||||
|
||||
grid = QGridLayout()
|
||||
grid.setContentsMargins(0, 0, 0, 0)
|
||||
grid.setHorizontalSpacing(8)
|
||||
grid.setVerticalSpacing(4)
|
||||
for row in range(self.axis_count):
|
||||
grid.addWidget(self._build_row(row), row, 0)
|
||||
outer.addLayout(grid)
|
||||
|
||||
outer.addStretch(1)
|
||||
|
||||
self.enable_all_button = QPushButton(self)
|
||||
self.enable_all_button.clicked.connect(self._on_enable_all)
|
||||
self.stop_all_button = QPushButton(self)
|
||||
self.stop_all_button.setObjectName("dangerButton")
|
||||
self.stop_all_button.clicked.connect(self.stopAllRequested)
|
||||
self.clear_all_button = QPushButton(self)
|
||||
self.clear_all_button.clicked.connect(self.clearAllRequested)
|
||||
for button in (self.enable_all_button, self.stop_all_button, self.clear_all_button):
|
||||
button.setProperty("rail", True)
|
||||
outer.addWidget(button)
|
||||
|
||||
self.retranslate()
|
||||
self.set_connected(False)
|
||||
|
||||
def _build_row(self, axis: int) -> QWidget:
|
||||
box = QWidget(self)
|
||||
row = QHBoxLayout(box)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(6)
|
||||
|
||||
index = QLabel(str(axis), box)
|
||||
index.setObjectName("axisIndex")
|
||||
index.setFixedWidth(16)
|
||||
|
||||
state = QLabel("", box)
|
||||
state.setObjectName("axisState")
|
||||
state.setFixedWidth(46)
|
||||
|
||||
position = QLabel("", box)
|
||||
position.setObjectName("axisPosition")
|
||||
|
||||
row.addWidget(index)
|
||||
row.addWidget(state)
|
||||
row.addWidget(position, 1)
|
||||
self._rows.append({"state": state, "position": position})
|
||||
return box
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
self.update_snapshot(self._snapshot)
|
||||
|
||||
def set_connected(self, connected: bool) -> None:
|
||||
self._connected = bool(connected)
|
||||
for button in (self.enable_all_button, self.stop_all_button, self.clear_all_button):
|
||||
button.setEnabled(self._connected)
|
||||
|
||||
def update_snapshot(self, snapshot: Snapshot) -> None:
|
||||
self._snapshot = snapshot
|
||||
self.set_connected(snapshot.connected)
|
||||
|
||||
for axis in range(self.axis_count):
|
||||
state = snapshot.axis(axis)
|
||||
widgets = self._rows[axis]
|
||||
if state is None:
|
||||
widgets["state"].setText(t("axes.enable_off"))
|
||||
widgets["position"].setText("—")
|
||||
widgets["state"].setStyleSheet(self._colour("muted"))
|
||||
continue
|
||||
|
||||
if state.status.alarm:
|
||||
widgets["state"].setText(t("status.alarm_short"))
|
||||
widgets["state"].setStyleSheet(self._colour("error"))
|
||||
elif state.enabled:
|
||||
widgets["state"].setText(t("axes.enable_on"))
|
||||
widgets["state"].setStyleSheet(self._colour("ok"))
|
||||
else:
|
||||
widgets["state"].setText(t("axes.enable_off"))
|
||||
widgets["state"].setStyleSheet(self._colour("muted"))
|
||||
|
||||
millimetres = user_units_to_mm(state.dpos, units=state.units) if state.units else None
|
||||
units_text = f"{state.dpos:.3f}"
|
||||
if millimetres is not None and millimetres == millimetres:
|
||||
units_text = t("axes.position_pair", value=units_text, mm=f"{millimetres:.3f}")
|
||||
widgets["position"].setText(units_text)
|
||||
widgets["position"].setToolTip(
|
||||
t("axes.motion_word") + ": " + (t("axes.moving") if not state.idle else t("axes.idle"))
|
||||
+ (" · " + t("axes.home_ok") if state.home_status == 1 else "")
|
||||
)
|
||||
|
||||
enabled_now = sum(1 for s in snapshot.axes if s.enabled)
|
||||
self.enable_all_button.setText(
|
||||
t("axes.disable_all") if enabled_now == self.axis_count else t("axes.enable_all")
|
||||
)
|
||||
|
||||
def _colour(self, tone: str) -> str:
|
||||
colour = load_colors(self._theme).get(_TONE_TOKENS.get(tone, "text_muted"), "#888888")
|
||||
return f"color: {colour};"
|
||||
|
||||
def _on_enable_all(self) -> None:
|
||||
enabled_now = sum(1 for s in self._snapshot.axes if s.enabled)
|
||||
# Toggle: if every drive is already on, this turns them all off.
|
||||
self.enableAllRequested.emit(enabled_now != self.axis_count)
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(t("readout.title"))
|
||||
self.stop_all_button.setText(t("axes.stop_all"))
|
||||
self.clear_all_button.setText(t("axes.clear_alarm_all"))
|
||||
# The toggle's label depends on current state, so re-derive it.
|
||||
self.update_snapshot(self._snapshot)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Top identity strip: brand, product name, and the connection state chip.
|
||||
|
||||
Mirrors the studio's brand bar so the two products look like one family, but
|
||||
carries only what this tool needs: who made it, what it is, and whether the
|
||||
controller is reachable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
from orisys_app_shared.theme import load_colors
|
||||
|
||||
#: Chip tone -> colour token name.
|
||||
_TONE_TOKENS = {
|
||||
"ok": "accent",
|
||||
"warn": "warn",
|
||||
"error": "danger",
|
||||
"muted": "text_muted",
|
||||
}
|
||||
|
||||
|
||||
class BrandBar(QFrame):
|
||||
"""The identity strip, with a state chip that reports the connection."""
|
||||
|
||||
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("brandBar")
|
||||
self.setFixedHeight(40)
|
||||
self._theme = theme
|
||||
self._connected = False
|
||||
# The chip's current content, kept so a theme or locale change can
|
||||
# re-render the same state without the caller re-stating it.
|
||||
self._chip_text = ""
|
||||
self._chip_tone = "muted"
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(12, 4, 12, 4)
|
||||
layout.setSpacing(10)
|
||||
|
||||
self.logo = QLabel(self)
|
||||
self.logo.setObjectName("brandLogo")
|
||||
pixmap = QPixmap(str(logo_topbar_path()))
|
||||
if not pixmap.isNull():
|
||||
self.logo.setPixmap(
|
||||
pixmap.scaledToHeight(24, Qt.TransformationMode.SmoothTransformation)
|
||||
)
|
||||
layout.addWidget(self.logo)
|
||||
|
||||
self.name = QLabel(self)
|
||||
self.name.setObjectName("brandName")
|
||||
layout.addWidget(self.name)
|
||||
|
||||
self.detail = QLabel(self)
|
||||
self.detail.setObjectName("brandDetail")
|
||||
layout.addWidget(self.detail)
|
||||
|
||||
layout.addStretch(1)
|
||||
|
||||
self.product = QLabel(self)
|
||||
self.product.setObjectName("brandProduct")
|
||||
layout.addWidget(self.product)
|
||||
|
||||
self.state_chip = QLabel(self)
|
||||
self.state_chip.setObjectName("stateChip")
|
||||
layout.addWidget(self.state_chip)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
# ----------------------------------------------------------------- content
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Refresh every label from the active locale."""
|
||||
self.name.setText(t("brand.name"))
|
||||
self.detail.setText(t("brand.detail"))
|
||||
self.product.setText(t("app.title"))
|
||||
self.set_connected(self._connected)
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
self.set_state_text(self._chip_text, self._chip_tone)
|
||||
|
||||
def set_connected(self, connected: bool) -> None:
|
||||
self._connected = bool(connected)
|
||||
self.set_state_text(
|
||||
t("connection.state_open") if connected else t("connection.state_closed"),
|
||||
"ok" if connected else "muted",
|
||||
)
|
||||
|
||||
def set_state_text(self, text: str, tone: str = "muted") -> None:
|
||||
self._chip_text = text
|
||||
self._chip_tone = tone
|
||||
self.state_chip.setText(text)
|
||||
token = _TONE_TOKENS.get(tone, "text_muted")
|
||||
color = load_colors(self._theme).get(token, "#888888")
|
||||
self.state_chip.setStyleSheet(
|
||||
f"color: {color}; border: 1px solid {color}; "
|
||||
"border-radius: 9px; padding: 1px 8px;"
|
||||
)
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Raw bus exchange: EtherCAT object dictionary (SDO) and RTEX parameters.
|
||||
|
||||
This is the drive-level panel. It is what a commissioning engineer reaches for
|
||||
when a node behaves oddly: read an object, see what the drive actually has, write
|
||||
one back. The controls are enabled strictly by the bus family the ``.bas``
|
||||
program reported, so an SDO write can never be aimed at an RTEX system (the
|
||||
vendor reference guards this too, by checking ``BUS_TYPE`` before every call).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
#: Height of this panel's fields and buttons. Pinned for the same reason as the
|
||||
#: axis panel's: an unconstrained QLineEdit is ~34 px, and four of them stacked
|
||||
#: push this panel tall enough to steal an axis row from the panel above it.
|
||||
FIELD_HEIGHT = 28
|
||||
|
||||
#: Width of the read/write buttons. A grid stretches a widget across the column
|
||||
#: it spans, and these share a row with unconstrained columns, so without a cap
|
||||
#: "读" renders as a 300 px slab.
|
||||
ACTION_WIDTH = 96
|
||||
|
||||
#: Data types accepted by ``ZAux_BusCmd_SDORead``/``SDOWrite``, in combo order.
|
||||
#: The DLL argument is the index + 1 (see ``sdo_data_type``).
|
||||
SDO_TYPES = (
|
||||
("int8", 1),
|
||||
("int16", 2),
|
||||
("int32", 3),
|
||||
("uint8", 4),
|
||||
("uint16", 5),
|
||||
("uint32", 6),
|
||||
)
|
||||
|
||||
|
||||
def sdo_data_type(index: int) -> int:
|
||||
"""Map a combo index to the DLL's data-type argument."""
|
||||
if 0 <= index < len(SDO_TYPES):
|
||||
return SDO_TYPES[index][1]
|
||||
return SDO_TYPES[0][1]
|
||||
|
||||
|
||||
class BusDataPanel(QGroupBox):
|
||||
"""SDO and RTEX read/write."""
|
||||
|
||||
sdoReadRequested = Signal(int, int, int, int)
|
||||
sdoWriteRequested = Signal(int, int, int, int, int)
|
||||
rtexReadRequested = Signal(int, int)
|
||||
rtexWriteRequested = Signal(int, int, float)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sdo_last: dict | None = None,
|
||||
rtex_last: dict | None = None,
|
||||
axis_count: int = 5,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("groupBox")
|
||||
self._axis_count = int(axis_count)
|
||||
sdo_last = sdo_last or {}
|
||||
rtex_last = rtex_last or {}
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
outer.setSpacing(8)
|
||||
|
||||
outer.addWidget(self._build_sdo(sdo_last))
|
||||
outer.addWidget(self._build_rtex(rtex_last))
|
||||
|
||||
self.result_label = QLabel(self)
|
||||
self.result_label.setObjectName("hintLabel")
|
||||
self.result_label.setWordWrap(True)
|
||||
outer.addWidget(self.result_label)
|
||||
|
||||
self.retranslate()
|
||||
# Disabled until a snapshot says otherwise: a read or write control that
|
||||
# is briefly live on a disconnected panel invites a click that cannot
|
||||
# work, and Qt's default for a new widget is enabled.
|
||||
self.set_bus_type("unknown")
|
||||
self._refresh_readonly()
|
||||
|
||||
# ------------------------------------------------------------------ widgets
|
||||
|
||||
def _build_sdo(self, last: dict) -> QWidget:
|
||||
self.ecat_box = QGroupBox(self)
|
||||
grid = QGridLayout(self.ecat_box)
|
||||
grid.setContentsMargins(10, 6, 10, 6)
|
||||
grid.setHorizontalSpacing(8)
|
||||
grid.setVerticalSpacing(4)
|
||||
|
||||
def field(text: str, width: int) -> QLineEdit:
|
||||
widget = QLineEdit(str(text), self.ecat_box)
|
||||
widget.setFixedWidth(width)
|
||||
widget.setFixedHeight(FIELD_HEIGHT)
|
||||
return widget
|
||||
|
||||
def button(text: str) -> QPushButton:
|
||||
widget = QPushButton(text, self.ecat_box)
|
||||
widget.setProperty("rail", True)
|
||||
widget.setFixedHeight(FIELD_HEIGHT)
|
||||
widget.setMaximumWidth(ACTION_WIDTH)
|
||||
return widget
|
||||
|
||||
self.node_label = QLabel(self.ecat_box)
|
||||
self.node_edit = field(last.get("node", 0), 60)
|
||||
self.index_label = QLabel(self.ecat_box)
|
||||
self.index_edit = field(last.get("index", 24704), 80)
|
||||
self.subindex_label = QLabel(self.ecat_box)
|
||||
self.subindex_edit = field(last.get("subindex", 0), 50)
|
||||
self.sdo_type_label = QLabel(self.ecat_box)
|
||||
self.sdo_type_combo = QComboBox(self.ecat_box)
|
||||
self.sdo_type_combo.setFixedHeight(FIELD_HEIGHT)
|
||||
for name, _value in SDO_TYPES:
|
||||
self.sdo_type_combo.addItem(name)
|
||||
self.sdo_type_combo.setCurrentIndex(int(last.get("type", 0)) % len(SDO_TYPES))
|
||||
self.sdo_value_label = QLabel(self.ecat_box)
|
||||
self.sdo_value_edit = field(0, 90)
|
||||
self.btn_sdo_read = button("")
|
||||
self.btn_sdo_read.clicked.connect(self._emit_sdo_read)
|
||||
self.btn_sdo_write = button("")
|
||||
self.btn_sdo_write.clicked.connect(self._emit_sdo_write)
|
||||
|
||||
grid.addWidget(self.node_label, 0, 0)
|
||||
grid.addWidget(self.node_edit, 0, 1)
|
||||
grid.addWidget(self.index_label, 0, 2)
|
||||
grid.addWidget(self.index_edit, 0, 3)
|
||||
grid.addWidget(self.subindex_label, 0, 4)
|
||||
grid.addWidget(self.subindex_edit, 0, 5)
|
||||
grid.addWidget(self.sdo_type_label, 0, 6)
|
||||
grid.addWidget(self.sdo_type_combo, 0, 7)
|
||||
grid.addWidget(self.sdo_value_label, 0, 8)
|
||||
grid.addWidget(self.sdo_value_edit, 0, 9)
|
||||
grid.addWidget(self.btn_sdo_read, 1, 7)
|
||||
grid.addWidget(self.btn_sdo_write, 1, 9)
|
||||
grid.setColumnStretch(10, 1)
|
||||
return self.ecat_box
|
||||
|
||||
def _build_rtex(self, last: dict) -> QWidget:
|
||||
self.rtex_box = QGroupBox(self)
|
||||
grid = QGridLayout(self.rtex_box)
|
||||
grid.setContentsMargins(10, 6, 10, 6)
|
||||
grid.setHorizontalSpacing(8)
|
||||
grid.setVerticalSpacing(4)
|
||||
|
||||
def field(text: str, width: int) -> QLineEdit:
|
||||
widget = QLineEdit(str(text), self.rtex_box)
|
||||
widget.setFixedWidth(width)
|
||||
widget.setFixedHeight(FIELD_HEIGHT)
|
||||
return widget
|
||||
|
||||
def button(text: str) -> QPushButton:
|
||||
widget = QPushButton(text, self.rtex_box)
|
||||
widget.setProperty("rail", True)
|
||||
widget.setFixedHeight(FIELD_HEIGHT)
|
||||
return widget
|
||||
|
||||
self.rtex_axis_label = QLabel(self.rtex_box)
|
||||
self.rtex_axis_edit = field(last.get("axis", 0), 50)
|
||||
self.rtex_parameter_label = QLabel(self.rtex_box)
|
||||
self.rtex_parameter_edit = field(last.get("parameter", 0), 90)
|
||||
self.rtex_value_label = QLabel(self.rtex_box)
|
||||
self.rtex_value_edit = field(0, 90)
|
||||
self.btn_rtex_read = button("")
|
||||
self.btn_rtex_read.clicked.connect(self._emit_rtex_read)
|
||||
self.btn_rtex_write = button("")
|
||||
self.btn_rtex_write.clicked.connect(self._emit_rtex_write)
|
||||
|
||||
grid.addWidget(self.rtex_axis_label, 0, 0)
|
||||
grid.addWidget(self.rtex_axis_edit, 0, 1)
|
||||
grid.addWidget(self.rtex_parameter_label, 0, 2)
|
||||
grid.addWidget(self.rtex_parameter_edit, 0, 3)
|
||||
grid.addWidget(self.rtex_value_label, 0, 4)
|
||||
grid.addWidget(self.rtex_value_edit, 0, 5)
|
||||
grid.addWidget(self.btn_rtex_read, 0, 6)
|
||||
grid.addWidget(self.btn_rtex_write, 0, 7)
|
||||
grid.setColumnStretch(8, 1)
|
||||
return self.rtex_box
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
def set_bus_type(self, bus_type: str) -> None:
|
||||
"""Enable only the panel matching the bus the controller reported."""
|
||||
self._bus_type = bus_type
|
||||
supports_sdo = bus_type == "ecat"
|
||||
supports_rtex = bus_type == "rtex"
|
||||
self.ecat_box.setEnabled(supports_sdo)
|
||||
self.rtex_box.setEnabled(supports_rtex)
|
||||
self._refresh_readonly()
|
||||
|
||||
_bus_type = "unknown"
|
||||
|
||||
def set_connected(self, connected: bool) -> None:
|
||||
self._connected = bool(connected)
|
||||
self._refresh_readonly()
|
||||
|
||||
_connected = False
|
||||
|
||||
def _refresh_readonly(self) -> None:
|
||||
# Re-applying the bus type is what actually gates the boxes; a
|
||||
# disconnected session disables both regardless of family.
|
||||
if not self._connected:
|
||||
self.ecat_box.setEnabled(False)
|
||||
self.rtex_box.setEnabled(False)
|
||||
return
|
||||
self.ecat_box.setEnabled(self._bus_type == "ecat")
|
||||
self.rtex_box.setEnabled(self._bus_type == "rtex")
|
||||
|
||||
def set_result(self, text: str) -> None:
|
||||
self.result_label.setText(text)
|
||||
|
||||
def sdo_request(self) -> tuple[int, int, int, int]:
|
||||
"""The SDO fields as the service expects them."""
|
||||
return (
|
||||
self._int(self.node_edit.text()),
|
||||
self._int(self.index_edit.text()),
|
||||
self._int(self.subindex_edit.text()),
|
||||
sdo_data_type(self.sdo_type_combo.currentIndex()),
|
||||
)
|
||||
|
||||
def rtex_request(self) -> tuple[int, int]:
|
||||
return (
|
||||
self._int(self.rtex_axis_edit.text()),
|
||||
self._int(self.rtex_parameter_edit.text()),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _int(text: str) -> int:
|
||||
try:
|
||||
return int(str(text).strip() or 0, 0)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def remember_state(self) -> dict[str, dict]:
|
||||
"""The field values the preference file should keep."""
|
||||
node, index, subindex, data_type = self.sdo_request()
|
||||
axis, parameter = self.rtex_request()
|
||||
type_index = next(
|
||||
(i for i, (_name, value) in enumerate(SDO_TYPES) if value == data_type), 0
|
||||
)
|
||||
return {
|
||||
"sdo_last": {
|
||||
"node": node,
|
||||
"index": index,
|
||||
"subindex": subindex,
|
||||
"type": type_index,
|
||||
},
|
||||
"rtex_last": {"axis": axis, "parameter": parameter},
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------- actions
|
||||
|
||||
def _emit_sdo_read(self) -> None:
|
||||
self.sdoReadRequested.emit(*self.sdo_request())
|
||||
|
||||
def _emit_sdo_write(self) -> None:
|
||||
node, index, subindex, data_type = self.sdo_request()
|
||||
self.sdoWriteRequested.emit(
|
||||
node, index, subindex, data_type, self._int(self.sdo_value_edit.text())
|
||||
)
|
||||
|
||||
def _emit_rtex_read(self) -> None:
|
||||
self.rtexReadRequested.emit(*self.rtex_request())
|
||||
|
||||
def _emit_rtex_write(self) -> None:
|
||||
axis, parameter = self.rtex_request()
|
||||
try:
|
||||
value = float(self.rtex_value_edit.text())
|
||||
except ValueError:
|
||||
self.set_result(t("busdata.not_available"))
|
||||
return
|
||||
self.rtexWriteRequested.emit(axis, parameter, value)
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(t("busdata.title"))
|
||||
self.ecat_box.setTitle(t("busdata.ecat"))
|
||||
self.rtex_box.setTitle(t("busdata.rtex"))
|
||||
self.node_label.setText(t("busdata.node"))
|
||||
self.index_label.setText(t("busdata.index"))
|
||||
self.subindex_label.setText(t("busdata.subindex"))
|
||||
self.sdo_type_label.setText(t("busdata.type"))
|
||||
self.sdo_value_label.setText(t("busdata.value"))
|
||||
self.btn_sdo_read.setText(t("busdata.read"))
|
||||
self.btn_sdo_write.setText(t("busdata.write"))
|
||||
self.rtex_axis_label.setText(t("busdata.axis"))
|
||||
self.rtex_parameter_label.setText(t("busdata.parameter"))
|
||||
self.rtex_value_label.setText(t("busdata.value"))
|
||||
self.btn_rtex_read.setText(t("busdata.read"))
|
||||
self.btn_rtex_write.setText(t("busdata.write"))
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Bus initialisation: pick a BAS program, run it, and read the result.
|
||||
|
||||
The sequence is the vendor's, not ours: ``BasDown`` writes the program to ROM
|
||||
and the controller runs it; the program delays, scans the slot, maps axes, and
|
||||
finally publishes ``BUS_TYPE``, ``Bus_InitStatus``, ``Bus_TotalAxisnum`` and the
|
||||
node count. This panel is a view onto that state machine plus the two buttons
|
||||
that (re)start it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import app_root
|
||||
|
||||
from ..motion import INIT_FAILED, INIT_OK, INIT_RUNNING, BusStatus
|
||||
|
||||
_STATE_KEYS = {
|
||||
INIT_RUNNING: "bus.state_running",
|
||||
INIT_OK: "bus.state_ok",
|
||||
INIT_FAILED: "bus.state_failed",
|
||||
}
|
||||
|
||||
_STATE_TONES = {
|
||||
"bus.state_running": "warn",
|
||||
"bus.state_ok": "ok",
|
||||
"bus.state_failed": "error",
|
||||
"bus.state_timeout": "error",
|
||||
}
|
||||
|
||||
#: The vendor requires a non-Chinese path for a BAS project; default the picker
|
||||
#: at the shipped copies so the operator starts somewhere legal.
|
||||
DEFAULT_BAS_DIR_NAME = "bas"
|
||||
|
||||
|
||||
def default_bas_dir() -> Path:
|
||||
return app_root() / "config" / DEFAULT_BAS_DIR_NAME
|
||||
|
||||
|
||||
class BusPanel(QGroupBox):
|
||||
"""BAS download, re-initialisation, and decoded bus state."""
|
||||
|
||||
chooseRequested = Signal()
|
||||
downloadRequested = Signal()
|
||||
reinitRequested = Signal()
|
||||
|
||||
def __init__(self, bas_path: str = "", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("groupBox")
|
||||
self._bas_path = bas_path
|
||||
self._state_text = t("bus.state_pending")
|
||||
self._state_tone = "muted"
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
outer.setSpacing(8)
|
||||
|
||||
grid = QGridLayout()
|
||||
grid.setHorizontalSpacing(8)
|
||||
grid.setVerticalSpacing(6)
|
||||
|
||||
self.path_label = QLabel(self)
|
||||
self.path_edit = QLineEdit(self)
|
||||
self.path_edit.setReadOnly(True)
|
||||
self.path_edit.setText(bas_path)
|
||||
self.choose_button = QPushButton(self)
|
||||
self.choose_button.clicked.connect(self.chooseRequested)
|
||||
grid.addWidget(self.path_label, 0, 0)
|
||||
grid.addWidget(self.path_edit, 0, 1, 1, 2)
|
||||
grid.addWidget(self.choose_button, 0, 3)
|
||||
|
||||
self.state_label = QLabel(self)
|
||||
self.state_value = QLabel(self)
|
||||
self.state_value.setObjectName("statusValue")
|
||||
grid.addWidget(self.state_label, 1, 0)
|
||||
grid.addWidget(self.state_value, 1, 1, 1, 3)
|
||||
|
||||
self.type_label = QLabel(self)
|
||||
self.type_value = QLabel(self)
|
||||
self.type_value.setObjectName("statusValue")
|
||||
grid.addWidget(self.type_label, 2, 0)
|
||||
grid.addWidget(self.type_value, 2, 1, 1, 3)
|
||||
|
||||
self.nodes_label = QLabel(self)
|
||||
self.nodes_value = QLabel(self)
|
||||
self.nodes_value.setObjectName("statusValue")
|
||||
self.axes_label = QLabel(self)
|
||||
self.axes_value = QLabel(self)
|
||||
self.axes_value.setObjectName("statusValue")
|
||||
grid.addWidget(self.nodes_label, 3, 0)
|
||||
grid.addWidget(self.nodes_value, 3, 1)
|
||||
grid.addWidget(self.axes_label, 3, 2)
|
||||
grid.addWidget(self.axes_value, 3, 3)
|
||||
|
||||
outer.addLayout(grid)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
self.download_button = QPushButton(self)
|
||||
self.download_button.setObjectName("primaryButton")
|
||||
self.download_button.clicked.connect(self.downloadRequested)
|
||||
self.reinit_button = QPushButton(self)
|
||||
self.reinit_button.clicked.connect(self.reinitRequested)
|
||||
buttons.addWidget(self.download_button)
|
||||
buttons.addWidget(self.reinit_button)
|
||||
buttons.addStretch(1)
|
||||
outer.addLayout(buttons)
|
||||
|
||||
self.message_label = QLabel(self)
|
||||
self.message_label.setObjectName("hintLabel")
|
||||
self.message_label.setWordWrap(True)
|
||||
outer.addWidget(self.message_label)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
@property
|
||||
def bas_path(self) -> str:
|
||||
return self._bas_path
|
||||
|
||||
def set_bas_path(self, path: str) -> None:
|
||||
self._bas_path = path
|
||||
self.path_edit.setText(path)
|
||||
|
||||
def set_connected(self, connected: bool) -> None:
|
||||
self.download_button.setEnabled(connected)
|
||||
self.reinit_button.setEnabled(connected)
|
||||
self.choose_button.setEnabled(True)
|
||||
|
||||
def set_status(self, bus: BusStatus, *, now: float | None = None) -> None:
|
||||
"""Render the bus state, including the vendor's timeout case."""
|
||||
key = _STATE_KEYS.get(bus.init_status, "bus.state_pending")
|
||||
if bus.init_status == INIT_FAILED and bus.message and "超时" in bus.message:
|
||||
key = "bus.state_timeout"
|
||||
self._state_text = t(key)
|
||||
self._state_tone = _STATE_TONES.get(key, "muted")
|
||||
self.state_value.setText(self._state_text)
|
||||
|
||||
type_keys = {
|
||||
"ecat": "bus.type_ecat",
|
||||
"rtex": "bus.type_rtex",
|
||||
"unknown": "bus.type_unknown",
|
||||
}
|
||||
self.type_value.setText(t(type_keys.get(bus.bus_type, "bus.type_unknown")))
|
||||
self.nodes_value.setText(str(bus.node_count))
|
||||
self.axes_value.setText(str(bus.total_axes))
|
||||
self.message_label.setText(bus.message)
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(t("bus.title"))
|
||||
self.path_label.setText(t("bus.bas_path"))
|
||||
self.state_label.setText(t("bus.state"))
|
||||
self.type_label.setText(t("bus.type"))
|
||||
self.nodes_label.setText(t("bus.nodes"))
|
||||
self.axes_label.setText(t("bus.axes"))
|
||||
self.choose_button.setText(t("bus.choose"))
|
||||
self.download_button.setText(t("bus.download"))
|
||||
self.reinit_button.setText(t("bus.reinit"))
|
||||
self.state_value.setText(self._state_text)
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Connection controls: mode, address, scan, connect, and connection state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion import ConnectionSpec
|
||||
from ..preferences import CONNECTION_MODES, DEFAULT_CONTROLLER_IPS
|
||||
|
||||
#: Modes that are wired up. The rest report "not available yet" rather than
|
||||
#: pretending to connect: a serial/PCI path that silently does nothing is worse
|
||||
#: than one that says it is not there.
|
||||
_IMPLEMENTED_MODES = ("eth", "local", "demo")
|
||||
|
||||
_MODE_LABEL_KEYS = {
|
||||
"eth": "connection.mode_eth",
|
||||
"com": "connection.mode_com",
|
||||
"pci": "connection.mode_pci",
|
||||
"local": "connection.mode_local",
|
||||
"demo": "connection.mode_demo",
|
||||
}
|
||||
|
||||
|
||||
class ConnectionPanel(QGroupBox):
|
||||
"""Everything needed to reach (or leave) the controller."""
|
||||
|
||||
connectRequested = Signal(object)
|
||||
disconnectRequested = Signal()
|
||||
scanRequested = Signal()
|
||||
|
||||
def __init__(self, spec: ConnectionSpec, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("groupBox")
|
||||
self._spec = spec
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
outer.setSpacing(8)
|
||||
|
||||
grid = QGridLayout()
|
||||
grid.setHorizontalSpacing(8)
|
||||
grid.setVerticalSpacing(6)
|
||||
|
||||
self.mode_label = QLabel(self)
|
||||
self.mode_combo = QComboBox(self)
|
||||
for mode in CONNECTION_MODES:
|
||||
self.mode_combo.addItem("", mode)
|
||||
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
|
||||
grid.addWidget(self.mode_label, 0, 0)
|
||||
grid.addWidget(self.mode_combo, 0, 1, 1, 2)
|
||||
|
||||
self.address_label = QLabel(self)
|
||||
self.address_edit = QLineEdit(self)
|
||||
self.address_edit.setMinimumWidth(150)
|
||||
self.scan_button = QPushButton(self)
|
||||
self.scan_button.clicked.connect(self.scanRequested)
|
||||
grid.addWidget(self.address_label, 1, 0)
|
||||
grid.addWidget(self.address_edit, 1, 1)
|
||||
grid.addWidget(self.scan_button, 1, 2)
|
||||
|
||||
self.ip_hint = QLabel(self)
|
||||
self.ip_hint.setObjectName("hintLabel")
|
||||
grid.addWidget(self.ip_hint, 2, 1, 1, 2)
|
||||
|
||||
outer.addLayout(grid)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
self.open_button = QPushButton(self)
|
||||
self.open_button.setObjectName("primaryButton")
|
||||
self.open_button.clicked.connect(self._emit_connect)
|
||||
self.close_button = QPushButton(self)
|
||||
self.close_button.clicked.connect(self.disconnectRequested)
|
||||
self.close_button.setEnabled(False)
|
||||
buttons.addWidget(self.open_button)
|
||||
buttons.addWidget(self.close_button)
|
||||
buttons.addStretch(1)
|
||||
outer.addLayout(buttons)
|
||||
|
||||
self.status_label = QLabel(self)
|
||||
self.status_label.setObjectName("statusValue")
|
||||
self.status_label.setWordWrap(True)
|
||||
outer.addWidget(self.status_label)
|
||||
|
||||
self.source_label = QLabel(self)
|
||||
self.source_label.setObjectName("hintLabel")
|
||||
self.source_label.setWordWrap(True)
|
||||
outer.addWidget(self.source_label)
|
||||
|
||||
self.retranslate()
|
||||
self.apply_spec(spec)
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
@property
|
||||
def spec(self) -> ConnectionSpec:
|
||||
"""The spec the fields currently describe."""
|
||||
mode = self.mode_combo.currentData() or "eth"
|
||||
if mode == "eth":
|
||||
return ConnectionSpec(mode="eth", ip=self.address_edit.text().strip())
|
||||
if mode == "local":
|
||||
return ConnectionSpec(mode="local", local_string=self.address_edit.text().strip() or "0")
|
||||
if mode == "com":
|
||||
return ConnectionSpec(mode="com", com_id=self._as_int(self.address_edit.text()))
|
||||
if mode == "pci":
|
||||
return ConnectionSpec(mode="pci", card_num=self._as_int(self.address_edit.text()))
|
||||
return ConnectionSpec(mode="demo")
|
||||
|
||||
@staticmethod
|
||||
def _as_int(text: str) -> int:
|
||||
try:
|
||||
return int(str(text).strip() or 0)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def apply_spec(self, spec: ConnectionSpec) -> None:
|
||||
self._spec = spec
|
||||
index = self.mode_combo.findData(spec.mode)
|
||||
if index >= 0:
|
||||
self.mode_combo.setCurrentIndex(index)
|
||||
self._fill_address(spec)
|
||||
self._on_mode_changed()
|
||||
|
||||
def _fill_address(self, spec: ConnectionSpec) -> None:
|
||||
if spec.mode == "eth":
|
||||
self.address_edit.setText(spec.ip or DEFAULT_CONTROLLER_IPS[0])
|
||||
elif spec.mode == "com":
|
||||
self.address_edit.setText(str(spec.com_id))
|
||||
elif spec.mode == "pci":
|
||||
self.address_edit.setText(str(spec.card_num))
|
||||
elif spec.mode == "local":
|
||||
self.address_edit.setText(spec.local_string or "0")
|
||||
else:
|
||||
self.address_edit.setText("")
|
||||
|
||||
def set_ips(self, ips: list[str]) -> None:
|
||||
"""Offer scanned addresses for the Ethernet mode."""
|
||||
if self.mode_combo.currentData() != "eth" or not ips:
|
||||
return
|
||||
current = self.address_edit.text().strip()
|
||||
if current and current not in ips:
|
||||
return
|
||||
self.ip_hint.setText(" ".join(ips[:4]))
|
||||
|
||||
def set_connected(self, connected: bool, source: str = "") -> None:
|
||||
self.open_button.setEnabled(not connected)
|
||||
self.close_button.setEnabled(connected)
|
||||
self.mode_combo.setEnabled(not connected)
|
||||
self.address_edit.setEnabled(not connected)
|
||||
self.status_label.setText(
|
||||
t("connection.state_open") if connected else t("connection.state_closed")
|
||||
)
|
||||
self.status_label.setObjectName("statusValueOk" if connected else "statusValue")
|
||||
self.source_label.setText(source)
|
||||
# Re-apply the stylesheet so the object-name change takes effect.
|
||||
self.status_label.style().unpolish(self.status_label)
|
||||
self.status_label.style().polish(self.status_label)
|
||||
|
||||
def set_status(self, text: str) -> None:
|
||||
self.status_label.setText(text)
|
||||
|
||||
# ----------------------------------------------------------------- actions
|
||||
|
||||
def _emit_connect(self) -> None:
|
||||
mode = self.mode_combo.currentData() or "eth"
|
||||
if mode not in _IMPLEMENTED_MODES:
|
||||
self.set_status(t("connection.no_more", mode=t(_MODE_LABEL_KEYS[mode])))
|
||||
return
|
||||
self.connectRequested.emit(self.spec)
|
||||
|
||||
def _on_mode_changed(self) -> None:
|
||||
mode = self.mode_combo.currentData() or "eth"
|
||||
keys = {
|
||||
"eth": ("connection.ip", True),
|
||||
"com": ("connection.com", True),
|
||||
"pci": ("connection.card", True),
|
||||
"local": ("connection.local_string", True),
|
||||
"demo": ("connection.mode", False),
|
||||
}
|
||||
label_key, editable = keys.get(mode, ("connection.ip", True))
|
||||
self.address_label.setText(t(label_key))
|
||||
self.address_edit.setEnabled(editable)
|
||||
self.scan_button.setEnabled(mode == "eth")
|
||||
self.ip_hint.setText("")
|
||||
if mode == "com":
|
||||
self.address_edit.setText(str(self._spec.com_id))
|
||||
elif mode == "pci":
|
||||
self.address_edit.setText(str(self._spec.card_num))
|
||||
elif mode == "local":
|
||||
self.address_edit.setText(self._spec.local_string or "0")
|
||||
elif mode == "eth":
|
||||
self.address_edit.setText(self._spec.ip or DEFAULT_CONTROLLER_IPS[0])
|
||||
else:
|
||||
self.address_edit.setText("")
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(t("connection.title"))
|
||||
self.mode_label.setText(t("connection.mode"))
|
||||
for index, mode in enumerate(CONNECTION_MODES):
|
||||
self.mode_combo.setItemText(index, t(_MODE_LABEL_KEYS[mode]))
|
||||
self.scan_button.setText(t("connection.scan"))
|
||||
self.open_button.setText(t("connection.open"))
|
||||
self.close_button.setText(t("connection.close"))
|
||||
self._on_mode_changed()
|
||||
@@ -0,0 +1,487 @@
|
||||
"""Test-sequence design: what points on the sensor get tested (workflow P5).
|
||||
|
||||
Three zones, because point generation is three questions that must be answered
|
||||
together:
|
||||
|
||||
* **profile** — the shape and the motion: grid mode, pitch and margin, standoff
|
||||
heights, approach and press speeds, press depth, dwell, settle window,
|
||||
repeats, visiting order. Persisted, so a bench profile is entered once.
|
||||
Laid out as two columns of six fields: one column of twelve wasted the vertical
|
||||
space the point list needs, and the list is the part that grows.
|
||||
* **preview** — the sensor surface with the generated points and the order they
|
||||
will be visited. Reserved space for the 3D view; today it says so rather than
|
||||
pretending.
|
||||
* **validation + point table** — every generated point is checked three ways
|
||||
(on the surface, inside travel, contact confirmable) and carries a run status,
|
||||
so the design and the run progress live in the same list. The list is a
|
||||
``QTableWidget`` because it has to hold hundreds of points and scroll; the
|
||||
previous fixed grid could only ever show three rows.
|
||||
|
||||
Nothing here moves the platform yet. The controls that would generate a point
|
||||
set are present and bound to the profile, but generation, simulation and the run
|
||||
are later phases — and :meth:`SequencePanel.planned_steps` still returns an empty
|
||||
list so a runner cannot start from a table that was never validated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..preferences import POINT_ORDERS, SURFACE_MODES
|
||||
|
||||
#: Profile fields, in form order: ``(key, locale_key, kind)``.
|
||||
#: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit).
|
||||
PROFILE_FIELDS: tuple[tuple[str, str, str], ...] = (
|
||||
("mode", "sequence.field_mode", "choice"),
|
||||
("pitch_mm", "sequence.field_pitch", "number"),
|
||||
("margin_mm", "sequence.field_margin", "number"),
|
||||
("radius_mm", "sequence.field_radius", "number"),
|
||||
("clear_height_mm", "sequence.field_clear_height", "number"),
|
||||
("approach_speed_mm_s", "sequence.field_approach_speed", "number"),
|
||||
("press_speed_mm_s", "sequence.field_press_speed", "number"),
|
||||
("press_depth_mm", "sequence.field_press_depth", "number"),
|
||||
("dwell_s", "sequence.field_dwell", "number"),
|
||||
("settle_window_s", "sequence.field_settle", "number"),
|
||||
("repeats", "sequence.field_repeats", "number"),
|
||||
("order", "sequence.field_order", "choice"),
|
||||
)
|
||||
|
||||
#: How many rows the two-column profile form has.
|
||||
PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2
|
||||
|
||||
#: Point-table columns: ``(key, locale_key)``.
|
||||
POINT_COLUMNS: tuple[tuple[str, str], ...] = (
|
||||
("index", "sequence.col_index"),
|
||||
("x", "sequence.col_x"),
|
||||
("y", "sequence.col_y"),
|
||||
("z", "sequence.col_z"),
|
||||
("status", "sequence.col_status"),
|
||||
("sensitivity", "sequence.col_sensitivity"),
|
||||
("samples", "sequence.col_samples"),
|
||||
("actions", "sequence.col_actions"),
|
||||
)
|
||||
|
||||
#: Editability of the point table. Positions are *generated*, not typed: a typed
|
||||
#: coordinate is one that nothing has checked against the sensor.
|
||||
_EDITABLE_POINT_COLUMNS: tuple[str, ...] = ()
|
||||
|
||||
#: Column widths of the point table, by column index.
|
||||
_POINT_COLUMN_WIDTHS = (50, 130, 130, 130, 120, 160, 90, 200)
|
||||
|
||||
#: Reserved controls and the phase that will fill each one.
|
||||
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run", "save", "load")
|
||||
|
||||
#: Which phase each pending control belongs to (see docs/workflow.md).
|
||||
PENDING_PHASES: dict[str, str] = {
|
||||
"generate": "P5",
|
||||
"simulate": "P6",
|
||||
"dry_run": "P6",
|
||||
"save": "P8",
|
||||
"load": "P8",
|
||||
}
|
||||
|
||||
#: The validation checks, with the locale key naming what each one proves.
|
||||
VALIDATION_CHECKS: tuple[tuple[str, str], ...] = (
|
||||
("on_surface", "sequence.check_surface"),
|
||||
("within_travel", "sequence.check_travel"),
|
||||
("contact_confirmable", "sequence.check_contact"),
|
||||
)
|
||||
|
||||
#: Placeholder rows, so the flow shows its shape. Marked as unmeasured: a row
|
||||
#: with no status is a design, not a measurement.
|
||||
_EXAMPLE_POINTS: tuple[tuple[float, float, float], ...] = (
|
||||
(0.0, 0.0, 12.4),
|
||||
(2.0, 0.0, 12.4),
|
||||
(4.0, 0.0, 12.4),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SequenceStep:
|
||||
"""One planned step, as the future runner will receive it.
|
||||
|
||||
``x``/``y``/``z`` are platform-frame user units (millimetres are shown beside
|
||||
them), ``repeat`` is a count, and ``force_n`` is ``None`` until FT feedback
|
||||
exists. ``sensor_xyz`` is the point's position in the sensor frame, computed
|
||||
from the P4 transform — the thing that makes it checkable against the surface.
|
||||
"""
|
||||
|
||||
index: int
|
||||
targets: dict[str, float]
|
||||
sensor_xyz: tuple[float, float, float] | None = None
|
||||
repeat: int = 1
|
||||
force_n: float | None = None
|
||||
dwell_s: float = 0.0
|
||||
note: str = ""
|
||||
|
||||
|
||||
class SequencePanel(QGroupBox):
|
||||
"""Workflow P5: profile, preview, validation and the point list."""
|
||||
|
||||
generateRequested = Signal()
|
||||
simulateRequested = Signal()
|
||||
dryRunRequested = Signal()
|
||||
saveRequested = Signal()
|
||||
loadRequested = Signal()
|
||||
|
||||
def __init__(self, profile: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("groupBox")
|
||||
self._profile_fields: dict[str, QWidget] = {}
|
||||
self._profile_labels: list[QLabel] = []
|
||||
self._choices: dict[str, QComboBox] = {}
|
||||
self._pending_buttons: dict[str, QPushButton] = {}
|
||||
self._check_rows: dict[str, QLabel] = {}
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
outer.setSpacing(8)
|
||||
|
||||
zones = QHBoxLayout()
|
||||
zones.setSpacing(10)
|
||||
zones.addWidget(self._build_profile(), 0)
|
||||
zones.addWidget(self._build_preview(), 1)
|
||||
zones.addWidget(self._build_validation(), 0)
|
||||
outer.addLayout(zones, 0)
|
||||
|
||||
# The point list is the part that grows, so it takes the vertical stretch
|
||||
# and the zones above it get what they need.
|
||||
outer.addWidget(self._build_point_table(), 1)
|
||||
|
||||
self.set_profile(profile or {})
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------ profile
|
||||
|
||||
def _build_profile(self) -> QWidget:
|
||||
box = QGroupBox(self)
|
||||
box.setObjectName("groupBox")
|
||||
grid = QGridLayout(box)
|
||||
grid.setContentsMargins(10, 8, 10, 8)
|
||||
grid.setHorizontalSpacing(10)
|
||||
grid.setVerticalSpacing(4)
|
||||
|
||||
# Two fields per row: label, field, label, field. Seeing the whole profile
|
||||
# at once is what lets an operator sanity-check a pitch against a depth.
|
||||
for index, (key, _locale_key, kind) in enumerate(PROFILE_FIELDS):
|
||||
row, side = divmod(index, 2)
|
||||
column = side * 2
|
||||
label = QLabel(box)
|
||||
label.setObjectName("hintLabel")
|
||||
grid.addWidget(label, row, column)
|
||||
self._profile_labels.append(label)
|
||||
|
||||
if kind == "choice":
|
||||
field: QWidget = QComboBox(box)
|
||||
field.setFixedHeight(28)
|
||||
self._choices[key] = field # type: ignore[assignment]
|
||||
else:
|
||||
field = QLineEdit(box)
|
||||
field.setFixedHeight(28)
|
||||
field.setProperty("fieldKey", key)
|
||||
grid.addWidget(field, row, column + 1)
|
||||
self._profile_fields[key] = field
|
||||
|
||||
grid.setColumnStretch(1, 1)
|
||||
grid.setColumnStretch(3, 1)
|
||||
self._profile_box = box
|
||||
return box
|
||||
|
||||
# ------------------------------------------------------------------ preview
|
||||
|
||||
def _build_preview(self) -> QWidget:
|
||||
box = QGroupBox(self)
|
||||
box.setObjectName("groupBox")
|
||||
column = QVBoxLayout(box)
|
||||
column.setContentsMargins(10, 8, 10, 8)
|
||||
column.setSpacing(6)
|
||||
|
||||
self.preview_label = QLabel(box)
|
||||
self.preview_label.setObjectName("hintLabel")
|
||||
self.preview_label.setWordWrap(True)
|
||||
self.preview_label.setMinimumHeight(120)
|
||||
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
column.addWidget(self.preview_label, 1)
|
||||
|
||||
self.preview_hint = QLabel(box)
|
||||
self.preview_hint.setObjectName("hintLabel")
|
||||
self.preview_hint.setWordWrap(True)
|
||||
column.addWidget(self.preview_hint)
|
||||
self._preview_box = box
|
||||
return box
|
||||
|
||||
# --------------------------------------------------------------- validation
|
||||
|
||||
def _build_validation(self) -> QWidget:
|
||||
box = QGroupBox(self)
|
||||
box.setObjectName("groupBox")
|
||||
column = QVBoxLayout(box)
|
||||
column.setContentsMargins(10, 8, 10, 8)
|
||||
column.setSpacing(4)
|
||||
|
||||
for key, _locale_key in VALIDATION_CHECKS:
|
||||
row = QLabel(box)
|
||||
row.setObjectName("statusValue")
|
||||
row.setWordWrap(True)
|
||||
column.addWidget(row)
|
||||
self._check_rows[key] = row
|
||||
|
||||
self.summary_label = QLabel(box)
|
||||
self.summary_label.setObjectName("hintLabel")
|
||||
self.summary_label.setWordWrap(True)
|
||||
column.addWidget(self.summary_label)
|
||||
column.addStretch(1)
|
||||
|
||||
for key in PENDING_CONTROLS:
|
||||
button = QPushButton(box)
|
||||
button.setProperty("rail", True)
|
||||
button.setFixedHeight(28)
|
||||
column.addWidget(button)
|
||||
self._pending_buttons[key] = button
|
||||
|
||||
self._pending_buttons["generate"].clicked.connect(self.generateRequested)
|
||||
self._pending_buttons["simulate"].clicked.connect(self.simulateRequested)
|
||||
self._pending_buttons["dry_run"].clicked.connect(self.dryRunRequested)
|
||||
self._pending_buttons["save"].clicked.connect(self.saveRequested)
|
||||
self._pending_buttons["load"].clicked.connect(self.loadRequested)
|
||||
# None of these can run: generation, simulation, the runner and the
|
||||
# dataset writer belong to later phases (see docs/workflow.md).
|
||||
for button in self._pending_buttons.values():
|
||||
button.setEnabled(False)
|
||||
|
||||
self._validation_box = box
|
||||
return box
|
||||
|
||||
# -------------------------------------------------------------- point table
|
||||
|
||||
def _build_point_table(self) -> QWidget:
|
||||
box = QGroupBox(self)
|
||||
box.setObjectName("groupBox")
|
||||
column = QVBoxLayout(box)
|
||||
column.setContentsMargins(10, 8, 10, 8)
|
||||
column.setSpacing(6)
|
||||
|
||||
self.table = QTableWidget(len(_EXAMPLE_POINTS), len(POINT_COLUMNS), box)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
||||
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self.table.setAlternatingRowColors(True)
|
||||
for index, width in enumerate(_POINT_COLUMN_WIDTHS):
|
||||
self.table.setColumnWidth(index, width)
|
||||
self.table.horizontalHeader().setSectionResizeMode(
|
||||
len(POINT_COLUMNS) - 1, QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
# The list scrolls rather than clipping: a grid of fixed labels could
|
||||
# only ever show the rows it was built with.
|
||||
self.table.setMinimumHeight(120)
|
||||
|
||||
for row, (x, y, z) in enumerate(_EXAMPLE_POINTS):
|
||||
values = {
|
||||
0: str(row + 1),
|
||||
1: f"{x:.3f}",
|
||||
2: f"{y:.3f}",
|
||||
3: f"{z:.3f}",
|
||||
4: t("sequence.status_pending"),
|
||||
5: "—",
|
||||
6: "—",
|
||||
7: "",
|
||||
}
|
||||
for column_index, text in values.items():
|
||||
item = QTableWidgetItem(text)
|
||||
if column_index >= 4:
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.table.setItem(row, column_index, item)
|
||||
|
||||
column.addWidget(self.table, 1)
|
||||
|
||||
self.point_hint = QLabel(box)
|
||||
self.point_hint.setObjectName("hintLabel")
|
||||
self.point_hint.setWordWrap(True)
|
||||
column.addWidget(self.point_hint)
|
||||
|
||||
self._table_box = box
|
||||
return box
|
||||
|
||||
# -------------------------------------------------------------------- model
|
||||
|
||||
def profile(self) -> dict[str, Any]:
|
||||
"""The profile as typed, with numbers parsed where they are numbers."""
|
||||
values: dict[str, Any] = {}
|
||||
for key, _locale_key, kind in PROFILE_FIELDS:
|
||||
widget = self._profile_fields[key]
|
||||
if kind == "choice":
|
||||
values[key] = widget.currentData() or widget.currentText()
|
||||
else:
|
||||
values[key] = _as_number(widget.text(), 0.0)
|
||||
values["repeats"] = int(max(1, values.get("repeats", 1)))
|
||||
return values
|
||||
|
||||
def set_profile(self, profile: dict[str, Any]) -> None:
|
||||
"""Load a stored profile; unknown or missing keys keep their default."""
|
||||
for key, _locale_key, kind in PROFILE_FIELDS:
|
||||
if key not in profile:
|
||||
continue
|
||||
widget = self._profile_fields[key]
|
||||
value = profile[key]
|
||||
if kind == "choice":
|
||||
index = widget.findData(value)
|
||||
if index >= 0:
|
||||
widget.setCurrentIndex(index)
|
||||
else:
|
||||
widget.setText(_format_number(value))
|
||||
|
||||
def profile_keys(self) -> list[str]:
|
||||
return [key for key, _locale_key, _kind in PROFILE_FIELDS]
|
||||
|
||||
def field(self, key: str) -> QWidget:
|
||||
return self._profile_fields[key]
|
||||
|
||||
def choice(self, key: str) -> QComboBox:
|
||||
return self._choices[key]
|
||||
|
||||
def pending_controls(self) -> list[str]:
|
||||
return list(PENDING_CONTROLS)
|
||||
|
||||
def pending_phases(self) -> dict[str, str]:
|
||||
return dict(PENDING_PHASES)
|
||||
|
||||
def validation_checks(self) -> list[str]:
|
||||
return [key for key, _locale_key in VALIDATION_CHECKS]
|
||||
|
||||
def check_row(self, key: str) -> QLabel:
|
||||
return self._check_rows[key]
|
||||
|
||||
def set_check(self, key: str, text: str, ok: bool = True) -> None:
|
||||
"""Report one validation result (used once generation exists)."""
|
||||
row = self._check_rows[key]
|
||||
row.setText(text)
|
||||
row.setProperty("checkOk", ok)
|
||||
|
||||
def column_keys(self) -> list[str]:
|
||||
return [key for key, _locale_key in POINT_COLUMNS]
|
||||
|
||||
def point_row(self, index: int) -> dict[str, str]:
|
||||
return {
|
||||
key: (self.table.item(index, column).text() if self.table.item(index, column) else "")
|
||||
for column, (key, _locale_key) in enumerate(POINT_COLUMNS)
|
||||
}
|
||||
|
||||
def point_row_count(self) -> int:
|
||||
return self.table.rowCount()
|
||||
|
||||
def append_point(self, x: float, y: float, z: float, status: str = "") -> int:
|
||||
"""Add a generated point. Used by the generator once P5 exists."""
|
||||
row = self.table.rowCount()
|
||||
self.table.insertRow(row)
|
||||
values = {
|
||||
0: str(row + 1),
|
||||
1: f"{x:.3f}",
|
||||
2: f"{y:.3f}",
|
||||
3: f"{z:.3f}",
|
||||
4: status or t("sequence.status_pending"),
|
||||
5: "—",
|
||||
6: "—",
|
||||
7: "",
|
||||
}
|
||||
for column, text in values.items():
|
||||
item = QTableWidgetItem(text)
|
||||
if column >= 4:
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.table.setItem(row, column, item)
|
||||
return row
|
||||
|
||||
def planned_steps(self) -> list[SequenceStep]:
|
||||
"""The steps the table describes, for the future runner.
|
||||
|
||||
Empty while the tab is a design surface: generation has not run, nothing
|
||||
has been validated, and a runner must not start from a table that was
|
||||
never checked — which is what the P5 gate exists to prevent.
|
||||
"""
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(t("sequence.title"))
|
||||
self._profile_box.setTitle(t("sequence.profile_title"))
|
||||
self._preview_box.setTitle(t("sequence.preview_title"))
|
||||
self._validation_box.setTitle(t("sequence.validation_title"))
|
||||
self._table_box.setTitle(t("sequence.points_title"))
|
||||
|
||||
for label, (_key, locale_key, _kind) in zip(self._profile_labels, PROFILE_FIELDS):
|
||||
label.setText(t(locale_key))
|
||||
for key, combo in self._choices.items():
|
||||
_fill_choice(combo, key)
|
||||
|
||||
self.preview_label.setText(t("sequence.preview_placeholder"))
|
||||
self.preview_hint.setText(t("sequence.preview_hint"))
|
||||
for key, _locale_key in VALIDATION_CHECKS:
|
||||
self._check_rows[key].setText(t(f"sequence.check_{key}") + ": —")
|
||||
self.summary_label.setText(t("sequence.validation_pending"))
|
||||
for key in PENDING_CONTROLS:
|
||||
self._pending_buttons[key].setText(t(f"sequence.btn_{key}"))
|
||||
self._pending_buttons[key].setToolTip(
|
||||
t("sequence.pending_tooltip", phase=PENDING_PHASES[key])
|
||||
)
|
||||
|
||||
self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS])
|
||||
for row in range(self.table.rowCount()):
|
||||
item = self.table.item(row, 4)
|
||||
if item is not None and item.text() in ("", t("sequence.status_pending")):
|
||||
item.setText(t("sequence.status_pending"))
|
||||
self.point_hint.setText(t("sequence.points_hint"))
|
||||
|
||||
|
||||
def _fill_choice(combo: QComboBox, key: str) -> None:
|
||||
"""Populate a choice field from its option list, keeping the selection."""
|
||||
current = combo.currentData()
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
if key == "mode":
|
||||
for value in SURFACE_MODES:
|
||||
combo.addItem(t(f"sequence.mode_{value}"), value)
|
||||
elif key == "order":
|
||||
for value in POINT_ORDERS:
|
||||
combo.addItem(t(f"sequence.order_{value}"), value)
|
||||
combo.blockSignals(False)
|
||||
if current is not None:
|
||||
index = combo.findData(current)
|
||||
if index >= 0:
|
||||
combo.setCurrentIndex(index)
|
||||
|
||||
|
||||
def _as_number(text: str, fallback: float) -> float:
|
||||
try:
|
||||
value = float(str(text).strip())
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
return value if value == value else fallback # reject NaN
|
||||
|
||||
|
||||
def _format_number(value: Any) -> str:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
if number == int(number) and abs(number) < 1e6:
|
||||
return str(int(number))
|
||||
return f"{number:g}"
|
||||
@@ -0,0 +1,424 @@
|
||||
"""The permanent motion status strip, and the run bar beneath it.
|
||||
|
||||
Two rows on purpose:
|
||||
|
||||
* :class:`StatusStrip` — connection, bus and last-action state. This is what must
|
||||
not move: the operator needs to know whether the platform is live while
|
||||
working in any tab, so it sits **above** the workspace tabs and switching tabs
|
||||
cannot change what it says. It also carries the poll-health chip, because a
|
||||
controller that stopped answering looks identical to one that is answering
|
||||
normally unless something shows the age of the last reading.
|
||||
* :class:`RunBar` — a run's phase, progress and controls. Separate from the strip
|
||||
so a run cannot hide the platform state, and because the abort control must be
|
||||
reachable from every tab rather than from inside the tab that started the run.
|
||||
|
||||
Neither widget issues a controller call. The window wires the signals, so there
|
||||
is still one place a command can originate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QProgressBar,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
|
||||
from ..motion import Snapshot
|
||||
|
||||
#: Tone -> colour token. Matches the brand bar's chip vocabulary.
|
||||
_TONE_TOKENS = {
|
||||
"ok": "accent",
|
||||
"warn": "warn",
|
||||
"error": "danger",
|
||||
"muted": "text_muted",
|
||||
}
|
||||
|
||||
#: Bus type -> locale key.
|
||||
_BUS_KEYS = {
|
||||
"ecat": "bus.type_ecat",
|
||||
"rtex": "bus.type_rtex",
|
||||
"unknown": "bus.type_unknown",
|
||||
}
|
||||
|
||||
#: Run phases, and the locale key that names each.
|
||||
RUN_PHASES = {
|
||||
"idle": "run.phase_idle",
|
||||
"dry_run": "run.phase_dry_run",
|
||||
"running": "run.phase_running",
|
||||
"paused": "run.phase_paused",
|
||||
"done": "run.phase_done",
|
||||
"failed": "run.phase_failed",
|
||||
}
|
||||
|
||||
#: A snapshot older than this many poll intervals is stale, and then unhealthy.
|
||||
_STALE_INTERVALS = 3.0
|
||||
_FAILED_INTERVALS = 10.0
|
||||
|
||||
|
||||
class _Chip(QLabel):
|
||||
"""A small toned label: the strip is read at a glance, not studied."""
|
||||
|
||||
def __init__(self, theme: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("statusChip")
|
||||
self._theme = theme
|
||||
self._tone = "muted"
|
||||
self.set_tone("muted")
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
self.set_tone(self._tone)
|
||||
|
||||
def set_tone(self, tone: str) -> None:
|
||||
self._tone = tone
|
||||
colour = load_colors(self._theme).get(_TONE_TOKENS.get(tone, "text_muted"), "#888888")
|
||||
self.setStyleSheet(
|
||||
f"color: {colour}; border: 1px solid {colour}; "
|
||||
"border-radius: 9px; padding: 1px 9px;"
|
||||
)
|
||||
|
||||
|
||||
class StatusStrip(QFrame):
|
||||
"""Connection, bus and last-action state, always on screen."""
|
||||
|
||||
pauseRequested = Signal()
|
||||
stopAllRequested = Signal()
|
||||
|
||||
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("statusStrip")
|
||||
self.setFixedHeight(40)
|
||||
self._theme = theme
|
||||
self._snapshot = Snapshot()
|
||||
self._last_action = ""
|
||||
self._last_action_ok = True
|
||||
self._last_snapshot_at: float | None = None
|
||||
self._poll_interval_ms = 100
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(10, 4, 10, 4)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self.title = QLabel(self)
|
||||
self.title.setObjectName("stripTitle")
|
||||
layout.addWidget(self.title)
|
||||
|
||||
self.chip_connection = _Chip(theme, self)
|
||||
self.chip_bus = _Chip(theme, self)
|
||||
self.chip_init = _Chip(theme, self)
|
||||
self.chip_axes = _Chip(theme, self)
|
||||
self.chip_health = _Chip(theme, self)
|
||||
for chip in (
|
||||
self.chip_connection,
|
||||
self.chip_bus,
|
||||
self.chip_init,
|
||||
self.chip_axes,
|
||||
self.chip_health,
|
||||
):
|
||||
layout.addWidget(chip)
|
||||
|
||||
layout.addStretch(1)
|
||||
|
||||
self.action_label = QLabel(self)
|
||||
self.action_label.setObjectName("stripAction")
|
||||
layout.addWidget(self.action_label)
|
||||
|
||||
self.pause_button = QPushButton(self)
|
||||
self.pause_button.setProperty("rail", True)
|
||||
self.pause_button.setCheckable(True)
|
||||
self.pause_button.clicked.connect(self.pauseRequested)
|
||||
layout.addWidget(self.pause_button)
|
||||
|
||||
self.stop_button = QPushButton(self)
|
||||
self.stop_button.setObjectName("dangerButton")
|
||||
self.stop_button.setProperty("rail", True)
|
||||
self.stop_button.clicked.connect(self.stopAllRequested)
|
||||
layout.addWidget(self.stop_button)
|
||||
|
||||
self.retranslate()
|
||||
self.set_snapshot(Snapshot())
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
for chip in (
|
||||
self.chip_connection,
|
||||
self.chip_bus,
|
||||
self.chip_init,
|
||||
self.chip_axes,
|
||||
self.chip_health,
|
||||
):
|
||||
chip.set_theme(theme)
|
||||
self.chip_health.set_tone(self.chip_health._tone)
|
||||
|
||||
def set_poll_interval_ms(self, interval_ms: int) -> None:
|
||||
self._poll_interval_ms = max(1, int(interval_ms))
|
||||
|
||||
def set_snapshot(self, snapshot: Snapshot) -> None:
|
||||
"""Render one poll. Never raises: this runs on a timer."""
|
||||
self._snapshot = snapshot
|
||||
self._last_snapshot_at = time.monotonic()
|
||||
connected = snapshot.connected
|
||||
|
||||
self.chip_connection.setText(
|
||||
t("connection.state_open") if connected else t("connection.state_closed")
|
||||
)
|
||||
self.chip_connection.set_tone("ok" if connected else "muted")
|
||||
|
||||
self.chip_bus.setText(t(_BUS_KEYS.get(snapshot.bus.bus_type, "bus.type_unknown")))
|
||||
self.chip_bus.set_tone("ok" if snapshot.bus.bus_type != "unknown" else "muted")
|
||||
|
||||
if not connected:
|
||||
self.chip_init.setText(t("bus.state_pending"))
|
||||
self.chip_init.set_tone("muted")
|
||||
elif snapshot.bus.initialized:
|
||||
self.chip_init.setText(t("bus.state_ok"))
|
||||
self.chip_init.set_tone("ok")
|
||||
elif snapshot.bus.init_status == 0:
|
||||
text = (
|
||||
"bus.state_timeout"
|
||||
if "超时" in snapshot.bus.message or "timed out" in snapshot.bus.message
|
||||
else "bus.state_failed"
|
||||
)
|
||||
self.chip_init.setText(t(text))
|
||||
self.chip_init.set_tone("error")
|
||||
else:
|
||||
self.chip_init.setText(t("bus.state_running"))
|
||||
self.chip_init.set_tone("warn")
|
||||
|
||||
enabled = sum(1 for state in snapshot.axes if state.enabled)
|
||||
total = len(snapshot.axes) or self._axis_total
|
||||
self.chip_axes.setText(t("status.axes_enabled", on=enabled, total=total))
|
||||
self.chip_axes.set_tone("ok" if enabled else "muted")
|
||||
|
||||
self.refresh_health()
|
||||
self._refresh_action()
|
||||
|
||||
#: Fallback axis total before the first snapshot describes the bus.
|
||||
_axis_total = 5
|
||||
|
||||
def set_axis_total(self, total: int) -> None:
|
||||
self._axis_total = max(1, int(total))
|
||||
|
||||
def refresh_health(self) -> None:
|
||||
"""Show how old the last reading is.
|
||||
|
||||
A stalled poll is otherwise invisible: every other chip keeps displaying
|
||||
its last value, so a controller that stopped answering looks exactly like
|
||||
one that is answering normally. Called on each snapshot *and* on the
|
||||
window's own timer, so the chip ages even when no snapshot arrives —
|
||||
which is the case it exists to reveal.
|
||||
"""
|
||||
if self._last_snapshot_at is None:
|
||||
self.chip_health.setText(t("status.health_none"))
|
||||
self.chip_health.set_tone("muted")
|
||||
return
|
||||
if self.paused:
|
||||
self.chip_health.setText(t("status.health_paused"))
|
||||
self.chip_health.set_tone("muted")
|
||||
return
|
||||
|
||||
age_ms = (time.monotonic() - self._last_snapshot_at) * 1000.0
|
||||
intervals = age_ms / float(self._poll_interval_ms)
|
||||
self.chip_health.setText(t("status.health_age", ms=int(age_ms)))
|
||||
if intervals > _FAILED_INTERVALS:
|
||||
self.chip_health.set_tone("error")
|
||||
elif intervals > _STALE_INTERVALS:
|
||||
self.chip_health.set_tone("warn")
|
||||
else:
|
||||
self.chip_health.set_tone("ok")
|
||||
|
||||
def set_action_result(self, text: str, ok: bool = True) -> None:
|
||||
"""Record the outcome of the last command, and show it here.
|
||||
|
||||
The status bar carries the same text; the strip is what stays visible
|
||||
when a tab is switched, so the last refusal cannot scroll away unseen.
|
||||
"""
|
||||
self._last_action = str(text)
|
||||
self._last_action_ok = bool(ok)
|
||||
self._refresh_action()
|
||||
|
||||
def _refresh_action(self) -> None:
|
||||
self.action_label.setText(self._last_action or t("status.none"))
|
||||
tone = "ok" if self._last_action_ok else "error"
|
||||
colour = load_colors(self._theme).get(_TONE_TOKENS[tone], "#888888")
|
||||
self.action_label.setStyleSheet(f"color: {colour};")
|
||||
|
||||
@property
|
||||
def paused(self) -> bool:
|
||||
return self.pause_button.isChecked()
|
||||
|
||||
def set_paused(self, paused: bool) -> None:
|
||||
self.pause_button.setChecked(bool(paused))
|
||||
self._refresh_pause_text()
|
||||
self.refresh_health()
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("status.motion"))
|
||||
self._refresh_pause_text()
|
||||
self.stop_button.setText(t("axes.stop_all"))
|
||||
self.set_snapshot(self._snapshot)
|
||||
self.refresh_health()
|
||||
|
||||
def _refresh_pause_text(self) -> None:
|
||||
self.pause_button.setText(
|
||||
t("status.resume") if self.pause_button.isChecked() else t("status.pause")
|
||||
)
|
||||
|
||||
|
||||
class RunBar(QFrame):
|
||||
"""A run's phase, progress and controls, visible from every tab."""
|
||||
|
||||
runRequested = Signal()
|
||||
pauseRequested = Signal()
|
||||
abortRequested = Signal()
|
||||
|
||||
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("runBar")
|
||||
self.setFixedHeight(40)
|
||||
self._theme = theme
|
||||
self._phase = "idle"
|
||||
self._total = 0
|
||||
self._completed = 0
|
||||
self._has_run = False
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(10, 4, 10, 4)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self.title = QLabel(self)
|
||||
self.title.setObjectName("stripTitle")
|
||||
layout.addWidget(self.title)
|
||||
|
||||
self.phase_label = QLabel(self)
|
||||
self.phase_label.setObjectName("statusValue")
|
||||
self.phase_label.setMinimumWidth(90)
|
||||
layout.addWidget(self.phase_label)
|
||||
|
||||
self.step_label = QLabel(self)
|
||||
self.step_label.setObjectName("stripAction")
|
||||
self.step_label.setMinimumWidth(140)
|
||||
layout.addWidget(self.step_label)
|
||||
|
||||
self.progress = QProgressBar(self)
|
||||
self.progress.setTextVisible(False)
|
||||
self.progress.setFixedHeight(12)
|
||||
self.progress.setMinimumWidth(160)
|
||||
layout.addWidget(self.progress, 1)
|
||||
|
||||
self.time_label = QLabel(self)
|
||||
self.time_label.setObjectName("stripAction")
|
||||
self.time_label.setMinimumWidth(180)
|
||||
layout.addWidget(self.time_label)
|
||||
|
||||
self.run_button = QPushButton(self)
|
||||
self.run_button.setObjectName("primaryButton")
|
||||
self.pause_button = QPushButton(self)
|
||||
self.abort_button = QPushButton(self)
|
||||
self.abort_button.setObjectName("dangerButton")
|
||||
for button in (self.run_button, self.pause_button, self.abort_button):
|
||||
button.setProperty("rail", True)
|
||||
button.setFixedHeight(26)
|
||||
layout.addWidget(button)
|
||||
|
||||
self.run_button.clicked.connect(self.runRequested)
|
||||
self.pause_button.clicked.connect(self.pauseRequested)
|
||||
self.abort_button.clicked.connect(self.abortRequested)
|
||||
|
||||
self.retranslate()
|
||||
self.set_run(False)
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
|
||||
def set_phase(self, phase: str) -> None:
|
||||
if phase not in RUN_PHASES:
|
||||
phase = "idle"
|
||||
self._phase = phase
|
||||
self.phase_label.setText(t(RUN_PHASES[phase]))
|
||||
busy = phase in ("running", "paused")
|
||||
tone = {
|
||||
"running": "accent",
|
||||
"paused": "warn",
|
||||
"failed": "danger",
|
||||
"done": "accent",
|
||||
}.get(phase, "text_muted")
|
||||
colour = load_colors(self._theme).get(tone, "#888888")
|
||||
self.phase_label.setStyleSheet(f"color: {colour};")
|
||||
self._refresh_buttons(busy)
|
||||
|
||||
def set_progress(self, completed: int, total: int) -> None:
|
||||
self._completed = max(0, int(completed))
|
||||
self._total = max(0, int(total))
|
||||
self.progress.setRange(0, self._total or 1)
|
||||
self.progress.setValue(min(self._completed, self._total or 1))
|
||||
if self._total:
|
||||
self.step_label.setText(
|
||||
t("run.step_of", done=self._completed, total=self._total)
|
||||
)
|
||||
else:
|
||||
self.step_label.setText(t("run.no_steps"))
|
||||
|
||||
def set_timing(self, elapsed_s: float, remaining_s: float | None) -> None:
|
||||
"""Elapsed, and the estimate. ``None`` means "not known yet"."""
|
||||
if remaining_s is None:
|
||||
self.time_label.setText(t("run.elapsed_only", elapsed=_duration(elapsed_s)))
|
||||
else:
|
||||
self.time_label.setText(
|
||||
t("run.elapsed_remaining", elapsed=_duration(elapsed_s), remaining=_duration(remaining_s))
|
||||
)
|
||||
|
||||
def set_run(self, has_run: bool) -> None:
|
||||
"""Whether a run exists at all — i.e. whether abort must be reachable."""
|
||||
self._has_run = bool(has_run)
|
||||
self._refresh_buttons(self._phase in ("running", "paused"))
|
||||
|
||||
def _refresh_buttons(self, busy: bool) -> None:
|
||||
# Abort is enabled as soon as a run exists: it is the one control that
|
||||
# must never require finding a tab first.
|
||||
self.abort_button.setEnabled(self._has_run)
|
||||
self.pause_button.setEnabled(self._has_run and busy)
|
||||
self.run_button.setEnabled(not busy)
|
||||
|
||||
@property
|
||||
def phase(self) -> str:
|
||||
return self._phase
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("run.title"))
|
||||
self.phase_label.setText(t(RUN_PHASES[self._phase]))
|
||||
self.run_button.setText(t("run.run"))
|
||||
self.pause_button.setText(t("run.pause"))
|
||||
self.abort_button.setText(t("run.abort"))
|
||||
if self._total:
|
||||
self.step_label.setText(
|
||||
t("run.step_of", done=self._completed, total=self._total)
|
||||
)
|
||||
else:
|
||||
self.step_label.setText(t("run.no_steps"))
|
||||
self.time_label.setText(t("run.elapsed_only", elapsed=_duration(0.0)))
|
||||
|
||||
|
||||
def _duration(seconds: float) -> str:
|
||||
"""``m:ss`` for a duration, which is how an operator reads a bench timer."""
|
||||
seconds = max(0.0, float(seconds))
|
||||
minutes, rest = divmod(int(seconds), 60)
|
||||
return f"{minutes:d}:{rest:02d}"
|
||||
@@ -0,0 +1,913 @@
|
||||
"""The AutoCal main window: panels, menus, and the bridge to the poller.
|
||||
|
||||
The window never touches the controller itself. Every controller call goes
|
||||
through :class:`~orisys_autocal.motion.poller.MotionPoller`, which owns the DLL
|
||||
handle on its own thread; results arrive as signals. That keeps the vendor
|
||||
library's blocking calls (a ROM write, a bus scan) off the GUI thread and keeps
|
||||
the ctypes handle single-threaded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.paths import outputs_dir
|
||||
from orisys_app_shared.theme import build_qss, load_colors
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QCloseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QScrollArea,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import __version__ as app_version
|
||||
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
|
||||
from ..preferences import load_preferences, save_preferences
|
||||
from .axis_panel import AxisPanel
|
||||
from .axis_readout import AxisReadoutPanel
|
||||
from .brand_bar import BrandBar
|
||||
from .bus_data_panel import BusDataPanel
|
||||
from .bus_panel import BusPanel, default_bas_dir
|
||||
from .connection_panel import ConnectionPanel
|
||||
from .sequence_panel import SequencePanel
|
||||
from .status_strip import RunBar, StatusStrip
|
||||
from .workspace import TABS, WorkspaceTabs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: The main split: setup + readout rail, then the workspace.
|
||||
#:
|
||||
#: Two columns, not three. Two thin rails bracketing a workspace that has to hold
|
||||
#: a twelve-field form, a 3D preview, a checks list and an eight-column table
|
||||
#: cost 566 px of a 1920 screen — 29% — for chrome. Merging them into one rail
|
||||
#: gives the workspace that 240 px back at every window size, which is the
|
||||
#: difference between a workable sequence page and a cramped one at the minimum
|
||||
#: window width.
|
||||
MAIN_SPLITTER_SIZES = (330, 1560)
|
||||
|
||||
#: Width below which the rail would clip its own controls.
|
||||
MAIN_MINIMUM_WIDTH = 1440
|
||||
|
||||
#: Default and minimum window sizes. The default is the design target; the
|
||||
#: minimum stays viable on a smaller bench monitor.
|
||||
DEFAULT_WINDOW_SIZE = (1920, 1080)
|
||||
MINIMUM_WINDOW_SIZE = (1440, 900)
|
||||
|
||||
#: The action key whose result the status bar is currently showing.
|
||||
_COMMAND_NAMES = {
|
||||
"connect": "connection.open",
|
||||
"disconnect": "connection.close",
|
||||
"scan": "connection.scan",
|
||||
"bas": "bus.download",
|
||||
"reinit": "bus.reinit",
|
||||
}
|
||||
|
||||
|
||||
class AutoCalMainWindow(QMainWindow):
|
||||
"""Platform control, five axes, one window."""
|
||||
|
||||
def __init__(self, prefs: dict | None = None) -> None:
|
||||
super().__init__()
|
||||
self.prefs = dict(prefs or load_preferences())
|
||||
self._theme = str(self.prefs.get("theme") or "dark")
|
||||
self._language = str(self.prefs.get("language") or "zh_CN")
|
||||
self._last_snapshot = Snapshot()
|
||||
self._pending: dict[int, str] = {}
|
||||
self._last_error = ""
|
||||
|
||||
self.setWindowTitle(t("app.title"))
|
||||
self.setMinimumSize(*MINIMUM_WINDOW_SIZE)
|
||||
|
||||
self._build_ui()
|
||||
self._build_menus()
|
||||
self._build_poller()
|
||||
|
||||
self._restore_geometry()
|
||||
self.retranslate()
|
||||
self._apply_theme()
|
||||
|
||||
# --------------------------------------------------------------- build UI
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
central = QWidget(self)
|
||||
layout = QVBoxLayout(central)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self.brand_bar = BrandBar(self._theme, central)
|
||||
layout.addWidget(self.brand_bar)
|
||||
|
||||
# The status strip sits above the workspace, outside the tab widget, so
|
||||
# that switching tabs cannot hide whether the platform is live. The run
|
||||
# bar sits under it for the same reason: abort must be reachable from
|
||||
# every tab, not only from the one that started the run.
|
||||
self.status_strip = StatusStrip(self._theme, central)
|
||||
layout.addWidget(self.status_strip)
|
||||
self.run_bar = RunBar(self._theme, central)
|
||||
layout.addWidget(self.run_bar)
|
||||
|
||||
body = QWidget(central)
|
||||
body_layout = QHBoxLayout(body)
|
||||
body_layout.setContentsMargins(8, 6, 8, 6)
|
||||
body_layout.setSpacing(8)
|
||||
layout.addWidget(body, stretch=1)
|
||||
|
||||
# ----------------------------------------------------------------- rail
|
||||
# One rail, not two. It holds setup, the axis readout and the collapsed
|
||||
# bus data, and it scrolls: at 900 px high those three groups do not fit,
|
||||
# and a rail that clips its own controls is worse than one that scrolls.
|
||||
self.rail_content = QWidget()
|
||||
left = QVBoxLayout(self.rail_content)
|
||||
left.setContentsMargins(0, 0, 0, 0)
|
||||
left.setSpacing(8)
|
||||
|
||||
connection = self.prefs.get("connection") or {}
|
||||
self.connection_panel = ConnectionPanel(
|
||||
ConnectionSpec(
|
||||
mode=str(connection.get("mode") or "eth"),
|
||||
ip=str(connection.get("ip") or ""),
|
||||
com_id=int(connection.get("com_id") or 0),
|
||||
card_num=int(connection.get("card_num") or 0),
|
||||
local_string=str(connection.get("local_string") or "0"),
|
||||
),
|
||||
self.rail_content,
|
||||
)
|
||||
self.bus_panel = BusPanel(str(self.prefs.get("bas_path") or ""), self.rail_content)
|
||||
self.axis_readout = AxisReadoutPanel(
|
||||
MotionService().axis_count, self._theme, self.rail_content
|
||||
)
|
||||
left.addWidget(self.connection_panel)
|
||||
left.addWidget(self.bus_panel)
|
||||
left.addWidget(self.axis_readout)
|
||||
|
||||
# Bus data (SDO/RTEX) is commissioning-only, so it is collapsed by
|
||||
# default: it was costing the bottom of the page ~270 px for a control
|
||||
# used once in a while.
|
||||
self.bus_data_group = QGroupBox(self.rail_content)
|
||||
self.bus_data_group.setObjectName("groupBox")
|
||||
self.bus_data_group.setCheckable(True)
|
||||
self.bus_data_group.setChecked(False)
|
||||
bus_data_layout = QVBoxLayout(self.bus_data_group)
|
||||
bus_data_layout.setContentsMargins(8, 6, 8, 8)
|
||||
self.bus_data_panel = BusDataPanel(
|
||||
sdo_last=self.prefs.get("sdo_last") or {},
|
||||
rtex_last=self.prefs.get("rtex_last") or {},
|
||||
axis_count=MotionService().axis_count,
|
||||
parent=self.bus_data_group,
|
||||
)
|
||||
bus_data_layout.addWidget(self.bus_data_panel)
|
||||
self.bus_data_panel.setVisible(False)
|
||||
self.bus_data_group.toggled.connect(self._on_bus_data_toggled)
|
||||
left.addWidget(self.bus_data_group)
|
||||
left.addStretch(1)
|
||||
|
||||
self.left_rail = QScrollArea(body)
|
||||
self.left_rail.setObjectName("railScroll")
|
||||
self.left_rail.setWidget(self.rail_content)
|
||||
self.left_rail.setWidgetResizable(True)
|
||||
self.left_rail.setFrameShape(QScrollArea.Shape.NoFrame)
|
||||
self.left_rail.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.left_rail.setMinimumWidth(300)
|
||||
|
||||
# ------------------------------------------------------------ workspace
|
||||
params = {
|
||||
axis: AxisParams(**values)
|
||||
for axis, values in _safe_axis_params(self.prefs.get("axis_params"))
|
||||
}
|
||||
self.axis_panel = AxisPanel(
|
||||
MotionService().axis_count,
|
||||
params,
|
||||
units_per_mm=float(self.prefs.get("units_per_mm") or 10.0),
|
||||
parent=body,
|
||||
)
|
||||
self.sequence_panel = SequencePanel(self.prefs.get("sequence_profile") or {}, body)
|
||||
self.workspace = WorkspaceTabs(self.axis_panel, self.sequence_panel, body)
|
||||
|
||||
self.main_splitter = QSplitter(Qt.Orientation.Horizontal, body)
|
||||
self.main_splitter.addWidget(self.left_rail)
|
||||
self.main_splitter.addWidget(self.workspace)
|
||||
self.main_splitter.setSizes(list(MAIN_SPLITTER_SIZES))
|
||||
self.main_splitter.setChildrenCollapsible(False)
|
||||
self.main_splitter.setStretchFactor(0, 0)
|
||||
self.main_splitter.setStretchFactor(1, 1)
|
||||
self.main_splitter.setObjectName("mainSplitter")
|
||||
body_layout.addWidget(self.main_splitter, 1)
|
||||
|
||||
self.diagnostics_box = self._build_diagnostics(central)
|
||||
layout.addWidget(self.diagnostics_box)
|
||||
|
||||
self.setCentralWidget(central)
|
||||
self.statusBar().showMessage(t("status.ready"))
|
||||
self._apply_chrome_colours()
|
||||
|
||||
self._wire_signals()
|
||||
|
||||
def _wire_signals(self) -> None:
|
||||
self.connection_panel.connectRequested.connect(self._on_connect)
|
||||
self.connection_panel.disconnectRequested.connect(self._on_disconnect)
|
||||
self.connection_panel.scanRequested.connect(self._on_scan)
|
||||
self.bus_panel.chooseRequested.connect(self._on_choose_bas)
|
||||
self.bus_panel.downloadRequested.connect(self._on_download_bas)
|
||||
self.bus_panel.reinitRequested.connect(self._on_reinit)
|
||||
self.axis_panel.paramsApplied.connect(self._on_params_applied)
|
||||
self.axis_panel.enableToggled.connect(self._on_enable_toggled)
|
||||
self.axis_panel.jogRequested.connect(self._on_jog)
|
||||
self.axis_panel.stopRequested.connect(self._on_stop)
|
||||
self.axis_panel.stopAllRequested.connect(self._on_stop_all)
|
||||
self.axis_panel.moveAbsRequested.connect(self._on_move_abs)
|
||||
self.axis_panel.moveRelRequested.connect(self._on_move_rel)
|
||||
self.axis_panel.homeRequested.connect(self._on_home)
|
||||
self.axis_panel.clearAlarmRequested.connect(self._on_clear_alarm)
|
||||
self.bus_data_panel.sdoReadRequested.connect(self._on_sdo_read)
|
||||
self.bus_data_panel.sdoWriteRequested.connect(self._on_sdo_write)
|
||||
self.bus_data_panel.rtexReadRequested.connect(self._on_rtex_read)
|
||||
self.bus_data_panel.rtexWriteRequested.connect(self._on_rtex_write)
|
||||
self.status_strip.stopAllRequested.connect(self._on_stop_all)
|
||||
self.status_strip.pauseRequested.connect(self._on_pause_polling)
|
||||
self.run_bar.runRequested.connect(self._on_run_requested)
|
||||
self.run_bar.pauseRequested.connect(self._on_run_pause)
|
||||
self.run_bar.abortRequested.connect(self._on_run_abort)
|
||||
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
|
||||
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
|
||||
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
|
||||
|
||||
# ---------------------------------------------------------------- run bar
|
||||
|
||||
def _on_run_requested(self) -> None:
|
||||
"""Refuse, naming the phase that owns the runner.
|
||||
|
||||
Connected rather than left dangling: a control that does nothing when
|
||||
pressed is indistinguishable from a bug, and the point of wiring this now
|
||||
is that the sooner it exists, the sooner the phase that fills it has a
|
||||
seam to build against.
|
||||
"""
|
||||
self._report_run("run.not_implemented", ok=False)
|
||||
|
||||
def _on_run_pause(self) -> None:
|
||||
self.run_bar.set_phase("paused" if self.run_bar.phase != "paused" else "running")
|
||||
|
||||
def _on_run_abort(self) -> None:
|
||||
# Abort always works, even before the runner exists: it stops the axes.
|
||||
self.run_bar.set_phase("failed")
|
||||
self._on_stop_all()
|
||||
self._report_run("run.abort_stopped", ok=True)
|
||||
|
||||
def _report_run(self, locale_key: str, *, ok: bool) -> None:
|
||||
text = t(locale_key)
|
||||
self.statusBar().showMessage(text)
|
||||
self.status_strip.set_action_result(text, ok)
|
||||
|
||||
def _apply_chrome_colours(self) -> None:
|
||||
"""Style the status bar from the theme rather than Qt's default.
|
||||
|
||||
``brand_bar_bg`` is a dark navy that reads well as a header and heavy as
|
||||
a footer, so the bar gets its own token with the brand colour as the
|
||||
fallback for a theme that predates it.
|
||||
"""
|
||||
colours = load_colors(self._theme)
|
||||
background = colours.get("status_bar_bg") or colours.get("brand_bar_bg", "#12315D")
|
||||
text = colours.get("status_bar_text") or "#ffffff"
|
||||
self.statusBar().setStyleSheet(
|
||||
f"QStatusBar {{ background-color: {background}; color: {text}; }}"
|
||||
f"QStatusBar::item {{ border: none; }}"
|
||||
)
|
||||
|
||||
def _on_bus_data_toggled(self, expanded: bool) -> None:
|
||||
self.bus_data_panel.setVisible(bool(expanded))
|
||||
self.bus_data_group.setMaximumHeight(16777215 if expanded else 28)
|
||||
|
||||
def _on_pause_polling(self) -> None:
|
||||
"""Stop or resume the state poll, without touching the connection."""
|
||||
paused = self.status_strip.paused
|
||||
if paused:
|
||||
self.poller.pause()
|
||||
self.statusBar().showMessage(t("status.paused"))
|
||||
else:
|
||||
self.poller.resume()
|
||||
self.statusBar().showMessage(t("status.ready"))
|
||||
# The health chip says "paused" rather than ageing into a false alarm.
|
||||
self.status_strip.refresh_health()
|
||||
|
||||
def _on_enable_all(self, enabled: bool) -> None:
|
||||
for axis in range(self.axis_panel.axis_count):
|
||||
self.poller.submit(
|
||||
lambda service, a=axis, on=enabled: service.set_axis_enable(a, on)
|
||||
)
|
||||
|
||||
def _on_clear_all_alarms(self) -> None:
|
||||
for axis in range(self.axis_panel.axis_count):
|
||||
self.poller.submit(lambda service, a=axis: service.clear_alarm(a))
|
||||
|
||||
def _build_diagnostics(self, parent: QWidget) -> QGroupBox:
|
||||
box = QGroupBox(parent)
|
||||
box.setObjectName("groupBox")
|
||||
layout = QVBoxLayout(box)
|
||||
layout.setContentsMargins(10, 8, 10, 10)
|
||||
layout.setSpacing(4)
|
||||
self.diagnostics_label = QLabel(box)
|
||||
self.diagnostics_label.setObjectName("hintLabel")
|
||||
self.diagnostics_label.setWordWrap(True)
|
||||
self.diagnostics_label.setTextInteractionFlags(
|
||||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
layout.addWidget(self.diagnostics_label)
|
||||
box.setVisible(bool(self.prefs.get("show_diagnostics")))
|
||||
return box
|
||||
|
||||
def _build_menus(self) -> None:
|
||||
menu_bar = self.menuBar()
|
||||
|
||||
self.menu_file = menu_bar.addMenu("")
|
||||
self.act_download_bas = QAction("", self)
|
||||
self.act_download_bas.triggered.connect(self._on_download_bas)
|
||||
self.act_reveal_config = QAction("", self)
|
||||
self.act_reveal_config.triggered.connect(self._on_reveal_config)
|
||||
self.act_reveal_outputs = QAction("", self)
|
||||
self.act_reveal_outputs.triggered.connect(self._on_reveal_outputs)
|
||||
self.act_quit = QAction("", self)
|
||||
self.act_quit.triggered.connect(self.close)
|
||||
for action in (
|
||||
self.act_download_bas,
|
||||
self.act_reveal_config,
|
||||
self.act_reveal_outputs,
|
||||
):
|
||||
self.menu_file.addAction(action)
|
||||
self.menu_file.addSeparator()
|
||||
self.menu_file.addAction(self.act_quit)
|
||||
|
||||
self.menu_view = menu_bar.addMenu("")
|
||||
self.menu_theme = self.menu_view.addMenu("")
|
||||
self.act_theme_dark = QAction("", self)
|
||||
self.act_theme_dark.setCheckable(True)
|
||||
self.act_theme_light = QAction("", self)
|
||||
self.act_theme_light.setCheckable(True)
|
||||
self.act_theme_dark.triggered.connect(lambda: self._set_theme("dark"))
|
||||
self.act_theme_light.triggered.connect(lambda: self._set_theme("light"))
|
||||
self.menu_theme.addAction(self.act_theme_dark)
|
||||
self.menu_theme.addAction(self.act_theme_light)
|
||||
|
||||
self.menu_language = self.menu_view.addMenu("")
|
||||
self.act_lang_zh = QAction("", self)
|
||||
self.act_lang_zh.setCheckable(True)
|
||||
self.act_lang_en = QAction("", self)
|
||||
self.act_lang_en.setCheckable(True)
|
||||
self.act_lang_zh.triggered.connect(lambda: self._set_language("zh_CN"))
|
||||
self.act_lang_en.triggered.connect(lambda: self._set_language("en_US"))
|
||||
self.menu_language.addAction(self.act_lang_zh)
|
||||
self.menu_language.addAction(self.act_lang_en)
|
||||
|
||||
self.act_diagnostics = QAction("", self)
|
||||
self.act_diagnostics.setCheckable(True)
|
||||
self.act_diagnostics.setChecked(bool(self.prefs.get("show_diagnostics")))
|
||||
self.act_diagnostics.toggled.connect(self._set_diagnostics)
|
||||
self.menu_view.addSeparator()
|
||||
self.menu_view.addAction(self.act_diagnostics)
|
||||
|
||||
# Tab shortcuts: the workspace is the page, so reaching a tab from the
|
||||
# keyboard is the navigation an operator uses most.
|
||||
self.menu_tabs = self.menu_view.addMenu("")
|
||||
self.act_tabs: dict[str, QAction] = {}
|
||||
for index, (key, locale_key) in enumerate(TABS):
|
||||
action = QAction("", self)
|
||||
action.setShortcut(f"Ctrl+{index + 1}")
|
||||
action.triggered.connect(lambda _checked=False, k=key: self.workspace.set_current_key(k))
|
||||
self.menu_tabs.addAction(action)
|
||||
self.act_tabs[key] = action
|
||||
self._tab_locale_keys = dict(TABS)
|
||||
|
||||
self.menu_help = menu_bar.addMenu("")
|
||||
self.act_about = QAction("", self)
|
||||
self.act_about.triggered.connect(self._on_about)
|
||||
self.menu_help.addAction(self.act_about)
|
||||
|
||||
def _build_poller(self) -> None:
|
||||
self.poller = MotionPoller(
|
||||
MotionService(language=self._language),
|
||||
axes=range(self.axis_panel.axis_count),
|
||||
)
|
||||
self.poller.snapshotReady.connect(self._on_snapshot)
|
||||
self.poller.commandFinished.connect(self._on_command_finished)
|
||||
self.poller.commandFailed.connect(self._on_command_failed)
|
||||
self.poller.set_interval_ms(int(self.prefs.get("state_poll_ms") or 100))
|
||||
self.status_strip.set_poll_interval_ms(int(self.prefs.get("state_poll_ms") or 100))
|
||||
|
||||
# Ages the poll-health chip even when no snapshot arrives. Runs on the
|
||||
# GUI thread and only reads a timestamp — the controller is untouched —
|
||||
# so a stopped poll becomes visible instead of leaving stale chips
|
||||
# looking healthy.
|
||||
self._health_timer = QTimer(self)
|
||||
self._health_timer.timeout.connect(self.status_strip.refresh_health)
|
||||
self._health_timer.start(500)
|
||||
|
||||
# ------------------------------------------------------------- controller
|
||||
|
||||
def connect_controller(
|
||||
self, spec: ConnectionSpec | None = None, *, label: str = "connect"
|
||||
) -> int:
|
||||
"""Ask the poller to open the controller. Returns the command id."""
|
||||
target = spec or self.connection_panel.spec
|
||||
token = self.poller.connect_controller(target)
|
||||
self._pending[token] = label
|
||||
self.connection_panel.set_status(t("connection.state_connecting"))
|
||||
return token
|
||||
|
||||
def disconnect_controller(self, *, label: str = "disconnect") -> int:
|
||||
token = self.poller.disconnect_controller()
|
||||
self._pending[token] = label
|
||||
return token
|
||||
|
||||
# --------------------------------------------------------------- snapshot
|
||||
|
||||
def _on_snapshot(self, snapshot: Snapshot) -> None:
|
||||
"""Signal handler for the poller's periodic state read."""
|
||||
self.update_snapshot(snapshot)
|
||||
|
||||
def update_snapshot(self, snapshot: Snapshot) -> None:
|
||||
self._last_snapshot = snapshot
|
||||
self.connection_panel.set_connected(
|
||||
snapshot.connected, self.poller.service.transport.source
|
||||
)
|
||||
self.brand_bar.set_connected(snapshot.connected)
|
||||
self.bus_panel.set_connected(snapshot.connected)
|
||||
self.bus_panel.set_status(snapshot.bus)
|
||||
# The bus-data panel gates on the connection first and the bus family
|
||||
# second, so both have to reach it or every SDO/RTEX control stays dead.
|
||||
self.bus_data_panel.set_connected(snapshot.connected)
|
||||
self.bus_data_panel.set_bus_type(snapshot.bus.bus_type)
|
||||
self.axis_panel.update_snapshot(snapshot)
|
||||
self.axis_readout.update_snapshot(snapshot)
|
||||
# The strip is outside the tabs, so it is fed on every poll regardless
|
||||
# of which tab is showing.
|
||||
self.status_strip.set_snapshot(snapshot)
|
||||
# The run bar tracks the designed point list: before a runner exists that
|
||||
# is zero, and its abort control stays correct either way.
|
||||
self.run_bar.set_progress(0, len(self.sequence_panel.planned_steps()))
|
||||
self.run_bar.set_run(bool(self.sequence_panel.planned_steps()))
|
||||
if snapshot.error:
|
||||
self._last_error = snapshot.error
|
||||
self._refresh_status_bar()
|
||||
if self.diagnostics_box.isVisible():
|
||||
self._refresh_diagnostics()
|
||||
|
||||
def _refresh_status_bar(self) -> None:
|
||||
snapshot = self._last_snapshot
|
||||
if not snapshot.connected:
|
||||
self.statusBar().showMessage(t("status.ready"))
|
||||
return
|
||||
parts = [
|
||||
t("status.poll_paused") if self.status_strip.paused else t("status.poll_ok"),
|
||||
t("status.poll_every", ms=self.poller.interval_ms),
|
||||
]
|
||||
if snapshot.bus.init_status == 1:
|
||||
parts.append(t("bus.state_ok"))
|
||||
if self._last_error:
|
||||
parts.append(f"{t('status.last_error')}: {self._last_error}")
|
||||
self.statusBar().showMessage(" | ".join(parts))
|
||||
|
||||
def _refresh_diagnostics(self) -> None:
|
||||
from ..preferences import path as preference_path
|
||||
|
||||
service = self.poller.service
|
||||
self.diagnostics_label.setText(
|
||||
"\n".join(
|
||||
[
|
||||
f"{t('diagnostics.binding')}: PySide6 {_pyside_version()}",
|
||||
f"{t('diagnostics.dll_dir')}: {service.transport.source}",
|
||||
f"{t('diagnostics.prefs')}: {preference_path()}",
|
||||
f"{t('diagnostics.outputs')}: {outputs_dir()}",
|
||||
f"{t('diagnostics.bas_loaded')}: "
|
||||
f"{t('diagnostics.yes') if service.bas_loaded else t('diagnostics.no')}",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# ----------------------------------------------------- command completion
|
||||
|
||||
def _on_command_finished(self, token: int, result) -> None:
|
||||
"""Report a completed command, whatever shape its result has.
|
||||
|
||||
Commands return different things: a ``MotionResult``, a list of them
|
||||
(stop-all), a ``(result, value)`` pair (SDO/RTEX reads), a list of IPs
|
||||
(scan), or ``None``. Rather than a convention each caller must remember,
|
||||
the shape is detected here.
|
||||
"""
|
||||
label_key = _COMMAND_NAMES.get(self._pending.pop(token, ""), "")
|
||||
name = t(label_key) if label_key else ""
|
||||
|
||||
message, ok = _interpret_result(result)
|
||||
if ok:
|
||||
self._last_error = ""
|
||||
if message:
|
||||
self.bus_data_panel.set_result(message)
|
||||
text = f"{name} {t('action.ok')}".strip()
|
||||
self.statusBar().showMessage(text)
|
||||
# The strip keeps the outcome visible after a tab switch, so a
|
||||
# command that was refused while the operator was elsewhere is not
|
||||
# lost the moment the status bar is overwritten.
|
||||
self.status_strip.set_action_result(text, True)
|
||||
return
|
||||
|
||||
self._last_error = message or t("action.failed")
|
||||
self.bus_data_panel.set_result(self._last_error)
|
||||
text = f"{name} {t('action.refused')}: {self._last_error}"
|
||||
self.statusBar().showMessage(text)
|
||||
self.status_strip.set_action_result(text, False)
|
||||
logger.warning("Command refused/failed: %s", self._last_error)
|
||||
|
||||
def _on_command_failed(self, token: int, message: str) -> None:
|
||||
label_key = _COMMAND_NAMES.get(self._pending.pop(token, ""), "")
|
||||
self._last_error = message
|
||||
name = t(label_key) if label_key else ""
|
||||
text = f"{name} {t('action.failed')}: {message}"
|
||||
self.statusBar().showMessage(text)
|
||||
self.status_strip.set_action_result(text, False)
|
||||
logger.error("Command raised: %s", message)
|
||||
|
||||
# ---------------------------------------------------------------- handlers
|
||||
|
||||
def _on_connect(self, spec: ConnectionSpec) -> None:
|
||||
self.connect_controller(spec)
|
||||
|
||||
def _on_disconnect(self) -> None:
|
||||
if not self._last_snapshot.connected:
|
||||
return
|
||||
moving = [
|
||||
state.axis
|
||||
for state in self._last_snapshot.axes
|
||||
if state.enabled and not state.idle
|
||||
]
|
||||
if moving:
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
t("connection.close"),
|
||||
t("axes.motion") + f" {moving}",
|
||||
)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self.disconnect_controller()
|
||||
|
||||
def _on_scan(self) -> None:
|
||||
def _scan(service: MotionService) -> object:
|
||||
return service.transport.search_eth()
|
||||
|
||||
token = self.poller.submit(_scan)
|
||||
self._pending[token] = "scan"
|
||||
|
||||
def _on_choose_bas(self) -> None:
|
||||
start = self.bus_panel.bas_path or str(default_bas_dir())
|
||||
path, _selected = QFileDialog.getOpenFileName(
|
||||
self, t("bus.dialog_title"), start, "BAS (*.bas);;All files (*)"
|
||||
)
|
||||
if path:
|
||||
self.bus_panel.set_bas_path(path)
|
||||
self.prefs["bas_path"] = path
|
||||
|
||||
def _on_download_bas(self) -> None:
|
||||
path = self.bus_panel.bas_path
|
||||
if not path:
|
||||
self._on_choose_bas()
|
||||
path = self.bus_panel.bas_path
|
||||
if not path:
|
||||
return
|
||||
token = self.poller.submit(lambda service: service.download_bas(path, 1))
|
||||
self._pending[token] = "bas"
|
||||
self.statusBar().showMessage(t("bus.downloaded"))
|
||||
|
||||
def _on_reinit(self) -> None:
|
||||
token = self.poller.submit(lambda service: service.reinit_bus())
|
||||
self._pending[token] = "reinit"
|
||||
|
||||
def _on_params_applied(self, axis: int, params: AxisParams) -> None:
|
||||
token = self.poller.submit(lambda service: service.set_axis_params(axis, params))
|
||||
self._pending[token] = ""
|
||||
|
||||
def _on_enable_toggled(self, axis: int, enabled: bool) -> None:
|
||||
token = self.poller.submit(lambda service: service.set_axis_enable(axis, enabled))
|
||||
self._pending[token] = ""
|
||||
|
||||
def _on_jog(self, axis: int, forward: bool) -> None:
|
||||
self.poller.submit(lambda service: service.jog(axis, forward))
|
||||
|
||||
def _on_stop(self, axis: int) -> None:
|
||||
self.poller.submit(lambda service: service.stop(axis))
|
||||
|
||||
def _on_stop_all(self) -> None:
|
||||
def _stop_all(service: MotionService) -> object:
|
||||
return service.stop_all()
|
||||
|
||||
self.poller.submit(_stop_all)
|
||||
|
||||
def _on_move_abs(self, axis: int, target: float) -> None:
|
||||
self.poller.submit(lambda service: service.move_abs(axis, target))
|
||||
|
||||
def _on_move_rel(self, axis: int, distance: float) -> None:
|
||||
self.poller.submit(lambda service: service.move_rel(axis, distance))
|
||||
|
||||
def _on_home(self, axis: int) -> None:
|
||||
self.poller.submit(lambda service: service.home(axis))
|
||||
|
||||
def _on_clear_alarm(self, axis: int) -> None:
|
||||
self.poller.submit(lambda service: service.clear_alarm(axis))
|
||||
|
||||
# ---------------------------------------------------------------- bus data
|
||||
|
||||
def _on_sdo_read(self, node: int, index: int, subindex: int, data_type: int) -> None:
|
||||
def _read(service: MotionService):
|
||||
result, value = service.sdo_read(node, index, subindex, data_type)
|
||||
return result, value
|
||||
|
||||
token = self.poller.submit(_read)
|
||||
self._pending[token] = ""
|
||||
|
||||
def _on_sdo_write(
|
||||
self, node: int, index: int, subindex: int, data_type: int, value: int
|
||||
) -> None:
|
||||
token = self.poller.submit(
|
||||
lambda service: service.sdo_write(node, index, subindex, data_type, value)
|
||||
)
|
||||
self._pending[token] = ""
|
||||
|
||||
def _on_rtex_read(self, axis: int, parameter: int) -> None:
|
||||
token = self.poller.submit(lambda service: service.rtex_read(axis, parameter))
|
||||
self._pending[token] = ""
|
||||
|
||||
def _on_rtex_write(self, axis: int, parameter: int, value: float) -> None:
|
||||
token = self.poller.submit(
|
||||
lambda service: service.rtex_write(axis, parameter, value)
|
||||
)
|
||||
self._pending[token] = ""
|
||||
|
||||
# ------------------------------------------------------------------ menus
|
||||
|
||||
def _set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
self.prefs["theme"] = theme
|
||||
self._apply_theme()
|
||||
self._persist_prefs()
|
||||
|
||||
def _apply_theme(self) -> None:
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.setStyleSheet(build_qss(self._theme))
|
||||
self.brand_bar.set_theme(self._theme)
|
||||
self.status_strip.set_theme(self._theme)
|
||||
self.run_bar.set_theme(self._theme)
|
||||
self.axis_readout.set_theme(self._theme)
|
||||
self._apply_chrome_colours()
|
||||
self.act_theme_dark.setChecked(self._theme == "dark")
|
||||
self.act_theme_light.setChecked(self._theme == "light")
|
||||
|
||||
def _set_language(self, language: str) -> None:
|
||||
self._language = language
|
||||
self.prefs["language"] = language
|
||||
set_locale(language)
|
||||
self.poller.set_language(language)
|
||||
self.retranslate()
|
||||
self._persist_prefs()
|
||||
|
||||
def _set_diagnostics(self, visible: bool) -> None:
|
||||
"""Show or hide the diagnostics box.
|
||||
|
||||
It used to be a splitter pane, which meant toggling it resized the whole
|
||||
page. Now it is a fixed footer: the layout above it does not move, so an
|
||||
operator cannot lose their place by turning the diagnostics on.
|
||||
"""
|
||||
self.prefs["show_diagnostics"] = bool(visible)
|
||||
self.diagnostics_box.setVisible(bool(visible))
|
||||
if visible:
|
||||
self._refresh_diagnostics()
|
||||
self._persist_prefs()
|
||||
|
||||
def _on_reveal_config(self) -> None:
|
||||
from orisys_app_shared.paths import config_dir
|
||||
|
||||
self._reveal(config_dir())
|
||||
|
||||
def _on_reveal_outputs(self) -> None:
|
||||
self._reveal(outputs_dir())
|
||||
|
||||
@staticmethod
|
||||
def _reveal(path: Path) -> None:
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
subprocess.Popen(["explorer", str(path)])
|
||||
else: # pragma: no cover - the platform is Windows
|
||||
subprocess.Popen(["xdg-open", str(path)])
|
||||
except OSError:
|
||||
logger.exception("Could not open %s", path)
|
||||
|
||||
def _on_about(self) -> None:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
t("app.about_title"),
|
||||
t(
|
||||
"app.about_body",
|
||||
app_version=app_version,
|
||||
shared_version=shared_version,
|
||||
api_version=API_VERSION,
|
||||
binding=f"PySide6 {_pyside_version()}",
|
||||
dll_dir=self.poller.service.transport.source,
|
||||
prefs_path=self.prefs_path(),
|
||||
),
|
||||
)
|
||||
|
||||
def prefs_path(self) -> str:
|
||||
from ..preferences import path as preference_path
|
||||
|
||||
return str(preference_path())
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setWindowTitle(f"{t('app.title')} {app_version}")
|
||||
self.brand_bar.retranslate()
|
||||
self.status_strip.retranslate()
|
||||
self.run_bar.retranslate()
|
||||
self.connection_panel.retranslate()
|
||||
self.bus_panel.retranslate()
|
||||
self.bus_data_group.setTitle(t("busdata.group"))
|
||||
self.bus_data_panel.retranslate()
|
||||
self.axis_panel.retranslate()
|
||||
self.axis_readout.retranslate()
|
||||
self.sequence_panel.retranslate()
|
||||
self.workspace.retranslate()
|
||||
|
||||
self.menu_file.setTitle(t("menu.file"))
|
||||
self.act_download_bas.setText(t("menu.open_bas"))
|
||||
self.act_reveal_config.setText(t("menu.reveal_config"))
|
||||
self.act_reveal_outputs.setText(t("menu.outputs"))
|
||||
self.act_quit.setText(t("menu.quit"))
|
||||
self.menu_view.setTitle(t("menu.view"))
|
||||
self.menu_theme.setTitle(t("menu.theme"))
|
||||
self.act_theme_dark.setText(t("menu.theme_dark"))
|
||||
self.act_theme_light.setText(t("menu.theme_light"))
|
||||
self.menu_language.setTitle(t("menu.language"))
|
||||
self.act_lang_zh.setText(t("menu.lang_zh"))
|
||||
self.act_lang_en.setText(t("menu.lang_en"))
|
||||
self.act_diagnostics.setText(t("menu.show_diagnostics"))
|
||||
self.menu_tabs.setTitle(t("menu.tabs"))
|
||||
for key, locale_key in self._tab_locale_keys.items():
|
||||
self.act_tabs[key].setText(t(locale_key))
|
||||
self.menu_help.setTitle(t("menu.help"))
|
||||
self.act_about.setText(t("menu.about"))
|
||||
self.act_lang_zh.setChecked(self._language == "zh_CN")
|
||||
self.act_lang_en.setChecked(self._language == "en_US")
|
||||
self.diagnostics_box.setTitle(t("diagnostics.title"))
|
||||
self._refresh_status_bar()
|
||||
|
||||
# ------------------------------------------------------------ persistence
|
||||
|
||||
def _restore_geometry(self) -> None:
|
||||
geometry = self.prefs.get("window_geometry")
|
||||
if isinstance(geometry, list) and len(geometry) == 4:
|
||||
try:
|
||||
self.setGeometry(*(int(value) for value in geometry))
|
||||
self._apply_splitter_sizes()
|
||||
return
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Ignoring malformed window_geometry: %r", geometry)
|
||||
self.resize(*DEFAULT_WINDOW_SIZE)
|
||||
self._apply_splitter_sizes()
|
||||
|
||||
def _apply_splitter_sizes(self) -> None:
|
||||
"""Restore the column split, or fall back to the designed proportions.
|
||||
|
||||
The previous layout stored three column widths in ``splitter_sizes`` for
|
||||
a different splitter; that key no longer describes anything, so it is
|
||||
ignored rather than misapplied.
|
||||
"""
|
||||
stored = self.prefs.get("main_splitter_sizes")
|
||||
if isinstance(stored, list) and len(stored) == self.main_splitter.count():
|
||||
try:
|
||||
self.main_splitter.setSizes([int(value) for value in stored])
|
||||
return
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Ignoring malformed main_splitter_sizes: %r", stored)
|
||||
self.main_splitter.setSizes(list(MAIN_SPLITTER_SIZES))
|
||||
|
||||
def _persist_prefs(self) -> None:
|
||||
"""Write the current UI state to AutoCal's preference file."""
|
||||
geometry = self.geometry()
|
||||
self.prefs["window_geometry"] = [
|
||||
geometry.x(),
|
||||
geometry.y(),
|
||||
geometry.width(),
|
||||
geometry.height(),
|
||||
]
|
||||
self.prefs["main_splitter_sizes"] = [int(v) for v in self.main_splitter.sizes()]
|
||||
self.prefs["bas_path"] = self.bus_panel.bas_path
|
||||
self.prefs["units_per_mm"] = self.axis_panel._units_per_mm
|
||||
self.prefs["axis_params"] = {
|
||||
str(axis): _params_to_dict(params)
|
||||
for axis, params in self.axis_panel.stored_params().items()
|
||||
}
|
||||
self.prefs.update(self.bus_data_panel.remember_state())
|
||||
# The sequence profile is the P5 design artefact: whatever is on screen is
|
||||
# what a later run would use, so it is persisted with everything else.
|
||||
self.prefs["sequence_profile"] = self.sequence_panel.profile()
|
||||
try:
|
||||
save_preferences(self.prefs)
|
||||
except OSError:
|
||||
logger.exception("Could not write the preference file")
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override
|
||||
"""Stop the platform, close the controller, then persist and leave."""
|
||||
logger.info("Closing: stopping axes and shutting the poller down")
|
||||
try:
|
||||
self.poller.submit(lambda service: service.stop_all())
|
||||
self.poller.disconnect_controller()
|
||||
self.poller.shutdown()
|
||||
except Exception: # noqa: BLE001 - closing must always finish
|
||||
logger.exception("Shutdown reported a problem")
|
||||
self._persist_prefs()
|
||||
event.accept()
|
||||
|
||||
|
||||
def _pyside_version() -> str:
|
||||
"""The installed PySide6 version, for the About box and diagnostics."""
|
||||
import PySide6
|
||||
|
||||
return getattr(PySide6, "__version__", "unknown")
|
||||
|
||||
|
||||
def _safe_axis_params(raw: object) -> list[tuple[int, dict]]:
|
||||
"""Turn the stored per-axis dict into ``AxisParams`` keyword groups.
|
||||
|
||||
A preference file written by an older build (or edited by hand) may be the
|
||||
wrong shape; a bad entry is dropped rather than crashing the window.
|
||||
"""
|
||||
known = set(AxisParams.__dataclass_fields__)
|
||||
result: list[tuple[int, dict]] = []
|
||||
if not isinstance(raw, dict):
|
||||
return result
|
||||
for key, values in raw.items():
|
||||
if not isinstance(values, dict):
|
||||
continue
|
||||
try:
|
||||
axis = int(key)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
clean = {name: value for name, value in values.items() if name in known}
|
||||
try:
|
||||
result.append((axis, clean))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Ignoring malformed axis_params[%r]: %r", key, values)
|
||||
return result
|
||||
|
||||
|
||||
def _params_to_dict(params: AxisParams) -> dict:
|
||||
return {
|
||||
name: getattr(params, name) for name in AxisParams.__dataclass_fields__
|
||||
}
|
||||
|
||||
|
||||
def _interpret_result(result) -> tuple[str, bool]:
|
||||
"""Reduce a command's result to ``(message, ok)``.
|
||||
|
||||
See :meth:`AutoCalMainWindow._on_command_finished` for why this is needed
|
||||
rather than each caller knowing its own result shape.
|
||||
"""
|
||||
if result is None:
|
||||
return "", True
|
||||
|
||||
# A single MotionResult (most commands).
|
||||
if hasattr(result, "ok"):
|
||||
return str(getattr(result, "message", "") or ""), bool(result.ok)
|
||||
|
||||
# A (MotionResult, value) pair from the SDO/RTEX reads. Checked before the
|
||||
# plain-list case, because a tuple's first element is a MotionResult here
|
||||
# while a stop-all list's items are.
|
||||
if isinstance(result, tuple) and len(result) == 2 and hasattr(result[0], "ok"):
|
||||
head, value = result
|
||||
if not head.ok:
|
||||
return str(getattr(head, "message", "") or ""), False
|
||||
return t("busdata.read_ok", value=value), True
|
||||
|
||||
# A list of MotionResults (stop-all): ok when every one succeeded, and the
|
||||
# first refusal's reason is the useful thing to show.
|
||||
if isinstance(result, (list, tuple)) and result and hasattr(result[0], "ok"):
|
||||
failures = [item for item in result if not item.ok]
|
||||
if failures:
|
||||
return str(getattr(failures[0], "message", "") or ""), False
|
||||
return "", True
|
||||
|
||||
# A scanned address list.
|
||||
if isinstance(result, (list, tuple)):
|
||||
return ", ".join(str(item) for item in result), True
|
||||
|
||||
return str(result), True
|
||||
@@ -0,0 +1,130 @@
|
||||
"""The single-page workspace.
|
||||
|
||||
One tab per task the operator does on the platform, with the motion status strip
|
||||
living *outside* this widget so a tab change cannot hide the state that decides
|
||||
whether it is safe to act.
|
||||
|
||||
Pages that are not built yet are present as labelled placeholders rather than
|
||||
missing: the layout is being reviewed for operator use, and a tab that appears
|
||||
only later would change the layout under review. Each placeholder names what
|
||||
will fill it, so nobody mistakes an empty page for a broken one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from .axis_panel import AxisPanel
|
||||
from .sequence_panel import SequencePanel
|
||||
|
||||
#: Tab identity -> locale key for its label. Order is the display order, and it
|
||||
#: follows the work: commission the axes, describe the sequence, watch the
|
||||
#: sensor, look at the surface, then record calibration points.
|
||||
TABS: tuple[tuple[str, str], ...] = (
|
||||
("axes", "workspace.tab_axes"),
|
||||
("sequence", "workspace.tab_sequence"),
|
||||
("sensor", "workspace.tab_sensor"),
|
||||
("surface", "workspace.tab_surface"),
|
||||
("points", "workspace.tab_points"),
|
||||
)
|
||||
|
||||
#: Tabs whose content does not exist yet, with the locale key that explains what
|
||||
#: will appear there and when.
|
||||
_PLACEHOLDERS: dict[str, str] = {
|
||||
"sensor": "workspace.reserved_sensor",
|
||||
"surface": "workspace.reserved_surface",
|
||||
"points": "workspace.reserved_points",
|
||||
}
|
||||
|
||||
|
||||
class PlaceholderPage(QWidget):
|
||||
"""An empty page that says what it is waiting for."""
|
||||
|
||||
def __init__(self, locale_key: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._locale_key = locale_key
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(8)
|
||||
self.headline = QLabel(self)
|
||||
self.headline.setObjectName("placeholderTitle")
|
||||
self.body = QLabel(self)
|
||||
self.body.setObjectName("hintLabel")
|
||||
self.body.setWordWrap(True)
|
||||
layout.addWidget(self.headline)
|
||||
layout.addWidget(self.body)
|
||||
layout.addStretch(1)
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.headline.setText(t("workspace.not_built"))
|
||||
self.body.setText(t(self._locale_key))
|
||||
|
||||
|
||||
class WorkspaceTabs(QTabWidget):
|
||||
"""The task workspace, with a stable tab set."""
|
||||
|
||||
pageChanged = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
axis_panel: AxisPanel,
|
||||
sequence_panel: SequencePanel,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("workspaceTabs")
|
||||
self._pages: dict[str, QWidget] = {}
|
||||
self._placeholder_keys: dict[str, str] = {}
|
||||
|
||||
self._pages["axes"] = axis_panel
|
||||
self._pages["sequence"] = sequence_panel
|
||||
for key, locale_key in _PLACEHOLDERS.items():
|
||||
page = PlaceholderPage(locale_key, self)
|
||||
self._pages[key] = page
|
||||
self._placeholder_keys[key] = locale_key
|
||||
|
||||
for key, _locale_key in TABS:
|
||||
self.addTab(self._pages[key], "")
|
||||
|
||||
self.currentChanged.connect(self._on_current_changed)
|
||||
self.retranslate()
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
|
||||
def page_keys(self) -> list[str]:
|
||||
return [key for key, _locale_key in TABS]
|
||||
|
||||
def page(self, key: str) -> QWidget:
|
||||
return self._pages[key]
|
||||
|
||||
def current_key(self) -> str:
|
||||
index = self.currentIndex()
|
||||
keys = self.page_keys()
|
||||
return keys[index] if 0 <= index < len(keys) else keys[0]
|
||||
|
||||
def set_current_key(self, key: str) -> None:
|
||||
keys = self.page_keys()
|
||||
if key in keys:
|
||||
self.setCurrentIndex(keys.index(key))
|
||||
|
||||
def retranslate(self) -> None:
|
||||
for index, (key, locale_key) in enumerate(TABS):
|
||||
self.setTabText(index, t(locale_key))
|
||||
for key, locale_key in self._placeholder_keys.items():
|
||||
page = self._pages[key]
|
||||
if isinstance(page, PlaceholderPage):
|
||||
page.retranslate()
|
||||
|
||||
def _on_current_changed(self, index: int) -> None:
|
||||
keys = self.page_keys()
|
||||
if 0 <= index < len(keys):
|
||||
self.pageChanged.emit(keys[index])
|
||||
Reference in New Issue
Block a user