add xArm driver layer ported from littlehand
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.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""Reading the environment, and refusing a setting the node cannot use."""
|
||||
|
||||
import pytest
|
||||
|
||||
from dora_ulite6.config import Config, env_float, env_int
|
||||
from dora_ulite6.effectors import (
|
||||
EFFECTORS,
|
||||
GripperEffector,
|
||||
NoEffector,
|
||||
VacuumEffector,
|
||||
get_effector,
|
||||
)
|
||||
from dora_ulite6.errors import EffectorUnsupportedError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_env(monkeypatch):
|
||||
"""Run every test against a bare environment, whatever the shell set."""
|
||||
for name in ("ROBOT_IP", "TOOL_TYPE", "TOOL_OFFSET_MM", "INIT_MODE", "INIT_Z", "DEFAULT_SPEED"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
def test_the_defaults_need_no_environment():
|
||||
config = Config.from_env()
|
||||
|
||||
assert config.robot_ip == "192.168.1.192"
|
||||
assert isinstance(config.effector, NoEffector)
|
||||
assert config.tool_offset_mm == 0.0
|
||||
assert config.init_mode == "none"
|
||||
|
||||
|
||||
def test_the_tool_offset_is_the_tools_own_plus_the_extra(monkeypatch):
|
||||
monkeypatch.setenv("TOOL_TYPE", "vacuum")
|
||||
monkeypatch.setenv("TOOL_OFFSET_MM", "4.5")
|
||||
|
||||
config = Config.from_env()
|
||||
|
||||
assert isinstance(config.effector, VacuumEffector)
|
||||
assert config.tool_offset_mm == pytest.approx(VacuumEffector.offset_mm + 4.5)
|
||||
|
||||
|
||||
def test_an_unknown_tool_is_refused_at_startup(monkeypatch):
|
||||
monkeypatch.setenv("TOOL_TYPE", "magnet")
|
||||
|
||||
with pytest.raises(ValueError, match="vacuum"):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
def test_an_unknown_init_mode_is_refused_at_startup(monkeypatch):
|
||||
# Caught here rather than at the first motion, so the node never joins the
|
||||
# dataflow with a startup move nobody can name.
|
||||
monkeypatch.setenv("INIT_MODE", "somewhere")
|
||||
|
||||
with pytest.raises(ValueError, match="INIT_MODE"):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
def test_init_pose_components_are_read(monkeypatch):
|
||||
monkeypatch.setenv("INIT_MODE", "pose")
|
||||
monkeypatch.setenv("INIT_Z", "180")
|
||||
|
||||
assert Config.from_env().init_pose.z == 180.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", " "])
|
||||
def test_a_blank_variable_falls_back_to_the_default(monkeypatch, value):
|
||||
monkeypatch.setenv("DEFAULT_SPEED", value)
|
||||
|
||||
assert env_float("DEFAULT_SPEED", 30.0) == 30.0
|
||||
|
||||
|
||||
def test_a_number_that_is_not_one_names_itself(monkeypatch):
|
||||
monkeypatch.setenv("PATH_SAMPLES", "four")
|
||||
|
||||
with pytest.raises(ValueError, match="PATH_SAMPLES"):
|
||||
env_int("PATH_SAMPLES", 4)
|
||||
|
||||
|
||||
def test_a_bare_flange_refuses_to_grasp():
|
||||
with pytest.raises(EffectorUnsupportedError):
|
||||
NoEffector().attach(object())
|
||||
|
||||
|
||||
def test_an_effector_is_refused_by_a_driver_that_cannot_switch_it():
|
||||
class ArmWithNoTools:
|
||||
pass
|
||||
|
||||
with pytest.raises(EffectorUnsupportedError, match="set_vacuum"):
|
||||
VacuumEffector().verify(ArmWithNoTools())
|
||||
|
||||
|
||||
def test_each_effector_switches_the_driver_method_it_declares():
|
||||
switched = []
|
||||
|
||||
class Arm:
|
||||
def set_gripper(self, closed):
|
||||
switched.append(closed)
|
||||
|
||||
arm = Arm()
|
||||
effector = get_effector("gripper")
|
||||
effector.verify(arm)
|
||||
effector.attach(arm)
|
||||
effector.detach(arm)
|
||||
|
||||
assert isinstance(effector, GripperEffector)
|
||||
assert switched == [True, False]
|
||||
|
||||
|
||||
def test_every_registered_effector_declares_a_reach():
|
||||
assert all(effector.offset_mm >= 0 for effector in EFFECTORS.values())
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Pose maths and the tool-tip offset. No robot needed."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from dora_ulite6.controller import interpolate, shortest_turn
|
||||
from dora_ulite6.pose import RobotPose, with_tool_offset
|
||||
|
||||
|
||||
def test_from_sequence_accepts_a_position_alone():
|
||||
pose = RobotPose.from_sequence([10, 20, 30])
|
||||
|
||||
assert pose.as_list() == [10, 20, 30, 180.0, 0.0, 0.0]
|
||||
|
||||
|
||||
def test_from_sequence_round_trips_a_full_pose():
|
||||
values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
|
||||
|
||||
assert RobotPose.from_sequence(values).as_list() == values
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("start", "target", "expected"),
|
||||
[(-178.0, 180.0, -2.0), (180.0, -178.0, 2.0), (0.0, 90.0, 90.0), (0.0, 270.0, -90.0)],
|
||||
)
|
||||
def test_shortest_turn_never_takes_the_long_way(start, target, expected):
|
||||
assert shortest_turn(start, target) == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_interpolate_turns_the_short_way_across_the_wrap():
|
||||
halfway = interpolate([0, 0, 0, -178, 0, 0], [0, 0, 0, 180, 0, 0], 0.5)
|
||||
|
||||
assert halfway[3] == pytest.approx(-179.0)
|
||||
|
||||
|
||||
def test_interpolate_walks_the_straight_line_in_position():
|
||||
halfway = interpolate([0, 0, 0, 180, 0, 0], [100, 200, 300, 180, 0, 0], 0.5)
|
||||
|
||||
assert halfway[:3] == pytest.approx([50, 100, 150])
|
||||
|
||||
|
||||
def test_tool_down_offset_lowers_the_tip():
|
||||
tip = with_tool_offset(RobotPose(300, 0, 250, roll=180, pitch=0, yaw=0), 61.0)
|
||||
|
||||
assert tip.z == pytest.approx(250 - 61.0)
|
||||
assert (tip.x, tip.y) == pytest.approx((300, 0))
|
||||
|
||||
|
||||
def test_offset_follows_the_tool_axis_not_the_base_axis():
|
||||
# Pitched 90° from tool-down, the tool points along +x rather than -z.
|
||||
tip = with_tool_offset(RobotPose(300, 0, 250, roll=0, pitch=90, yaw=0), 61.0)
|
||||
|
||||
assert tip.x == pytest.approx(300 + 61.0)
|
||||
assert tip.z == pytest.approx(250)
|
||||
|
||||
|
||||
def test_a_zero_offset_returns_the_pose_untouched():
|
||||
pose = RobotPose(1, 2, 3, 4, 5, 6)
|
||||
|
||||
assert with_tool_offset(pose, 0.0) is pose
|
||||
|
||||
|
||||
def test_the_offset_inverts_exactly():
|
||||
# move_to relies on this: it takes the offset back off a tip pose to get the
|
||||
# flange pose the controller wants.
|
||||
pose = RobotPose(123, -45, 210, roll=170, pitch=12, yaw=-30)
|
||||
|
||||
there = with_tool_offset(pose, 61.0)
|
||||
back = with_tool_offset(there, -61.0)
|
||||
|
||||
np.testing.assert_allclose(back.as_list(), pose.as_list(), atol=1e-9)
|
||||
Reference in New Issue
Block a user