Files
dora-teleop/tests/test_joy.py
T
cristhian 7a0847893c fix gamepad layout and refuse a pad whose axes disagree
An Xbox One S pad over Bluetooth numbers its controls differently from the
indices this node shipped with: the right stick is on axes 2 and 3, the
triggers on 4 and 5, and the buttons carry gaps at 2 and 5. Against the old
layout an untouched pad reported a resting trigger as full right-stick
deflection, which asked for 30 deg/s of pitch while lying on the desk, held
the lease for ever so GAMEPAD_IDLE_RELEASE_S never fired, and moved a real
arm into a controller error. X toggled freedrive and LB was the E-STOP, so
the resulting latch could not be released from the pad at all.

Layout now carries the swept indices. Each pad is also measured the first
time it is seen at rest: a stick must rest near centre and a trigger near
the bottom of its travel, and a pad that does not fit reads as absent and is
named in the log rather than driving an arm from a numbering nobody checked.
The measured rest becomes each axis's zero, so a worn stick's tilt is
subtracted rather than deadzoned away and a trigger that reads 0 until it
first moves stops reading as half pressed.

Rest is never re-measured while the pad is in use, because doing that under
a held stick would record the deflection as centre and the arm would move
when the operator let go. The probe drives from the same calibration, so
what it prints is still what the arm would get, and it shows the mismatch.

Tests name their controls through axes_for and buttons_for instead of
numbering them; the positional lists are why a wrong index passed a green
suite.
2026-09-16 18:06:06 -03:00

151 lines
5.2 KiB
Python

"""The pad as it arrives on the wire, and what silence on that wire means."""
import pyarrow as pa
import pytest
from dora_teleop import mapping
from dora_teleop.joy import ABSENT, JoyInput, Sample, decode
from dora_teleop.mapping import LAYOUT
from tests.conftest import axes_for, input_event, joy_message, stick
def joy_event(sample):
return input_event("joy", joy_message(sample))
def test_a_reading_survives_the_round_trip():
sample = decode(joy_event(stick(axes=[0.5, -0.5], buttons=[False, True])))
assert sample.axes[:2] == [0.5, -0.5]
assert sample.buttons == [False, True]
assert sample.present is True
def test_an_absent_pad_decodes_as_absent_not_as_centred():
sample = decode(joy_event(ABSENT))
assert sample.present is False
assert sample.axes == []
def test_a_pad_with_fewer_axes_than_the_layout_decodes_without_complaint():
# A three-axis flight stick is a strange thing to drive an arm with, but it
# must not take the node down; `mapping.axis` treats a missing one as centred.
sample = decode(joy_event(Sample(axes=[0.0, 0.0, 0.0], buttons=[False], present=True)))
assert len(sample.axes) == 3
def test_a_message_with_no_rows_reads_as_absent():
empty = input_event("joy", pa.array([], type=joy_message(ABSENT).type))
assert decode(empty) is ABSENT
def test_a_fresh_reading_is_handed_back():
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(), 100.0)
joy.accept(stick(axes=[0.5]), 100.0)
assert joy.read(100.1).axes[0] == 0.5
def test_a_reading_nobody_is_refreshing_reads_as_absent():
# dora-joy publishes every tick, so silence means it died — and a dead pad
# node is indistinguishable from an unplugged pad as far as the arm cares.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(), 100.0)
joy.accept(stick(axes=[0.5]), 100.0)
assert joy.read(100.5) is ABSENT
# --- the pad this node is actually holding ---
def test_a_pad_is_measured_where_it_rests_and_the_tilt_is_subtracted():
# A stick that no longer centres is the ordinary kind of worn pad. Its rest
# becomes zero, so it asks for nothing until it is actually pushed.
joy = JoyInput(ttl_s=0.2)
tilted = axes_for(left_x=0.2, left_y=-0.15)
joy.accept(stick(axes=tilted), 100.0)
reading = joy.read(100.1)
assert reading.present is True
assert reading.axes[LAYOUT.left_x] == pytest.approx(0.0)
assert reading.axes[LAYOUT.left_y] == pytest.approx(0.0)
assert mapping.is_active(reading) is False
def test_the_tilt_is_subtracted_rather_than_deadzoned_so_full_travel_remains():
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(axes=axes_for(left_x=0.2)), 100.0)
joy.accept(stick(axes=axes_for(left_x=1.0)), 100.0)
assert joy.read(100.1).axes[LAYOUT.left_x] == pytest.approx(0.8)
def test_a_trigger_that_rests_at_zero_does_not_read_as_half_pressed():
# Some pads report a trigger as 0 until it first moves. Read as SDL's -1..1
# that is half travel, which would lift the arm on its own.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(axes=axes_for(right_trigger=0.0)), 100.0)
reading = joy.read(100.1)
assert mapping.trigger(reading.axes[LAYOUT.right_trigger]) == pytest.approx(0.0)
assert mapping.is_active(reading) is False
def test_a_pad_that_does_not_fit_the_layout_reads_as_absent():
# The bug this exists for: a trigger on an index the layout calls a stick
# rests at -1.0, which is full deflection — an untouched pad on the desk
# asking for full speed, and holding the lease so nothing can take it back.
joy = JoyInput(ttl_s=0.2)
wrong = list(axes_for())
wrong[LAYOUT.right_y] = -1.0
joy.accept(stick(axes=wrong), 100.0)
assert joy.read(100.1) is ABSENT
assert "right stick Y" in joy.calibration.mismatch
def test_a_pad_held_when_it_appears_is_refused_until_it_is_let_go():
# A stick held at startup and a wrongly numbered axis look identical in one
# reading, so the safe answer is the same for both: no arm until it rests.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(axes=axes_for(left_y=-1.0)), 100.0)
assert joy.read(100.1) is ABSENT
joy.accept(stick(), 100.2)
assert joy.read(100.3).present is True
def test_rest_is_not_re_measured_while_the_pad_is_in_use():
# Re-measuring under a held stick would record that deflection as centre,
# and the arm would then move the moment the operator let go.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(), 100.0)
joy.accept(stick(axes=axes_for(left_y=-1.0)), 100.1)
joy.accept(stick(axes=axes_for(left_y=-1.0)), 100.2)
assert joy.read(100.3).axes[LAYOUT.left_y] == pytest.approx(-1.0)
def test_a_pad_that_goes_away_is_measured_again_when_it_comes_back():
# The second pad is not the first one, and may not rest where it did.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(axes=axes_for(left_x=0.2)), 100.0)
joy.accept(Sample(present=False), 100.1)
joy.accept(stick(axes=axes_for(left_x=-0.2)), 100.2)
assert joy.read(100.3).axes[LAYOUT.left_x] == pytest.approx(0.0)
def test_nothing_ever_arriving_reads_as_absent():
# A dataflow with no `joy` wired at all, which is a browser-only console.
assert JoyInput(ttl_s=0.2).read(100.0) is ABSENT