fixed platform not moving problem
This commit is contained in:
@@ -16,20 +16,25 @@ from .models import (
|
||||
Snapshot,
|
||||
)
|
||||
from .poller import MotionPoller
|
||||
from .service import PLATFORM_AXES, MotionService
|
||||
from .service import DEFAULT_BAS_TASK, PLATFORM_AXES, MotionService, clamp_bas_task
|
||||
from .transport import DemoTransport, NativeTransport, ZmcTransport
|
||||
from .units import (
|
||||
DEFAULT_ACCEL,
|
||||
DEFAULT_SPEED,
|
||||
DEFAULT_UNITS,
|
||||
PULSES_PER_MM,
|
||||
ROTARY_UNITS_PER_90_DEG,
|
||||
deg_to_user_units,
|
||||
mm_to_user_units,
|
||||
safe_float,
|
||||
units_per_degree,
|
||||
user_units_to_deg,
|
||||
user_units_to_mm,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_ACCEL",
|
||||
"DEFAULT_BAS_TASK",
|
||||
"DEFAULT_SPEED",
|
||||
"DEFAULT_UNITS",
|
||||
"INIT_FAILED",
|
||||
@@ -38,6 +43,7 @@ __all__ = [
|
||||
"INIT_TIMEOUT_S",
|
||||
"PLATFORM_AXES",
|
||||
"PULSES_PER_MM",
|
||||
"ROTARY_UNITS_PER_90_DEG",
|
||||
"AxisParams",
|
||||
"AxisState",
|
||||
"AxisStatusBits",
|
||||
@@ -52,7 +58,11 @@ __all__ = [
|
||||
"NativeTransport",
|
||||
"Snapshot",
|
||||
"ZmcTransport",
|
||||
"clamp_bas_task",
|
||||
"deg_to_user_units",
|
||||
"mm_to_user_units",
|
||||
"safe_float",
|
||||
"units_per_degree",
|
||||
"user_units_to_deg",
|
||||
"user_units_to_mm",
|
||||
]
|
||||
|
||||
@@ -26,6 +26,14 @@ logger = logging.getLogger(__name__)
|
||||
#: Auxiliary (host-side) error block offset, from ``zauxdll2.h``.
|
||||
ERR_AUX_OFFSET = 30000
|
||||
|
||||
#: The block the PC library uses for its *own* faults — handshake, socket,
|
||||
#: address, a controller that stopped answering mid-session. It sits below
|
||||
#: ``ERR_AUX_OFFSET``, and it is **not** in the firmware's code table: pointing
|
||||
#: an operator at ZDevelop for one of these sends them to a table that cannot
|
||||
#: contain the number. Observed live: ``20008`` from ``ZAux_OpenEth`` on a first,
|
||||
#: transient attempt that succeeded on the next.
|
||||
HOST_LIB_ERROR_RANGE = (20000, 29999)
|
||||
|
||||
#: Meaning per code, as ``(english, chinese)``.
|
||||
MESSAGES: dict[int, tuple[str, str]] = {
|
||||
ERR_AUX_OFFSET: ("No response from the controller", "控制器无应答"),
|
||||
@@ -42,6 +50,24 @@ UNKNOWN_HINT = (
|
||||
"or run the same command from ZDevelop to see the decoded fault."
|
||||
)
|
||||
|
||||
#: Advice for a host-library fault, which is a communication problem the
|
||||
#: operator can actually act on — and which is frequently transient.
|
||||
HOST_LIB_HINT_ZH = (
|
||||
"上位机库通信错误:请确认网线/网段与控制器 IP,"
|
||||
"确认控制器已上电、没有其它程序占用连接,然后重试。"
|
||||
)
|
||||
HOST_LIB_HINT_EN = (
|
||||
"Host-library communication error: check the cable/segment and the "
|
||||
"controller IP, make sure the controller is powered and no other program "
|
||||
"holds the connection, then retry."
|
||||
)
|
||||
|
||||
|
||||
def is_host_lib_error(code: int) -> bool:
|
||||
"""True for a PC-library fault, which is not a controller code."""
|
||||
low, high = HOST_LIB_ERROR_RANGE
|
||||
return low <= int(code) <= high
|
||||
|
||||
|
||||
def describe(code: int, *, language: str = "zh_CN") -> str:
|
||||
"""A human-readable message for a controller return code.
|
||||
@@ -60,18 +86,23 @@ def describe(code: int, *, language: str = "zh_CN") -> str:
|
||||
|
||||
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"
|
||||
if known is not None:
|
||||
return known[index]
|
||||
|
||||
logger.debug("Undocumented ZMotion return code %s", code)
|
||||
if is_host_lib_error(code):
|
||||
prefix = "上位机库错误" if language == "zh_CN" else "Host-library error"
|
||||
return f"{prefix} {code}"
|
||||
return known[index]
|
||||
prefix = "错误码" if language == "zh_CN" else "Error code"
|
||||
return f"{prefix} {code}"
|
||||
|
||||
|
||||
def describe_with_hint(code: int, *, language: str = "zh_CN") -> str:
|
||||
"""As :func:`describe`, plus the ZDevelop pointer for unknown codes."""
|
||||
"""As :func:`describe`, plus the advice the code's family warrants."""
|
||||
message = describe(code, language=language)
|
||||
if int(code) == 0 or int(code) in MESSAGES:
|
||||
return message
|
||||
if is_host_lib_error(code):
|
||||
hint = HOST_LIB_HINT_ZH if language == "zh_CN" else HOST_LIB_HINT_EN
|
||||
return f"{message} — {hint}"
|
||||
return f"{message} — {UNKNOWN_HINT}"
|
||||
|
||||
@@ -134,11 +134,17 @@ class BusStatus:
|
||||
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."""
|
||||
def timed_out(self, now: float, timeout: float = INIT_TIMEOUT_S) -> bool:
|
||||
"""True when a BAS run started long ago has still not reported.
|
||||
|
||||
``timeout`` is the window to judge against. It defaults to the vendor's
|
||||
25 s, but a caller that owns a configurable window (the service) must
|
||||
pass it: reading the module constant here instead made the configured
|
||||
timeout invisible to every caller that asked this question.
|
||||
"""
|
||||
if self.init_status != INIT_RUNNING or self.init_started_at is None:
|
||||
return False
|
||||
return (now - self.init_started_at) > INIT_TIMEOUT_S
|
||||
return (now - self.init_started_at) > float(timeout)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -279,8 +279,8 @@ class MotionPoller(QObject):
|
||||
"""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 connect_controller(self, spec, *, bas_path: str = "") -> int:
|
||||
return self.submit(lambda service: service.connect(spec, bas_path=bas_path))
|
||||
|
||||
def disconnect_controller(self) -> int:
|
||||
def _disconnect(service: MotionService):
|
||||
|
||||
@@ -52,6 +52,48 @@ PLATFORM_AXES = 5
|
||||
#: ``BUS_TYPE`` values written by the vendor ``.bas`` programs.
|
||||
_BUS_TYPE_NAMES: dict[int, BusType] = {0: "ecat", 1: "rtex"}
|
||||
|
||||
#: The controller task the vendor reference starts its BAS program on
|
||||
#: (``RUN "<file>",4``). Re-initialisation must name the *same* task, or it
|
||||
#: stops a task that was never running and restarts nothing.
|
||||
DEFAULT_BAS_TASK = 4
|
||||
|
||||
#: Accepted task range. The controller's task numbers are small; anything
|
||||
#: outside this is a typed-in mistake, not a task to send.
|
||||
_BAS_TASK_RANGE = (1, 8)
|
||||
|
||||
#: Substrings the firmware puts in an online command's reply when the command
|
||||
#: was refused *without* a non-zero return code — a ``RUN`` naming a program the
|
||||
#: controller does not have is the one that matters here (``ZAux_Execute``
|
||||
#: returns 0 for it).
|
||||
_ONLINE_FAILURE_MARKERS = ("not find", "fail", "error")
|
||||
|
||||
|
||||
def clamp_bas_task(value: object, fallback: int = DEFAULT_BAS_TASK) -> int:
|
||||
"""A usable controller task number, or the vendor default."""
|
||||
try:
|
||||
task = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
low, high = _BAS_TASK_RANGE
|
||||
return task if low <= task <= high else fallback
|
||||
|
||||
|
||||
def _online_failure(reply: str) -> str:
|
||||
"""The controller's own refusal text from an online command, or ``""``.
|
||||
|
||||
``ZAux_Execute`` reports a rejected online command in its reply rather than
|
||||
in its return code, so a caller that only checks the code cannot tell
|
||||
"started" from "no such program". Anything the firmware answers that carries
|
||||
a failure marker is returned for the UI to show verbatim.
|
||||
"""
|
||||
text = " ".join(str(reply or "").split())
|
||||
if not text:
|
||||
return ""
|
||||
lowered = text.lower()
|
||||
if any(marker in lowered for marker in _ONLINE_FAILURE_MARKERS):
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
class MotionService:
|
||||
"""The controller session: one connection, five axes, one state machine."""
|
||||
@@ -64,12 +106,14 @@ class MotionService:
|
||||
units_per_mm: float = DEFAULT_UNITS,
|
||||
pulses_per_mm: float = PULSES_PER_MM,
|
||||
language: str = "zh_CN",
|
||||
bas_task: int = DEFAULT_BAS_TASK,
|
||||
) -> 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.bas_task = clamp_bas_task(bas_task)
|
||||
|
||||
self._spec: ConnectionSpec | None = None
|
||||
self._bus = BusStatus()
|
||||
@@ -125,11 +169,18 @@ class MotionService:
|
||||
|
||||
# ------------------------------------------------------------ connect/init
|
||||
|
||||
def connect(self, spec: ConnectionSpec) -> MotionResult:
|
||||
def connect(self, spec: ConnectionSpec, *, bas_path: str = "") -> MotionResult:
|
||||
"""Open the controller.
|
||||
|
||||
A previous connection is closed first: switching transport type without
|
||||
closing leaks the DLL handle, which the reference program does.
|
||||
|
||||
``bas_path`` is the ``.bas`` file the operator last used, as remembered
|
||||
by the UI. It is *recorded*, never downloaded: a controller usually still
|
||||
has the program in ROM from the last session, and re-writing it on every
|
||||
connect is both slow and a bus re-scan the operator did not ask for. Its
|
||||
only job is to make :meth:`reinit_bus` possible on a session that adopted
|
||||
a running program instead of downloading one.
|
||||
"""
|
||||
if self._spec is not None:
|
||||
self.disconnect()
|
||||
@@ -146,25 +197,35 @@ class MotionService:
|
||||
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()
|
||||
self._adopt_existing_bas(bas_path=bas_path)
|
||||
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:
|
||||
def _adopt_existing_bas(self, *, bas_path: str = "") -> 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
|
||||
path = str(bas_path or "").strip()
|
||||
self._bas_loaded = True
|
||||
# ``init_status`` stays "running" until the first poll reads the real
|
||||
# ``Bus_InitStatus``: connect() must not claim a state it has not read.
|
||||
# The adoption is *timestamped* though, so a controller that never
|
||||
# answers still reaches the timeout instead of looking busy forever —
|
||||
# and so "re-initialise" is not refused as "already running" five
|
||||
# minutes into a session.
|
||||
self._init_started_at = time.monotonic()
|
||||
self._bus = BusStatus(
|
||||
bus_type=_BUS_TYPE_NAMES.get(int(bus_type), "unknown"),
|
||||
init_status=INIT_RUNNING,
|
||||
bas_path=path,
|
||||
bas_loaded=True,
|
||||
init_started_at=self._init_started_at,
|
||||
)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
@@ -224,29 +285,50 @@ class MotionService:
|
||||
)
|
||||
|
||||
def reinit_bus(self) -> MotionResult:
|
||||
"""Re-run the BAS program's init task without re-downloading it."""
|
||||
"""Re-run the BAS program's init task without re-downloading it.
|
||||
|
||||
The vendor sequence, exactly: stop the task the program runs on, then
|
||||
``RUN`` the file on that same task. Task numbers are not interchangeable
|
||||
— the reference program starts on task 4, so stopping task 2 restarts
|
||||
nothing.
|
||||
|
||||
``ZAux_Execute`` returns 0 for a ``RUN`` naming a program the controller
|
||||
does not have, putting the diagnosis in the reply text instead. That text
|
||||
is checked here rather than trusted: a re-initialisation that cannot
|
||||
happen must not be reported as one that is running.
|
||||
"""
|
||||
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",
|
||||
"没有 BASIC 程序文件名,无法重新初始化;请先下载 ECAT.bas 或 RTEX.bas",
|
||||
"No BAS program file name is known, so it cannot be "
|
||||
"re-initialised; download ECAT.bas or RTEX.bas first",
|
||||
)
|
||||
)
|
||||
if self._bus.init_status == INIT_RUNNING and not self._bus.timed_out(time.monotonic()):
|
||||
if self._bus.init_status == INIT_RUNNING and not self._bus.timed_out(
|
||||
time.monotonic(), self._init_timeout
|
||||
):
|
||||
return MotionResult.refused(
|
||||
self._text("总线正在初始化,请稍候", "Bus initialisation is already running")
|
||||
)
|
||||
|
||||
script = os.path.basename(self._bus.bas_path)
|
||||
task = clamp_bas_task(self.bas_task)
|
||||
try:
|
||||
self._transport.execute("STOPTASK 2")
|
||||
self._transport.execute(f'RUN "{script}",4')
|
||||
stop_reply = self._transport.execute(f"STOPTASK {task}")
|
||||
run_reply = self._transport.execute(f'RUN "{script}",{task}')
|
||||
except (ZAuxError, OSError) as exc:
|
||||
code = getattr(exc, "code", 0)
|
||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||
|
||||
# The stop command has no meaningful reply; the RUN one does.
|
||||
failure = _online_failure(run_reply)
|
||||
if failure:
|
||||
logger.warning("Re-initialise rejected for %s on task %s: %s", script, task, failure)
|
||||
return MotionResult.failed(0, failure)
|
||||
|
||||
self._init_started_at = time.monotonic()
|
||||
self._bus = BusStatus(
|
||||
bus_type=self._bus.bus_type,
|
||||
@@ -255,6 +337,19 @@ class MotionService:
|
||||
bas_loaded=True,
|
||||
init_started_at=self._init_started_at,
|
||||
)
|
||||
# Re-read BUS_TYPE now: the program may be a different family than the
|
||||
# one the previous session left behind.
|
||||
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
|
||||
logger.info(
|
||||
"Re-initialising %s on controller task %s (stop reply=%r)",
|
||||
script,
|
||||
task,
|
||||
stop_reply,
|
||||
)
|
||||
return MotionResult.success(
|
||||
self._text("正在重新初始化总线", "Re-initialising the bus")
|
||||
)
|
||||
|
||||
@@ -17,7 +17,9 @@ Three implementations:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
@@ -26,6 +28,8 @@ from orisys_app_shared.zmotion import ZAuxError, ZmotionClient
|
||||
from .errors import ERR_AUX_OFFSET
|
||||
from .models import BusType, ConnectionSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ZmcTransport(Protocol):
|
||||
@@ -42,7 +46,7 @@ class ZmcTransport(Protocol):
|
||||
|
||||
def bas_download(self, filename: str, run_mode: int = 1) -> None: ...
|
||||
|
||||
def execute(self, command: str) -> None: ...
|
||||
def execute(self, command: str) -> str: ...
|
||||
|
||||
def user_var(self, name: str) -> float: ...
|
||||
|
||||
@@ -114,6 +118,14 @@ class ZmcTransport(Protocol):
|
||||
class NativeTransport:
|
||||
"""The real controller, through the vendored ZMotion runtime."""
|
||||
|
||||
#: How many times an Ethernet open is attempted before the failure is
|
||||
#: reported, and how long to wait between attempts. A first connection to a
|
||||
#: controller on a cold ARP/route table can fail once and succeed on the
|
||||
#: next try — the reference program's answer is "click connect again", which
|
||||
#: the operator should not have to know.
|
||||
ETH_OPEN_ATTEMPTS = 3
|
||||
ETH_OPEN_RETRY_DELAY_S = 0.3
|
||||
|
||||
def __init__(self, client: ZmotionClient | None = None) -> None:
|
||||
self._client = client or ZmotionClient()
|
||||
|
||||
@@ -127,16 +139,39 @@ class NativeTransport:
|
||||
|
||||
def open(self, spec: ConnectionSpec) -> None:
|
||||
if spec.mode == "eth":
|
||||
result = self._client.open_eth(spec.ip)
|
||||
self._open_eth_with_retry(spec.ip)
|
||||
elif spec.mode == "com":
|
||||
result = self._client.open_com(spec.com_id)
|
||||
result.raise_for_status(f"open {spec.describe()}")
|
||||
elif spec.mode == "pci":
|
||||
result = self._client.open_pci(spec.card_num)
|
||||
result.raise_for_status(f"open {spec.describe()}")
|
||||
elif spec.mode == "local":
|
||||
result = self._client.fast_open(5, spec.local_string, 1000)
|
||||
result.raise_for_status(f"open {spec.describe()}")
|
||||
else:
|
||||
raise ValueError(f"{spec.mode!r} is not a native connection mode")
|
||||
result.raise_for_status(f"open {spec.describe()}")
|
||||
|
||||
def _open_eth_with_retry(self, ip: str) -> None:
|
||||
"""Open over Ethernet, retrying a *failed* attempt a couple of times."""
|
||||
last_code = 0
|
||||
for attempt in range(1, self.ETH_OPEN_ATTEMPTS + 1):
|
||||
result = self._client.open_eth(ip)
|
||||
if result.ok:
|
||||
if attempt > 1:
|
||||
logger.info("Ethernet open of %s succeeded on attempt %s", ip, attempt)
|
||||
return
|
||||
last_code = result.code
|
||||
logger.warning(
|
||||
"Ethernet open of %s failed on attempt %s/%s with code %s",
|
||||
ip,
|
||||
attempt,
|
||||
self.ETH_OPEN_ATTEMPTS,
|
||||
result.code,
|
||||
)
|
||||
if attempt < self.ETH_OPEN_ATTEMPTS:
|
||||
time.sleep(self.ETH_OPEN_RETRY_DELAY_S)
|
||||
ZAuxError(last_code, f"open {ip}").raise_for_status(f"open {ip}")
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
@@ -152,8 +187,15 @@ class NativeTransport:
|
||||
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 execute(self, command: str) -> str:
|
||||
"""Run an online command; return the controller's own reply text.
|
||||
|
||||
The reply is not decoration: an online command the firmware refuses
|
||||
(``RUN`` naming a program it does not have) returns code 0 and explains
|
||||
itself only in this text. See
|
||||
:meth:`orisys_autocal.motion.service.MotionService.reinit_bus`.
|
||||
"""
|
||||
return str(self._client.execute(command).raise_for_status(command).value or "")
|
||||
|
||||
def user_var(self, name: str) -> float:
|
||||
return float(self._client.get_user_var(name).raise_for_status(name).value)
|
||||
@@ -278,6 +320,7 @@ class DemoTransport:
|
||||
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._bas_file = ""
|
||||
self._init_started: float | None = None
|
||||
self._opened = False
|
||||
self._sdo: dict[tuple[int, int, int], int] = {}
|
||||
@@ -301,15 +344,25 @@ class DemoTransport:
|
||||
if not self._opened:
|
||||
raise ZAuxError(ERR_NOT_OPEN, "not connected")
|
||||
self._bas_loaded = True
|
||||
# The simulation keeps the file name, so a re-init can tell a program
|
||||
# the controller has from one it does not — the distinction the real
|
||||
# firmware makes in an online command's reply text.
|
||||
self._bas_file = os.path.basename(str(filename).replace("\\", "/"))
|
||||
# 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:
|
||||
def execute(self, command: str) -> str:
|
||||
if not self._opened:
|
||||
raise ZAuxError(ERR_NOT_OPEN, "not connected")
|
||||
if "RUN" in command.upper() and self._bas_loaded:
|
||||
wanted = _run_filename(command)
|
||||
if wanted and self._bas_file and wanted != self._bas_file:
|
||||
# The real firmware returns code 0 here and explains itself only
|
||||
# in this text. See MotionService.reinit_bus.
|
||||
return f"Online command error, Run filename:{wanted} not find."
|
||||
self._init_started = time.monotonic() + DEMO_INIT_DELAY_S
|
||||
return ""
|
||||
|
||||
def user_var(self, name: str) -> float:
|
||||
if not self._bas_loaded:
|
||||
@@ -465,3 +518,19 @@ 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
|
||||
|
||||
|
||||
def _run_filename(command: str) -> str:
|
||||
"""The quoted file name in an online ``RUN "file",task`` command, or ``""``.
|
||||
|
||||
Shared by the simulated controller and its tests so the demo rejects the
|
||||
same commands the real firmware rejects.
|
||||
"""
|
||||
text = str(command or "")
|
||||
start = text.find('"')
|
||||
if start < 0:
|
||||
return ""
|
||||
end = text.find('"', start + 1)
|
||||
if end < 0:
|
||||
return ""
|
||||
return text[start + 1 : end].strip()
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
"""User-unit to millimetre conversion.
|
||||
"""Platform unit conversion (linear mm and rotary degrees).
|
||||
|
||||
The numbers come from bench measurement on the platform, transcribed from the
|
||||
vendor reference program's comments::
|
||||
Bench measurements (controller user units, with the platform's usual
|
||||
``UNITS = 10`` pulse equivalent unless noted)::
|
||||
|
||||
# 轴0(X)/轴1(Y) 实测: UNITS=10 时 1000用户单位 = 2mm → 每毫米 5000 脉冲
|
||||
# 轴0(X)/轴1(Y): UNITS=10 时 1000用户单位 = 2mm → 每毫米 5000 脉冲
|
||||
# 当量5 → 1000单位=1mm,不是100单位=1mm
|
||||
# 轴2: 12500 用户单位 = 90°
|
||||
# 轴3: 29500 用户单位 = 90°
|
||||
|
||||
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.
|
||||
Linear display millimetres use ``PULSES_PER_MM`` and the axis pulse equivalent.
|
||||
Rotary display degrees use :data:`ROTARY_UNITS_PER_90_DEG` for axes 2 and 3.
|
||||
Displayed values are *derived*, not fresh measurements.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
#: Measured: 5000 pulses per millimetre (see the module docstring).
|
||||
#: Measured: 5000 pulses per millimetre (axes 0 / 1).
|
||||
PULSES_PER_MM = 5000.0
|
||||
|
||||
#: Pulse equivalent the platform is configured with by default.
|
||||
#: Pulse equivalent the linear axes are 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
|
||||
|
||||
#: Bench: user units that correspond to a 90° turn, keyed by axis number.
|
||||
#: Axis 2 and 3 are rotary; axes 0 / 1 / 4 stay out of this map until measured.
|
||||
ROTARY_UNITS_PER_90_DEG: dict[int, float] = {
|
||||
2: 12500.0,
|
||||
3: 29500.0,
|
||||
}
|
||||
|
||||
|
||||
def safe_float(text: object, fallback: float) -> float:
|
||||
"""Parse ``text`` as a finite float, falling back on anything unusable.
|
||||
@@ -70,3 +78,27 @@ def mm_to_user_units(
|
||||
if units <= 0:
|
||||
return math.nan
|
||||
return float(value) * float(pulses_per_mm) / float(units)
|
||||
|
||||
|
||||
def units_per_degree(axis: int) -> float:
|
||||
"""User units per degree for a rotary axis, or NaN if not calibrated."""
|
||||
per_90 = ROTARY_UNITS_PER_90_DEG.get(int(axis))
|
||||
if per_90 is None or per_90 <= 0:
|
||||
return math.nan
|
||||
return float(per_90) / 90.0
|
||||
|
||||
|
||||
def user_units_to_deg(axis: int, value: float) -> float:
|
||||
"""Convert controller user units to degrees for a calibrated rotary axis."""
|
||||
scale = units_per_degree(axis)
|
||||
if not math.isfinite(scale) or scale == 0.0:
|
||||
return math.nan
|
||||
return float(value) / scale
|
||||
|
||||
|
||||
def deg_to_user_units(axis: int, degrees: float) -> float:
|
||||
"""Inverse of :func:`user_units_to_deg`."""
|
||||
scale = units_per_degree(axis)
|
||||
if not math.isfinite(scale):
|
||||
return math.nan
|
||||
return float(degrees) * scale
|
||||
|
||||
@@ -13,7 +13,7 @@ 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.service import DEFAULT_BAS_TASK, PLATFORM_AXES, clamp_bas_task
|
||||
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
|
||||
|
||||
#: Connection modes, in the order the combo offers them.
|
||||
@@ -155,6 +155,11 @@ def default_preferences() -> dict[str, Any]:
|
||||
# Remembered so "download the BAS program" is one click after the first
|
||||
# time, instead of re-picking the file every session.
|
||||
"bas_path": "",
|
||||
#: The controller task the BAS program runs on. The vendor reference
|
||||
#: starts it on 4 and re-initialises it with ``RUN "<file>",4``; stopping
|
||||
#: any other task restarts nothing. Kept configurable because the task
|
||||
#: number is a property of the project loaded on the controller.
|
||||
"bas_task": DEFAULT_BAS_TASK,
|
||||
#: 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,
|
||||
@@ -199,6 +204,8 @@ def load_preferences() -> dict[str, Any]:
|
||||
axis_params = prefs.setdefault("axis_params", {})
|
||||
for axis, values in _default_axis_params().items():
|
||||
axis_params.setdefault(axis, values)
|
||||
# A hand-edited task number must not reach the controller as typed.
|
||||
prefs["bas_task"] = clamp_bas_task(prefs.get("bas_task"))
|
||||
return prefs
|
||||
|
||||
|
||||
|
||||
@@ -146,13 +146,17 @@ class ConnectionPanel(QGroupBox):
|
||||
self.address_edit.setText("")
|
||||
|
||||
def set_ips(self, ips: list[str]) -> None:
|
||||
"""Offer scanned addresses for the Ethernet mode."""
|
||||
"""Offer scanned addresses for the Ethernet mode.
|
||||
|
||||
The hint is shown whatever the address box currently holds: a scan that
|
||||
found the controller is exactly the moment the operator wants to see the
|
||||
address, and suppressing it because the box already held a different one
|
||||
made the button look broken. The box itself is never overwritten, so a
|
||||
typed address survives a scan.
|
||||
"""
|
||||
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]))
|
||||
self.ip_hint.setText(" ".join(str(ip) for ip in ips[:4]))
|
||||
|
||||
def set_connected(self, connected: bool, source: str = "") -> None:
|
||||
self.open_button.setEnabled(not connected)
|
||||
|
||||
@@ -40,6 +40,7 @@ from PySide6.QtWidgets import (
|
||||
from .. import __version__ as app_version
|
||||
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
|
||||
from ..motion.kinematics import PlatformStack
|
||||
from ..motion.service import clamp_bas_task
|
||||
from ..preferences import load_preferences, save_preferences
|
||||
from ..sensor import BundleError, load_bundle, load_uv_texture, rasterise, texture_path_for
|
||||
from ..sensor.uv_flat import DEFAULT_FLAT_SIZE
|
||||
@@ -655,7 +656,10 @@ class AutoCalMainWindow(QMainWindow):
|
||||
|
||||
def _build_poller(self) -> None:
|
||||
self.poller = MotionPoller(
|
||||
MotionService(language=self._language),
|
||||
MotionService(
|
||||
language=self._language,
|
||||
bas_task=clamp_bas_task(self.prefs.get("bas_task")),
|
||||
),
|
||||
axes=range(self.axis_panel.axis_count),
|
||||
)
|
||||
self.poller.snapshotReady.connect(self._on_snapshot)
|
||||
@@ -679,7 +683,12 @@ class AutoCalMainWindow(QMainWindow):
|
||||
) -> 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)
|
||||
# The remembered BAS file travels with the connect so that a session
|
||||
# which adopts a program already running on the controller can still
|
||||
# re-initialise it (the controller does not report the file name).
|
||||
token = self.poller.connect_controller(
|
||||
target, bas_path=str(self.prefs.get("bas_path") or "")
|
||||
)
|
||||
self._pending[token] = label
|
||||
self.connection_panel.set_status(t("connection.state_connecting"))
|
||||
return token
|
||||
@@ -770,7 +779,12 @@ class AutoCalMainWindow(QMainWindow):
|
||||
message, ok = _interpret_result(result)
|
||||
if ok:
|
||||
self._last_error = ""
|
||||
if message:
|
||||
if label_key == "connection.scan" and isinstance(result, (list, tuple)):
|
||||
# A scan exists to put the addresses somewhere the operator can
|
||||
# click; reporting "OK" with the list only in the status bar
|
||||
# leaves the IP box untouched.
|
||||
self.connection_panel.set_ips([str(item) for item in result])
|
||||
elif message:
|
||||
self.bus_data_panel.set_result(message)
|
||||
text = f"{name} {t('action.ok')}".strip()
|
||||
self.statusBar().showMessage(text)
|
||||
|
||||
Reference in New Issue
Block a user