#!/usr/bin/env python3 """ g1_mode_controller.py Interactive terminal controller for Unitree G1 locomotion + basic upper body (waist/arms). Key features: - Menu is generated from a single OPTIONS dictionary (easy to extend). - Ctrl+C / Quit = StopMove only (keep current posture; NO prep/damp). - Status prints LIVE joints (deg) from LowState. - WALK/RUN teleop uses the "old working" keyboard control (pynput). - PREP mode = stand + balance (NO Start/FSM200). - READY/START mode = PREP + Start (FSM 200). - StandUp/Squat/Sit/Lie-to-Stand use SetFsmId() fallback for compatibility. Run: python3 g1_mode_controller.py --iface enp3s0 """ from __future__ import annotations import argparse import sys import time from dataclasses import dataclass from typing import Optional, Dict, Tuple, Callable, Any from threading import Lock, Event from collections import OrderedDict # ---------- Unitree SDK2 ---------- from unitree_sdk2py.core.channel import ChannelFactoryInitialize, ChannelSubscriber from unitree_sdk2py.g1.loco.g1_loco_client import LocoClient from unitree_sdk2py.idl.unitree_hg.msg.dds_ import LowState_ # type: ignore from unitree_sdk2py.comm.motion_switcher.motion_switcher_client import MotionSwitcherClient # Optional teleop keyboard: try: from pynput.keyboard import Listener, Key, KeyCode # type: ignore HAVE_PYNPUT = True except Exception: HAVE_PYNPUT = False # ---------------- Joint map (for status) ---------------- JOINT_NAMES = { 12: "WAIST_YAW", 13: "WAIST_ROLL", 14: "WAIST_PITCH", 15: "L_SHOULDER_PITCH", 16: "L_SHOULDER_ROLL", 17: "L_SHOULDER_YAW", 18: "L_ELBOW", 19: "L_WRIST_ROLL", 20: "L_WRIST_PITCH", 21: "L_WRIST_YAW", 22: "R_SHOULDER_PITCH", 23: "R_SHOULDER_ROLL", 24: "R_SHOULDER_YAW", 25: "R_ELBOW", 26: "R_WRIST_ROLL", 27: "R_WRIST_PITCH", 28: "R_WRIST_YAW", } def rad2deg(x: float) -> float: return float(x) * 180.0 / 3.141592653589793 # ---------------- Live State Monitor ---------------- class LiveStateMonitor: """Subscribes LowState and keeps last joint positions.""" def __init__(self): self.lock = Lock() self.last_q_rad: Dict[int, float] = {} self.msg_count = 0 self.last_rx_time = 0.0 self.first_msg_evt = Event() def cb(self, msg: LowState_): motor_state = getattr(msg, "motor_state", None) if motor_state is None: return with self.lock: self.msg_count += 1 self.last_rx_time = time.time() for idx in JOINT_NAMES.keys(): try: self.last_q_rad[idx] = float(motor_state[idx].q) except Exception: pass self.first_msg_evt.set() def snapshot_deg(self) -> Tuple[int, float, Dict[int, float]]: with self.lock: age = (time.time() - self.last_rx_time) if self.last_rx_time else 1e9 qdeg = {i: rad2deg(q) for i, q in self.last_q_rad.items()} return self.msg_count, age, qdeg # ---------------- Upper body (waist/arms) publisher ---------------- @dataclass class ArmRig: pub: object cmd: object crc: object waist_idx: int = 12 def set_joint(self, idx: int, q: float, kp: float = 60.0, kd: float = 1.5, tau: float = 0.0): mc = self.cmd.motor_cmd[idx] mc.q = float(q) mc.dq = 0.0 mc.tau = float(tau) mc.kp = float(kp) mc.kd = float(kd) def send(self): self.cmd.crc = self.crc.Crc(self.cmd) self.pub.Write(self.cmd) def try_init_arm_sdk() -> Optional[ArmRig]: try: from unitree_sdk2py.core.channel import ChannelPublisher # type: ignore from unitree_sdk2py.idl.unitree_hg.msg.dds_ import LowCmd_ # type: ignore from unitree_sdk2py.idl.default import unitree_hg_msg_dds__LowCmd_ # type: ignore from unitree_sdk2py.utils.crc import CRC # type: ignore pub = ChannelPublisher("rt/arm_sdk", LowCmd_) pub.Init() cmd = unitree_hg_msg_dds__LowCmd_() crc = CRC() # enable arm_sdk mode cmd.motor_cmd[29].q = 1 return ArmRig(pub=pub, cmd=cmd, crc=crc) except Exception as e: print("[arm_sdk] disabled:", e) return None # ---------------- Core helpers ---------------- def init_loco(timeout: float = 10.0) -> LocoClient: bot = LocoClient() bot.SetTimeout(timeout) bot.Init() return bot def init_msc(timeout: float = 5.0) -> MotionSwitcherClient: msc = MotionSwitcherClient() msc.SetTimeout(timeout) msc.Init() return msc def safe_call(name: str, fn: Callable[..., Any], *args, **kwargs) -> Any: """ Unitree python wrappers often return None on success. This helper prints [OK] on no-exception; prints [ERR] on exception. """ try: ret = fn(*args, **kwargs) print(f"[OK] {name}: success (ret={ret})") return ret except Exception as e: code = getattr(e, "code", None) payload = getattr(e, "payload", None) print(f"[ERR] {name} failed: {repr(e)} code={code} payload={payload}") return None def stop_only(bot: Optional[LocoClient]): """Stop walking/teleop velocity but do NOT change mode/posture.""" if bot is None: return safe_call("StopMove", bot.StopMove) def move_cmd(bot: LocoClient, vx: float, vy: float, om: float): """ SDK variations exist. We try a safe call order. """ try: bot.Move(vx, vy, om, True) return except Exception: pass try: bot.Move(vx, vy, om) return except Exception: pass try: bot.Move(vx, vy, om, continuous_move=True) # type: ignore return except Exception as e: print("[ERR] Move failed in all signatures:", repr(e)) def fsm_set(bot: LocoClient, fsm_id: int, label: str): """ Prefer direct methods if present; fallback to SetFsmId(fsm_id). """ if hasattr(bot, label): fn = getattr(bot, label) return safe_call(label, fn) # common fallback in some sdk builds if hasattr(bot, "SetFsmId"): return safe_call(f"{label} (FSM {fsm_id}) via SetFsmId({fsm_id})", bot.SetFsmId, fsm_id) print(f"[ERR] Neither {label} nor SetFsmId exists in this SDK build.") return None def balance_stand(bot: LocoClient, mode: int = 0): """ Some builds require BalanceStand(balance_mode). Others expose SetBalanceMode(0/1). """ if hasattr(bot, "BalanceStand"): try: return safe_call(f"BalanceStand({mode})", bot.BalanceStand, mode) except TypeError: return safe_call("BalanceStand()", bot.BalanceStand) if hasattr(bot, "SetBalanceMode"): return safe_call(f"SetBalanceMode({mode})", bot.SetBalanceMode, mode) print("[WARN] No BalanceStand/SetBalanceMode available in this SDK.") return None # ---------------- PREP / READY sequences ---------------- def prep_mode(bot: LocoClient): """ PREP = stand + balance (NO Start/FSM200). Matches your tested behavior: StopMove -> Damp -> StandUp(FSM4) -> ramp stand height -> BalanceStand(0) -> set final height. """ print("[PREP] stand + balance (NO Start/FSM200) ...") safe_call("StopMove", bot.StopMove) safe_call("Damp", bot.Damp) # StandUp FSM 4 fsm_set(bot, 4, "StandUp") # Stand height ramp (0.02 .. 0.50) if hasattr(bot, "SetStandHeight"): for h in [x / 100.0 for x in range(2, 51, 2)]: safe_call(f"SetStandHeight({h:.2f}m)", bot.SetStandHeight, float(h)) time.sleep(0.03) # set a comfortable final height before balancing safe_call("SetStandHeight(0.22m)", bot.SetStandHeight, 0.22) else: print("[WARN] bot.SetStandHeight not available in this SDK build.") # Balance (static stand) balance_stand(bot, mode=0) # Re-send final height (common trick) if hasattr(bot, "SetStandHeight"): safe_call("SetStandHeight(0.22m)", bot.SetStandHeight, 0.22) print("[PREP] Done. (NO Start)") def ready_start_mode(bot: LocoClient): """ READY/START = PREP + Start (FSM200). """ print("[READY] stand + balance + start (FSM 200) ...") prep_mode(bot) if hasattr(bot, "Start"): safe_call("Start (FSM 200)", bot.Start) else: # some builds: SetFsmId(200) fsm_set(bot, 200, "Start") print("[READY] Done. (FSM200 expected)") # ---------------- Teleop (old keyboard function) ---------------- def teleop_loop(bot: LocoClient, speed_limit: float): if not HAVE_PYNPUT: print("[ERR] pynput not installed. Install: pip install pynput") return LIN_STEP = 0.05 ANG_STEP = 0.2 SEND_PERIOD = 0.10 # 10 Hz pressed = set() def on_press(k): if isinstance(k, KeyCode) and k.char: pressed.add(k.char.lower()) else: pressed.add(k) def on_release(k): if isinstance(k, KeyCode) and k.char: pressed.discard(k.char.lower()) else: pressed.discard(k) def key(name: str) -> bool: if name == "space": return Key.space in pressed if name == "esc": return Key.esc in pressed return name in pressed def clamp(v: float) -> float: return max(-speed_limit, min(speed_limit, v)) vx = vy = om = 0.0 last_send = 0.0 print("\n--- TELEOP ---") print("Hold keys: W/S (vx), Q/E (vy), A/D (yaw), Space (stop). ESC to exit.\n") listener = Listener(on_press=on_press, on_release=on_release) listener.start() try: while True: if key("esc"): stop_only(bot) break 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"): om = clamp(om + ANG_STEP) elif key("d") and not key("a"): om = clamp(om - ANG_STEP) else: om = 0.0 if key("space"): vx = vy = om = 0.0 now = time.time() if now - last_send >= SEND_PERIOD: # enable gait mode if supported (some SDKs require it for walking) if hasattr(bot, "SetBalanceMode"): try: bot.SetBalanceMode(1) except Exception: pass move_cmd(bot, vx, vy, om) last_send = now sys.stdout.write(f"\rvx={vx:+.2f} vy={vy:+.2f} yaw={om:+.2f} ") sys.stdout.flush() time.sleep(0.005) except KeyboardInterrupt: stop_only(bot) print("\n--- teleop interrupted (StopMove only) ---\n") finally: listener.stop() print("\n--- teleop ended ---\n") # ---------------- Presets for arms ---------------- LEFT_DOWN = [ (12, 0.0), (15, +0.211), (16, +0.181), (17, -0.284), (18, +0.672), (19, -0.379), (20, -0.852), (21, -0.019), ] RIGHT_DOWN = [ (12, 0.0), (22, +0.087), (23, -0.271), (24, +0.323), (25, +0.691), (26, +0.240), (27, -0.771), (28, -0.176), ] def apply_pose(arm: ArmRig, pose, kp: float = 60.0, kd: float = 1.5): for j, q in pose: arm.set_joint(j, q, kp=kp, kd=kd, tau=0.0) arm.send() # ---------------- Main ---------------- def main(): ap = argparse.ArgumentParser() ap.add_argument("--iface", required=True, help="NIC connected to G1 (example: enp3s0)") ap.add_argument("--timeout", type=float, default=10.0) ap.add_argument("--topic", default="", help="LowState topic override (default tries rt/lf/lowstate then rt/lowstate)") args = ap.parse_args() # DDS init ONCE ChannelFactoryInitialize(0, args.iface) # Live monitor monitor = LiveStateMonitor() topics = [args.topic] if args.topic else ["rt/lf/lowstate", "rt/lowstate"] sub = None last_err = None for t in topics: try: sub = ChannelSubscriber(t, LowState_) sub.Init(monitor.cb, 200) print(f"[OK] LowState subscribed: {t}") break except Exception as e: last_err = e print(f"[WARN] LowState subscribe failed for {t}: {e}", file=sys.stderr) if sub is None: print(f"[WARN] Could not subscribe LowState. Last error: {last_err}") # Init clients print("Initializing LocoClient...") bot: Optional[LocoClient] = None try: bot = init_loco(timeout=args.timeout) except Exception as e: print("[ERR] Loco init failed:", e) print("Initializing MotionSwitcherClient...") msc: Optional[MotionSwitcherClient] = None try: msc = init_msc(timeout=5.0) status, result = msc.CheckMode() print(f"[MSC] Current mode: {result.get('name') or '(none)'}") except Exception as e: print("[WARN] MotionSwitcher init failed:", e) arm = try_init_arm_sdk() # Snapshot on start if sub is not None and monitor.first_msg_evt.wait(timeout=2.0): cnt, age, qdeg = monitor.snapshot_deg() print("\n[STATE] Current joint snapshot (deg):") print(f" lowstate_msgs={cnt} age={age:.2f}s") for idx in sorted(qdeg.keys()): print(f" [{idx:02d}] {JOINT_NAMES.get(idx,'?'):<18} {qdeg[idx]:+8.2f}°") print() def need_bot() -> bool: if bot is None: print("[ERR] No LocoClient available.") return False return True def need_arm() -> bool: if arm is None: print("[ERR] arm_sdk not available.") return False return True # ----- Dictionary-driven menu ----- OPTIONS: "OrderedDict[int, Dict[str, Any]]" = OrderedDict() OPTIONS[1] = {"label": "PREP mode (stand + balance) [NO start/FSM200]", "fn": lambda: prep_mode(bot) if need_bot() else None} OPTIONS[2] = {"label": "READY/START mode (stand + balance + start, FSM 200)", "fn": lambda: ready_start_mode(bot) if need_bot() else None} OPTIONS[3] = {"label": "ZeroTorque (FSM 0)", "fn": lambda: fsm_set(bot, 0, "ZeroTorque") if need_bot() else None} OPTIONS[4] = {"label": "Damp (FSM 1)", "fn": lambda: fsm_set(bot, 1, "Damp") if need_bot() else None} OPTIONS[5] = {"label": "StandUp (FSM 4)", "fn": lambda: fsm_set(bot, 4, "StandUp") if need_bot() else None} OPTIONS[6] = {"label": "Squat (FSM 2)", "fn": lambda: fsm_set(bot, 2, "Squat") if need_bot() else None} OPTIONS[7] = {"label": "Sit (FSM 3)", "fn": lambda: fsm_set(bot, 3, "Sit") if need_bot() else None} OPTIONS[8] = {"label": "LowStand", "fn": lambda: safe_call("LowStand", bot.LowStand) if (need_bot() and hasattr(bot, "LowStand")) else print("[ERR] LowStand not available")} OPTIONS[9] = {"label": "HighStand", "fn": lambda: safe_call("HighStand", bot.HighStand) if (need_bot() and hasattr(bot, "HighStand")) else print("[ERR] HighStand not available")} OPTIONS[10] = {"label": "Lie-to-Stand (FSM 702) (if supported)", "fn": lambda: fsm_set(bot, 702, "Lie2StandUp") if need_bot() else None} OPTIONS[11] = {"label": "Walk teleop (keyboard: W/S Q/E A/D, ESC exit)", "fn": lambda: teleop_loop(bot, speed_limit=0.6) if need_bot() else None} OPTIONS[12] = {"label": "Run teleop (keyboard: W/S Q/E A/D, ESC exit)", "fn": lambda: teleop_loop(bot, speed_limit=1.2) if need_bot() else None} OPTIONS[16] = {"label": "Status (LIVE joints)", "fn": lambda: show_status(bot, arm, msc, sub, monitor)} OPTIONS[17] = {"label": "MotionSwitcher: Select mode 'ai' (supported)", "fn": lambda: msc_select_ai(msc)} OPTIONS[18] = {"label": "MotionSwitcher: Release current mode", "fn": lambda: msc_release(msc)} OPTIONS[19] = {"label": "MotionSwitcher: Show current mode", "fn": lambda: msc_show(msc)} OPTIONS[20] = {"label": "Seated mode ON (Sit)", "fn": lambda: fsm_set(bot, 3, "Sit") if need_bot() else None} OPTIONS[21] = {"label": "Seated mode OFF (StandUp + PREP)", "fn": lambda: seated_off(bot) if need_bot() else None} OPTIONS[22] = {"label": "Reconnect (re-init Loco + MotionSwitcher)", "fn": lambda: reconnect(args, lambda b: set_bot_ref(b), lambda m: set_msc_ref(m))} # Helpers need access to outer bot/msc refs: def set_bot_ref(new_bot: Optional[LocoClient]): nonlocal bot bot = new_bot def set_msc_ref(new_msc: Optional[MotionSwitcherClient]): nonlocal msc msc = new_msc def print_menu(): print("\n" + "=" * 70) print("G1 MODE CONTROLLER - select option:") print("=" * 70) for k, v in OPTIONS.items(): print(f"{k:>3}) {v['label']}") print(" 0) Quit (StopMove only)") print("=" * 70) print_menu() while True: try: choice = input("\nSelect option (0-22): ").strip() except KeyboardInterrupt: stop_only(bot) print("\n[EXIT] Ctrl+C → StopMove only. Keeping current posture. Bye.") break except EOFError: stop_only(bot) print("\n[EXIT] EOF → StopMove only. Keeping current posture. Bye.") break if not choice: continue if choice == "0": stop_only(bot) print("\n[EXIT] Quit → StopMove only. Keeping current posture. Bye.") break try: opt = int(choice) except ValueError: print("Please enter a number.") continue if opt not in OPTIONS: print("Unknown option.") print_menu() continue try: OPTIONS[opt]["fn"]() except KeyboardInterrupt: stop_only(bot) print("\n[INTERRUPT] Ctrl+C → StopMove only. Keeping current posture.") except Exception as e: print("[ERR] Action failed:", repr(e)) print_menu() # --------- extra action helpers that need current refs --------- def show_status(bot, arm, msc, sub, monitor): print("Status:") print(f" LocoClient: {'OK' if bot is not None else 'NOT INITIALISED'}") print(f" MotionSwitcher: {'OK' if msc is not None else 'DISABLED'}") if msc is not None: try: _s, r = msc.CheckMode() print(f" MSC mode: {r.get('name') or '(none)'}") except Exception: print(" MSC mode: (error reading)") print(f" arm_sdk: {'OK' if arm is not None else 'DISABLED'}") print(f" pynput: {'OK' if HAVE_PYNPUT else 'NOT INSTALLED'}") if sub is None: print(" LowState: NOT SUBSCRIBED") return cnt, age, qdeg = monitor.snapshot_deg() print(f" LowState: OK msgs={cnt} age={age:.2f}s") print(" Current joints (deg):") for idx in sorted(qdeg.keys()): print(f" [{idx:02d}] {JOINT_NAMES.get(idx,'?'):<18} {qdeg[idx]:+8.2f}°") def msc_select_ai(msc: Optional[MotionSwitcherClient]): if msc is None: print("[ERR] MotionSwitcher not available.") return ret = safe_call("SelectMode('ai')", msc.SelectMode, "ai") # ret could be (7004,None) etc; we just print it from safe_call. def msc_release(msc: Optional[MotionSwitcherClient]): if msc is None: print("[ERR] MotionSwitcher not available.") return safe_call("ReleaseMode()", msc.ReleaseMode) def msc_show(msc: Optional[MotionSwitcherClient]): if msc is None: print("[ERR] MotionSwitcher not available.") return try: s, r = msc.CheckMode() print(f"[MSC] Current mode: {r.get('name') or '(none)'}") except Exception as e: print("[ERR] CheckMode failed:", repr(e)) def seated_off(bot: LocoClient): print("[SEATED] Leaving seated: StandUp + PREP (no Start)...") fsm_set(bot, 4, "StandUp") prep_mode(bot) def reconnect(args, set_bot, set_msc): print("Reconnecting LocoClient...") try: b = init_loco(timeout=args.timeout) set_bot(b) print("[OK] LocoClient reconnected.") except Exception as e: print("[ERR] Loco reconnect failed:", repr(e)) set_bot(None) print("Reconnecting MotionSwitcherClient...") try: m = init_msc(timeout=5.0) set_msc(m) try: _s, r = m.CheckMode() print(f"[MSC] Current mode: {r.get('name') or '(none)'}") except Exception: pass print("[OK] MotionSwitcher reconnected.") except Exception as e: print("[ERR] MotionSwitcher reconnect failed:", repr(e)) set_msc(None) if __name__ == "__main__": main()