#!/usr/bin/env python3 """ keyboard_controller.py – simple WASD-style tele-op for Unitree G-1. The script: 1. Runs the hanger boot sequence so the robot starts balancing. 2. Enters a curses UI where you can drive with the keys below. Controls --------- W / S : forward / backward velocity A / D : yaw left / right (turn) Q / E : lateral left / right (optional, G-1 supports side-step) Space : stop (zero velocities) Z : Damp (soft) and exit Esc : emergency stop & exit (ZeroTorque) Velocities are applied continuously – every key-press adjusts the target values which are sent to the robot at 10 Hz. """ from __future__ import annotations import argparse # ------------------------------------------------------------------------ # Notes on the 2025-04-28 update # ------------------------------------------------------------------------ # # * Continuous command _while a key is physically held_. As soon as the key is # released the corresponding velocity is reset to **zero**. # * Supports holding several keys together – e.g. **W + A** to move forward # while turning left. # # This requires real “key-up” events which the `curses` module cannot provide. # We now use the lightweight third-party `pynput` package to poll the current # key state. It communicates with the X-server (Linux), Win32, or Quartz and # therefore works for normal users on typical desktop sessions (no sudo). # Curses is kept only for drawing the tiny on-screen HUD. # # When running under Wayland `pynput` might still fall back to reading # `/dev/input/event*`; in that corner-case you’d again need the permissions or # group/udev tweaks previously mentioned. # ------------------------------------------------------------------------ import time # Third-party --------------------------------------------------------------- import curses # We now use the cross-platform `pynput` library which reads key events via the # X server / Win32 API / Quartz, so it works unprivileged on desktop Linux, # macOS and Windows. On Wayland sessions `pynput` falls back to /dev/input and # may again need the permissions discussed earlier—but on X11 (the default on # many distros) it works out-of-the-box. try: from pynput.keyboard import Listener, Key, KeyCode # type: ignore except ModuleNotFoundError as exc: # pragma: no cover raise SystemExit( "The 'pynput' package is required for keyboard_controller.py.\n" "Install with: pip install pynput" ) from exc from hanger_boot_sequence import hanger_boot_sequence # --------------------------------------------------------------------------- # Parameter defaults # --------------------------------------------------------------------------- LIN_STEP = 0.05 # m/s per press ANG_STEP = 0.2 # rad/s per press SEND_PERIOD = 0.1 # seconds (10 Hz) def clamp(value: float, limit: float = 0.6) -> float: return max(-limit, min(limit, value)) def drive_loop(stdscr: "curses._CursesWindow", bot) -> None: # Curses setup for a tiny on–screen HUD. curses.cbreak() stdscr.nodelay(True) # Make getch() non-blocking so the UI stays alive. # ------------------------------------------------------------------ # Internal state # ------------------------------------------------------------------ vx = vy = omega = 0.0 # target velocities that will be sent to the robot last_send = 0.0 # ------------------------------------------------------------------ # Keyboard listener setup (pynput) # ------------------------------------------------------------------ pressed_keys: set[object] = set() # holds Key / single-char strings def _on_press(key): # noqa: D401 – tiny helper """Callback – store the key object / char in *pressed_keys*.""" if isinstance(key, KeyCode) and key.char is not None: pressed_keys.add(key.char.lower()) else: pressed_keys.add(key) def _on_release(key): if isinstance(key, KeyCode) and key.char is not None: pressed_keys.discard(key.char.lower()) else: pressed_keys.discard(key) listener = Listener(on_press=_on_press, on_release=_on_release) listener.start() def key(name: str) -> bool: # helper similar to keyboard.is_pressed if name == "space": return Key.space in pressed_keys if name == "esc": return Key.esc in pressed_keys return name in pressed_keys try: while True: # ------------------------------------------------------------------ # 1. Build/refresh the target velocity based on current key states. # ------------------------------------------------------------------ if key("w") and not key("s"): vx = clamp(vx + LIN_STEP) elif key("s") and not key("w"): vx = clamp(vx - LIN_STEP) else: vx = 0.0 if key("q") and not key("e"): vy = clamp(vy + LIN_STEP) elif key("e") and not key("q"): vy = clamp(vy - LIN_STEP) else: vy = 0.0 if key("a") and not key("d"): omega = clamp(omega + ANG_STEP) elif key("d") and not key("a"): omega = clamp(omega - ANG_STEP) else: omega = 0.0 # Space bar – an emergency stop of sorts that zeroes everything no # matter what other keys are held. if key("space"): vx = vy = omega = 0.0 # ------------------------------------------------------------------ # 2. Handle exit conditions. # ------------------------------------------------------------------ if key("z"): bot.Damp() break if key("esc"): bot.StopMove() bot.ZeroTorque() break # ------------------------------------------------------------------ # 3. Send the command at the configured rate and update HUD. # ------------------------------------------------------------------ now = time.time() if now - last_send >= SEND_PERIOD: bot.Move(vx, vy, omega, continous_move=True) last_send = now stdscr.erase() stdscr.addstr(0, 0, "Hold keys to drive – Z: quit ESC: e-stop") stdscr.addstr(2, 0, f"vx: {vx:+.2f} vy: {vy:+.2f} omega: {omega:+.2f}") stdscr.refresh() # A very small sleep keeps CPU usage civilised (<1 % on typical PCs). time.sleep(0.005) finally: # Ensure the listener thread is stopped before leaving curses context. listener.stop() def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--iface", default="enp3s0", help="network interface connected to robot") args = parser.parse_args() # Boot sequence – returns initialised LocoClient in FSM-200 bot = hanger_boot_sequence(iface=args.iface) curses.wrapper(drive_loop, bot) if __name__ == "__main__": try: main() except KeyboardInterrupt: print("\nInterrupted – sending Damp …") try: bot.Damp() # type: ignore[name-defined] except Exception: pass