Files
dora-ulite6/dora_ulite6/config.py
cristhian a95d804f0b
validate / validate (pull_request) Successful in 58m41s
validate / test (pull_request) Successful in 58m50s
validate / markdownlint (pull_request) Successful in 58m39s
fix jogging stepping by holding one velocity instead of re-arming
`set_cartesian_velocity` passed `duration=velocity_command_ttl_s` on every
message, so each one re-armed a timed segment and the controller re-planned
the same motion. On a real Lite6 that is not smooth travel: the arm moves in
steps. Reducing how often the velocity is sent does not help — re-arming four
times a second still steps — so it now uses `duration=0`, the SDK's "always
effective, will not stop automatically", and skips a velocity that has not
changed. One command stands until a different one replaces it.

That removes the controller-side expiry, which `_expire_jog` was relying on:
it flipped `_jogging` and reported `ready` without commanding anything,
because the controller's own timer had already stopped the arm. With the
velocity held indefinitely that would leave an arm travelling while the node
reported idle, so `_expire_jog` now queues the stop itself. It goes through
the job queue rather than the driver, since it runs on the reporter thread
and the control thread owns the driver.

`VELOCITY_COMMAND_TTL_S` is unchanged in value and meaning to a caller, but
it is now enforced here rather than by the controller.

Both halves are one commit on purpose: `duration=0` without the stop in
`_expire_jog` is an arm that never stops when its sender dies.

Adds tests/test_driver.py — the driver had no direct coverage, because
FakeArm replaces it everywhere else, which is why this was invisible to the
suite.
2026-09-16 14:27:46 -03:00

132 lines
5.4 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
# How long a jog stream may go quiet before this node stops the arm. The
# velocity is set on the controller once and held there indefinitely — see
# `ULite6.set_cartesian_velocity` — so nothing expires it but us: this is
# the deadman behind a sender that dies mid-jog, enforced by `_expire_jog`.
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(),
)