Brings across the robot layer from monolitic-littlehand-python, which was written against this same controller. The point of it is that a command either moved the arm or raised: the SDK returns a code for a motion the controller quietly refused, wait_move calls that refusal finished, and arm.error_code is the report stream's stale copy. So errors are read live, arrival is confirmed rather than assumed, and a move whose straight-line path leaves the joint envelope is refused before it is commanded. Failures are typed, because the distinctions decide the response: an unreachable pose is not an unavailable arm, and a joint past its limit needs a motion nobody should start without warning the room. read_report is the deliberate exception to all of it: a report-cache read that costs no round trip and cannot block, which is the only read that works while a motion is in flight.
131 lines
5.2 KiB
Python
131 lines
5.2 KiB
Python
"""Everything the node reads from the environment, parsed once at startup.
|
|
|
|
Parsing happens here rather than where each value is used, so an unusable
|
|
setting stops the node before it joins the dataflow instead of surfacing on the
|
|
first motion — the same reason the ZED node parses its depth mode up front.
|
|
"""
|
|
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
|
|
from dora_ulite6.effectors import EndEffector, get_effector
|
|
from dora_ulite6.pose import RobotPose
|
|
|
|
INIT_MODES = ("none", "home", "pose")
|
|
|
|
|
|
def env_str(name: str, default: str) -> str:
|
|
"""Return the variable, or ``default`` when it is unset."""
|
|
return os.getenv(name, default)
|
|
|
|
|
|
def env_float(name: str, default: float) -> float:
|
|
"""Return the variable as a float, naming it if it is not one."""
|
|
value = os.getenv(name)
|
|
if value is None or not value.strip():
|
|
return default
|
|
try:
|
|
return float(value)
|
|
except ValueError:
|
|
raise ValueError(f"{name} must be a number, not {value!r}") from None
|
|
|
|
|
|
def env_int(name: str, default: int) -> int:
|
|
"""Return the variable as an integer, naming it if it is not one."""
|
|
value = os.getenv(name)
|
|
if value is None or not value.strip():
|
|
return default
|
|
try:
|
|
return int(value)
|
|
except ValueError:
|
|
raise ValueError(f"{name} must be an integer, not {value!r}") from None
|
|
|
|
|
|
@dataclass
|
|
class MotionConfig:
|
|
"""Driver-level motion limits and timeouts.
|
|
|
|
The arrival tolerances are wide on purpose: whether a motion happened is
|
|
decided by the controller's error, and these only catch an arm that never
|
|
left where it stood. Tightening them starts refusing moves that worked.
|
|
"""
|
|
|
|
arrival_tolerance_mm: float = 25.0
|
|
arrival_tolerance_deg: float = 15.0
|
|
arrival_timeout_s: float = 2.0
|
|
# How clear of its own bound a joint solution must stay for a pose to be
|
|
# commanded, and how many points along the straight line are checked.
|
|
joint_limit_margin_deg: float = 1.0
|
|
path_samples: int = 4
|
|
ready_timeout_s: float = 3.0
|
|
home_timeout_s: float = 20.0
|
|
tool_settle_s: float = 2.0
|
|
# The controller's own expiry for one jog command: the arm keeps moving at
|
|
# the commanded velocity until this runs out, so a sender that stops sending
|
|
# stops the arm.
|
|
velocity_command_ttl_s: float = 0.5
|
|
|
|
@classmethod
|
|
def from_env(cls) -> "MotionConfig":
|
|
"""Read the motion limits and timeouts from the environment."""
|
|
return cls(
|
|
arrival_tolerance_mm=env_float("ARRIVAL_TOLERANCE_MM", 25.0),
|
|
arrival_tolerance_deg=env_float("ARRIVAL_TOLERANCE_DEG", 15.0),
|
|
arrival_timeout_s=env_float("ARRIVAL_TIMEOUT_S", 2.0),
|
|
joint_limit_margin_deg=env_float("JOINT_LIMIT_MARGIN_DEG", 1.0),
|
|
path_samples=env_int("PATH_SAMPLES", 4),
|
|
ready_timeout_s=env_float("READY_TIMEOUT_S", 3.0),
|
|
home_timeout_s=env_float("HOME_TIMEOUT_S", 20.0),
|
|
tool_settle_s=env_float("TOOL_SETTLE_S", 2.0),
|
|
velocity_command_ttl_s=env_float("VELOCITY_COMMAND_TTL_S", 0.5),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Config:
|
|
"""The node's settings: where the arm is, what it carries, and how it starts."""
|
|
|
|
robot_ip: str = "192.168.1.192"
|
|
effector: EndEffector = field(default_factory=lambda: get_effector("none"))
|
|
# The mounted tool's own reach past the flange, plus TOOL_OFFSET_MM.
|
|
tool_offset_mm: float = 0.0
|
|
default_speed: float = 30.0
|
|
default_joint_speed: float = 20.0
|
|
# How often the control thread re-reads the arm. The dataflow tick only
|
|
# decides how often the newest reading is published.
|
|
telemetry_interval_s: float = 0.1
|
|
# How long to wait before trying an unreachable arm again.
|
|
reconnect_interval_s: float = 5.0
|
|
init_mode: str = "none"
|
|
init_pose: RobotPose = field(default_factory=lambda: RobotPose(300.0, 0.0, 250.0))
|
|
init_speed: float = 50.0
|
|
motion: MotionConfig = field(default_factory=MotionConfig)
|
|
|
|
@classmethod
|
|
def from_env(cls) -> "Config":
|
|
"""Read every setting from the environment, refusing one the node cannot use."""
|
|
effector = get_effector(env_str("TOOL_TYPE", "none").lower())
|
|
init_mode = env_str("INIT_MODE", "none").lower()
|
|
if init_mode not in INIT_MODES:
|
|
raise ValueError(f"INIT_MODE must be one of {', '.join(INIT_MODES)}, not {init_mode!r}")
|
|
return cls(
|
|
robot_ip=env_str("ROBOT_IP", "192.168.1.192"),
|
|
effector=effector,
|
|
tool_offset_mm=effector.offset_mm + env_float("TOOL_OFFSET_MM", 0.0),
|
|
default_speed=env_float("DEFAULT_SPEED", 30.0),
|
|
default_joint_speed=env_float("DEFAULT_JOINT_SPEED", 20.0),
|
|
telemetry_interval_s=env_int("TELEMETRY_INTERVAL_MS", 100) / 1000.0,
|
|
reconnect_interval_s=env_float("RECONNECT_INTERVAL_S", 5.0),
|
|
init_mode=init_mode,
|
|
init_pose=RobotPose(
|
|
x=env_float("INIT_X", 300.0),
|
|
y=env_float("INIT_Y", 0.0),
|
|
z=env_float("INIT_Z", 250.0),
|
|
roll=env_float("INIT_ROLL", 180.0),
|
|
pitch=env_float("INIT_PITCH", 0.0),
|
|
yaw=env_float("INIT_YAW", 0.0),
|
|
),
|
|
init_speed=env_float("INIT_SPEED", 50.0),
|
|
motion=MotionConfig.from_env(),
|
|
)
|