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())
|
||||
Reference in New Issue
Block a user