Files
cristhian d336f8d499 read the gamepad from dora-joy and publish twist and command
The pad moves to dora-joy, so this node keeps no hardware dependency and the
pad becomes reusable. It arrives on `joy` as a plain reading; `JoyInput.read`
treats silence past JOY_TTL_S as absence, because that node dying looks like
silence rather than like an unplugged pad and the arm cannot tell them apart.

Both control sources are now normalised to a [-1, 1] six-vector before anything
is scaled, and the scaling happens in one function. The web path used to pass a
velocity straight through without going near mapping, which is why the console
had to speak the arm's units. It no longer does.

`twist` (SI) and `command` (robot-agnostic verbs) are the standard ports. `jog`
and `request` still carry the same intent in dora-ulite6's units and service
names so the two repositories can move independently; LEGACY_REQUEST is the
only place that knows what that node calls a verb. Wire one or the other, never
both. `joint_jog` is specified in the manifest and deliberately not built.

Schemas are declared rather than inferred: pyarrow reads a null `value` as its
own type, so an inferred `command` would change its port's type between one
verb and the next.

Three bugs found while moving this code, each with a test:

- `Sample.active` compared raw axes against a threshold, but SDL rests a
  trigger at -1.0, so every connected pad read as in use for ever: the pad took
  the lease on plug-in and GAMEPAD_IDLE_RELEASE_S never fired.
- The e-stop latch was never consulted when deciding a velocity, so Back
  stopped the arm for one tick and the next stick nudge drove it again. Nothing
  on the pad could clear it either. It is now checked above the lease, and X
  releases it and clears the controller's error, as the console's RESET does.
- Buttons were read before the arbiter granted the lease, so the first press
  after the pad had been idle was swallowed: not owned yet when read, and no
  longer a rising edge by the next tick.
2026-09-16 12:18:51 -05:00

140 lines
4.5 KiB
Python

"""Translation both ways: what the console asks for, and what it is shown."""
from dora_teleop import ui
from dora_teleop.arbiter import GAMEPAD, WEB
# --- what the console asks for ---
def test_a_jog_carries_its_six_numbers():
command = ui.command_of({"type": "jog", "axes": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]})
assert command.type == "jog"
assert command.axes == [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]
def test_a_jog_of_the_wrong_length_is_not_a_jog():
assert ui.command_of({"type": "jog", "axes": [1, 2, 3]}) is None
def test_a_jog_carrying_something_that_is_not_a_number_is_not_a_jog():
assert ui.command_of({"type": "jog", "axes": [1, 2, 3, 4, 5, "fast"]}) is None
def test_a_message_we_have_never_heard_of_is_dropped_not_refused():
# The console and this node version independently; a page from a newer build
# must not take the arm down by saying something we have not learned yet.
assert ui.command_of({"type": "hologram"}) is None
assert ui.command_of({}) is None
assert ui.command_of("not even an object") is None
def test_taking_control_carries_whether_it_may_preempt():
command = ui.command_of({"type": "control", "action": "take", "preempt": True})
assert (command.action, command.preempt) == ("take", True)
def test_the_gripper_button_says_which_way():
assert ui.command_of({"type": "gripper", "closed": True}).enabled is True
assert ui.command_of({"type": "gripper", "closed": False}).enabled is False
def test_a_move_needs_a_position():
assert ui.command_of({"type": "move", "x": 1.0, "y": 2.0}) is None
command = ui.command_of({"type": "move", "x": 250.0, "y": 0.0, "z": 200.0, "speed": 40})
assert command.row == {"x": 250.0, "y": 0.0, "z": 200.0, "speed": 40.0}
# --- what the console is shown ---
def test_an_arm_that_cannot_be_reached_reads_as_disconnected():
view = ui.ArmView(state=ui.UNAVAILABLE)
assert ui.arm_state(view, jogging=False) == ui.UI_DISCONNECTED
def test_an_arm_still_starting_up_reads_as_disconnected():
view = ui.ArmView(state=ui.CONNECTING)
assert ui.arm_state(view, jogging=False) == ui.UI_DISCONNECTED
def test_a_latched_estop_outranks_everything_else():
# The arm may well read ready, and saying so would invite somebody to push it.
view = ui.ArmView(state=ui.READY, estop=True, freedrive_on=True)
assert ui.arm_state(view, jogging=True) == ui.UI_ESTOP
def test_freedrive_outranks_idle():
# An arm that can be pushed by hand is not one holding station.
view = ui.ArmView(state=ui.READY, freedrive_on=True)
assert ui.arm_state(view, jogging=False) == ui.UI_FREEDRIVE
def test_a_moving_arm_we_are_jogging_reads_as_jogging():
view = ui.ArmView(state=ui.MOVING)
assert ui.arm_state(view, jogging=True) == ui.UI_JOGGING
assert ui.arm_state(view, jogging=False) == ui.UI_MOVING
def test_a_recovering_arm_is_there_but_will_not_move():
view = ui.ArmView(state=ui.RECOVERING)
assert ui.arm_state(view, jogging=False) == ui.UI_STOPPED
def test_a_state_we_do_not_know_reads_as_disconnected_rather_than_as_ready():
view = ui.ArmView(state="dancing")
assert ui.arm_state(view, jogging=False) == ui.UI_DISCONNECTED
def test_the_snapshot_carries_every_key_the_page_reads_by_name():
view = ui.ArmView(state=ui.READY, pose=[1, 2, 3, 4, 5, 6], joints=[0] * 6, connected=True)
snapshot = ui.snapshot(view, WEB, jogging=False, timestamp=123.0)
for key in (
"type",
"ts",
"connected",
"ready",
"arm_state",
"owner",
"estop",
"gripper",
"units",
"freedrive",
"position",
"joints",
):
assert key in snapshot
assert snapshot["position"] == {"x": 1, "y": 2, "z": 3, "roll": 4, "pitch": 5, "yaw": 6}
def test_an_arm_with_no_pose_yet_reports_none_rather_than_a_stale_one():
snapshot = ui.snapshot(ui.ArmView(), None, jogging=False, timestamp=0.0)
assert snapshot["position"] is None
assert snapshot["owner"] == "none"
def test_the_owner_is_named_so_the_page_can_gate_its_jog_pad():
assert ui.snapshot(ui.ArmView(), GAMEPAD, False, 0.0)["owner"] == "gamepad"
assert ui.snapshot(ui.ArmView(), WEB, False, 0.0)["owner"] == "web"
def test_get_state_fills_in_what_telemetry_does_not_carry():
view = ui.ArmView()
view.take_get_state({"connected": True, "ready": True, "tool_engaged": True, "freedrive": True})
assert (view.connected, view.ready, view.tool_engaged, view.freedrive_on) == (True, True, True, True)