Add generic joystick support as input #110
Conversation
kingb
left a comment
There was a problem hiding this comment.
Thanks for putting this together — the host-side evdev path is well-isolated and the threading model looks right. Net: approve with one must-fix and a handful of smaller items.
Single biggest concern, calling out at the top because it isn't obvious from the diff:
⚠️ Behavior change in --task.use-joystick
Before this PR, --task.use-joystick resolved to velocity_input = state_input = "interface" — i.e. the SDK wireless controller (the dongle/controller shipped with Unitree G1, Booster T1, etc.). After this PR it resolves to "joystick" → UsbJoystickInput reading /dev/input/event*.
For a user running on a real G1 with the Unitree controller and no USB gamepad, the same command they ran yesterday will now error out with RuntimeError: No USB joystick found via evdev. This is a silent semantic flip of a user-facing flag, and I want to make sure it's intentional before merge.
Questions / suggestions:
-
Is this intentional? If the goal is "on Thor, default to a host-side gamepad," that's a reasonable design — but it should be a deliberate decision, not a side effect.
-
The old behavior is still reachable via
--task.velocity-input interface --task.state-input interface, but nothing surfaces that to the user. -
Suggestion — keep the new
"joystick"source, but pick one of:- Have
--task.use-joystickfirst tryUsbJoystickInput, and if no USB gamepad is present, fall back to"interface". (Mirrors the existing darwin → keyboard fallback in_init_joystick_handler.) - Add an explicit
--task.use-sdk-controllershortcut for the old behavior, keep--task.use-joystickmeaning what it now means, and add a release note. - Or rename the new shortcut to
--task.use-usb-joystickand leave--task.use-joystickpointing at"interface".
Whatever we pick, the PR description and the
TaskConfig.use_joystickdocstring should call out the change. - Have
Must-fix
- Test will fail to collect on macOS —
usb_joystick.pydoesimport evdevat module top level, andevdevonly builds on Linux. The newtest_joystick_maps_to_usb_joystickimports the module unconditionally. Wrap withpytest.importorskip("evdev")or defer theimport evdevinsideusb_joystick. (Inline.) - Negative
device_indexis silently accepted and indexes from the end. (Inline.)
Should-fix
- Device identity is fragile across replugs —
_list_gamepads()orders byevdev.list_devices()(alphabetical over/dev/input/event*), so today's index 0 may be tomorrow's index 1.dev.uniq(potentially populated with a serial / BT MAC) is the right primitive when available; see inline. start()is a no-op because the thread is started in__init__— breaks the protocol's lifecycle contract._KEY_LABELdoesn't includeF1(keyboard fallback, fine) or lowercasex(looks like a typo inJOYSTICK_COMMANDS). Pre-existing injoystick.py, worth a follow-up.
Out-of-scope / future-CR candidates
STICK_DEADZONE = 0.1now duplicated here +interface.py. Pull intoinputs/impl/joystick.pynext toJOYSTICK_COMMANDS.joystick_type: str = "xbox"is unused — either wire it through (per-controller layout) or drop it.- The stick-suppression-while-button-held behavior is inherited from
InterfaceInput; the rationale there was that the SDK wire format couples sticks + keys in one frame, which doesn't apply to evdev. Current behavior freezes commanded velocity mid-chord — benign but not necessarily what the user wants. Worth re-examining together withInterfaceInput.
Nit
- PR description says "Tested with Xbox controller" — please add the model (Series X? 360? wired vs wireless via dongle?). Different generations expose different evdev codes especially on triggers.
| flag_name: str | None = None | ||
| if self.use_joystick: | ||
| shortcut = "interface" | ||
| shortcut = "joystick" |
There was a problem hiding this comment.
Behavior change — see the top-level review comment. This single character change ("interface" → "joystick") silently flips the meaning of --task.use-joystick for every existing G1/T1 user from "SDK wireless controller" to "USB gamepad on the host." Please confirm intent and either:
- add a fallback so
"joystick"reverts to"interface"when no USB gamepad is present, or - preserve
--task.use-joystick → "interface"and add a separate--task.use-usb-joystickshortcut for the new behavior.
Whichever direction we pick, please update the use_joystick docstring just above to spell it out.
| if source == "joystick": | ||
| from holosoma_inference.inputs.impl.usb_joystick import UsbJoystickInput | ||
|
|
||
| return UsbJoystickInput(device_index=policy.config.task.joystick_device) |
There was a problem hiding this comment.
Lazy-importing UsbJoystickInput here is the right call — it keeps macOS users (where evdev doesn't build) able to import this module as long as they don't request "joystick". ✓
One thing to watch: the test in inputs/tests/test_providers.py does from holosoma_inference.inputs.impl import usb_joystick at the top of the test method, which will fail to collect on macOS regardless. Suggest pytest.importorskip("evdev") there.
|
|
||
| import threading | ||
|
|
||
| import evdev |
There was a problem hiding this comment.
macOS test-collection failure: importing this module on macOS crashes at this line because evdev only builds on Linux (it links against <linux/input.h>). The lazy import in inputs/__init__.py keeps production code safe, but tests/test_providers.py::test_joystick_maps_to_usb_joystick does from holosoma_inference.inputs.impl import usb_joystick unconditionally, which will fail collection on darwin.
Two options:
- Easier: at the top of the test,
pytest.importorskip("evdev"). - More invasive: defer
import evdevto inside the function bodies that use it, so this module can be imported on darwin (just not instantiated).
Option 1 is sufficient.
| dev.close() | ||
| continue | ||
| abs_codes = {code for code, _ in caps[evdev.ecodes.EV_ABS]} | ||
| if {evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y, evdev.ecodes.ABS_RX} <= abs_codes: |
There was a problem hiding this comment.
Requiring ABS_RX excludes single-stick / flight-style controllers but is fine for Xbox/Logitech-class gamepads. Worth a docstring note that this is gamepad-only by design — otherwise the next debug session for "my controller wasn't detected" goes through this whole function.
| candidates.append(dev) | ||
| else: | ||
| dev.close() | ||
| return candidates |
There was a problem hiding this comment.
Device identity is fragile. evdev.list_devices() returns /dev/input/event* in alphabetical order, which is not stable across reboots/replugs. Today's joystick_device=0 could be tomorrow's =1.
For a single-controller setup it's fine; for multi-controller or robust-replug setups it's a footgun.
Suggestion (potentially future-CR): add an optional joystick_device_id: str = "" config field with this match order:
dev.uniqexact match (when populated, this is the closest evdev equivalent to a MAC — Bluetooth gamepads almost always have it; some wired USB gamepads do too if the vendor bothered with serials).- Substring match against the
/dev/input/by-id/usb-VENDOR_PRODUCT_SERIAL-event-joysticksymlink target. - Substring match against
dev.name(warn on >1 match). - Fall back to
joystick_deviceindex whenjoystick_device_idis empty (today's behavior).
dev.uniq is the strongest primitive when populated — worth exploring even if we don't ship the full chain in this PR.
| if cmd is not None: | ||
| commands.append(cmd) | ||
| self._last_label = label | ||
| return commands |
There was a problem hiding this comment.
Edge detection on _last_label can swallow rapid same-chord presses if both the press and release land between two poll_commands() cycles (~policy rl_rate, usually 50Hz). InterfaceInput has the same model and same theoretical bug, so not a regression — just noting that command repeat-rate is bounded by policy rate, not physical button rate.
| span = info.max - info.min | ||
| if span <= 0: | ||
| return | ||
| normalized = (value - info.min) / span * 2.0 - 1.0 # → [-1, 1] |
There was a problem hiding this comment.
Operator precedence: (value - info.min) / span * 2.0 - 1.0 evaluates as ((value - info.min) / span) * 2.0 - 1.0 — correct, but adding outer parens reads more clearly:
normalized = ((value - info.min) / span) * 2.0 - 1.0| elif code == evdev.ecodes.ABS_Z: # analog L2 | ||
| self._set_bit(_BIT_L2, value > TRIGGER_THRESHOLD) | ||
| elif code == evdev.ecodes.ABS_RZ: # analog R2 | ||
| self._set_bit(_BIT_R2, value > TRIGGER_THRESHOLD) |
There was a problem hiding this comment.
TRIGGER_THRESHOLD = 128 assumes an unsigned 0–255 range. Some controllers (especially older Xbox 360 wired pads) report ABS_Z/ABS_RZ as signed -32768..32767, in which case value > 128 is true for the entire "trigger pressed past quarter" range including the resting position on some pads.
Safer: read info.min / info.max from dev.capabilities()[EV_ABS] and threshold at min + (max - min) * 0.5. Out of scope for this PR if Xbox-Series-X-only is the immediate target, but worth a TODO.
| from holosoma_inference.inputs.impl import usb_joystick | ||
|
|
||
| sentinel = MagicMock() | ||
| monkeypatch.setattr(usb_joystick, "UsbJoystickInput", lambda **_: sentinel) |
There was a problem hiding this comment.
This unconditional import of usb_joystick will fail collection on macOS (where evdev doesn't build). Wrap with:
import pytest
pytest.importorskip("evdev")
from holosoma_inference.inputs.impl import usb_joystickor at the class level if other tests in this class touch the same module.
| vel = self.config.task.velocity_input | ||
| other = self.config.task.state_input | ||
| need_joystick = bool({"interface", "joystick"} & {vel, other}) | ||
| need_joystick = "interface" in {vel, other} |
There was a problem hiding this comment.
This is the right semantic split — the SDK's wireless-controller path is only needed for "interface" channels now. ✓
Worth confirming on real hardware that a policy started with --task.velocity-input interface (no joystick channel) but use_joystick=True on the SDK still passes joystick-key events through correctly. The need_joystick boolean's meaning to create_interface is unchanged, but the set of code paths exercising it shrunk.
This PR adds joystick controller as an input for
holosoma_inference. Tested with Xbox controller.