""" marcus_cli.py — Marcus CLI Client =================================== Connect to Marcus server via WebSocket from any terminal. Prompts for IP and port on startup, then provides a command interface. Start: python3 Client/marcus_cli.py OR: python3 Client/marcus_cli.py --ip 192.168.123.164 --port 8765 """ import asyncio import argparse import json import os import sys import time PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if PROJECT_DIR not in sys.path: sys.path.insert(0, PROJECT_DIR) try: import websockets except ImportError: print("Missing dependency: pip install websockets") sys.exit(1) from Core.config_loader import load_config _net = load_config("Network") DEFAULT_IP = _net.get("jetson_ip", "192.168.123.164") DEFAULT_PORT = _net.get("websocket_port", 8765) # ── COLORS ─────────────────────────────────────────────────────────────────── class C: RESET = "\033[0m" BOLD = "\033[1m" GREEN = "\033[92m" RED = "\033[91m" YELLOW = "\033[93m" CYAN = "\033[96m" GRAY = "\033[90m" ORANGE = "\033[38;5;208m" def _ts(): return time.strftime("%H:%M:%S") # ── CONNECTION ─────────────────────────────────────────────────────────────── async def connect_and_run(ip: str, port: int): url = f"ws://{ip}:{port}" print(f"\n{C.CYAN}Connecting to {url}...{C.RESET}") try: async with websockets.connect(url, ping_interval=20, ping_timeout=10) as ws: print(f"{C.GREEN}Connected to Marcus server{C.RESET}\n") # Start receiver task receiver = asyncio.create_task(_receive_loop(ws)) # Input loop try: await _input_loop(ws) except (EOFError, KeyboardInterrupt): pass finally: receiver.cancel() except ConnectionRefusedError: print(f"{C.RED}Connection refused — is the server running on {ip}:{port}?{C.RESET}") except OSError as e: print(f"{C.RED}Network error: {e}{C.RESET}") except Exception as e: print(f"{C.RED}Connection failed: {e}{C.RESET}") async def _receive_loop(ws): """Background task — receives and displays server messages.""" try: async for raw in ws: try: data = json.loads(raw) _handle_message(data) except json.JSONDecodeError: print(f"{C.GRAY}[?] {raw[:80]}{C.RESET}") except websockets.exceptions.ConnectionClosed: print(f"\n{C.RED}Disconnected from server{C.RESET}") except asyncio.CancelledError: pass async def _input_loop(ws): """Main input loop — sends commands to server.""" _print_help() while True: try: cmd = await asyncio.get_event_loop().run_in_executor( None, lambda: input(f"{C.ORANGE}Command: {C.RESET}").strip() ) except (EOFError, KeyboardInterrupt): print(f"\n{C.GRAY}Disconnecting...{C.RESET}") break if not cmd: continue lower = cmd.lower() if lower in ("q", "quit", "exit"): break elif lower == "help": _print_help() continue elif lower == "status": await ws.send(json.dumps({"type": "ping"})) continue elif lower == "camera": await ws.send(json.dumps({"type": "get_camera"})) continue elif lower.startswith("profile "): profile = cmd[8:].strip() await ws.send(json.dumps({"type": "set_camera", "profile": profile})) continue elif lower == "capture": await ws.send(json.dumps({"type": "capture"})) continue elif lower == "log": print(f"{C.GRAY} Server-side nav log not available from CLI{C.RESET}") continue else: # Send as navigation command await ws.send(json.dumps({"type": "command", "command": cmd})) # ── MESSAGE DISPLAY ────────────────────────────────────────────────────────── def _handle_message(data): t = data.get("type", "") if t == "frame": # Suppress frame data in CLI (no GUI) return elif t == "status": lidar = "ALIVE" if data.get("lidar") else "OFFLINE" model = data.get("model", "?") camera = data.get("camera", "?") yolo = data.get("yolo", False) odom = data.get("odometry", False) memory = data.get("memory", False) print(f"{C.GREEN} Server ready{C.RESET}") print(f" Model : {C.CYAN}{model}{C.RESET}") print(f" YOLO : {C.GREEN if yolo else C.GRAY}{'active' if yolo else 'off'}{C.RESET}") print(f" Odometry : {C.GREEN if odom else C.GRAY}{'active' if odom else 'off'}{C.RESET}") print(f" Memory : {C.GREEN if memory else C.GRAY}{'active' if memory else 'off'}{C.RESET}") print(f" LiDAR : {C.GREEN if lidar == 'ALIVE' else C.RED}{lidar}{C.RESET}") print(f" Camera : {camera}") msg = data.get("message", "") if msg: print(f" {C.GRAY}{msg}{C.RESET}") print() elif t == "thinking": cmd = data.get("command", "") print(f"{C.YELLOW} Thinking... ({cmd}){C.RESET}") elif t == "decision": action = data.get("action", "?") speak = data.get("speak", "") elapsed = data.get("elapsed", "?") cmd = data.get("cmd", "?") ts = data.get("timestamp", "") btype = data.get("brain_type", "") color = (C.GREEN if cmd == "FORWARD" else C.CYAN if cmd in ("LEFT", "RIGHT", "MULTI", "GOAL") else C.ORANGE if cmd in ("GREETING", "TALK", "LOCAL") else C.RED if cmd in ("STOP", "NONE") else C.GRAY) print(f" [{ts}] {color}{C.BOLD}{action}{C.RESET} {C.GRAY}({elapsed}s){C.RESET}") if speak: print(f" {C.CYAN}Sanad: {speak}{C.RESET}") elif t == "camera_config": p = data.get("profile", "?") w, h, f = data.get("width", "?"), data.get("height", "?"), data.get("fps", "?") active = data.get("pipeline_active", False) note = data.get("note", "") print(f" Camera: {p} ({w}x{h}@{f}Hz) pipeline={'active' if active else 'stopped'}") if note: print(f" {C.GRAY}{note}{C.RESET}") elif t == "capture_result": if data.get("ok"): size_kb = len(data.get("data", "")) * 3 // 4 // 1024 ts = data.get("timestamp", "") print(f"{C.GREEN} Captured frame ({size_kb}KB) at {ts}{C.RESET}") else: print(f"{C.RED} Capture failed: {data.get('message', '?')}{C.RESET}") elif t == "pong": lidar = "OK" if data.get("lidar") else "OFFLINE" ts = data.get("timestamp", "") print(f" [{ts}] Status — LiDAR: {lidar}") elif t == "error": print(f"{C.RED} ERROR: {data.get('message', '?')}{C.RESET}") else: print(f"{C.GRAY} [{t}] {json.dumps(data)[:100]}{C.RESET}") def _print_help(): print(f""" {C.BOLD} MARCUS CLI CLIENT{C.RESET} {'─' * 40} {C.CYAN}Navigation commands:{C.RESET} move forward turn left stop walk forward turn right 90 halt {C.CYAN}System commands:{C.RESET} status — ping server + LiDAR camera — get camera status profile — switch camera (low/medium/high/full) capture — take a photo help — this menu q — disconnect """) # ── MAIN ───────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="Marcus CLI Client") parser.add_argument("--ip", default=None, help="Server IP address") parser.add_argument("--port", type=int, default=None, help="Server port") args = parser.parse_args() eth_ip = _net.get("jetson_ip", "192.168.123.164") wlan_ip = _net.get("jetson_wlan_ip", "10.255.254.86") print(f"\n{C.BOLD}{C.ORANGE} MARCUS — CLI Client{C.RESET}") print(f" {'═' * 40}") print(f" {C.GRAY}Connection options:{C.RESET}") print(f" 1) eth0 — {eth_ip}:{DEFAULT_PORT}") print(f" 2) wlan0 — {wlan_ip}:{DEFAULT_PORT}") print(f" 3) custom") print() if args.ip: ip = args.ip else: choice = input(f" Choose [1/2/3] or IP [{eth_ip}]: ").strip() if choice == "1" or not choice: ip = eth_ip elif choice == "2": ip = wlan_ip elif choice == "3": ip = input(f" Server IP: ").strip() or eth_ip else: # User typed an IP directly ip = choice if args.port: port = args.port else: port_str = input(f" Port [{DEFAULT_PORT}]: ").strip() port = int(port_str) if port_str else DEFAULT_PORT try: asyncio.run(connect_and_run(ip, port)) except KeyboardInterrupt: print(f"\n{C.GRAY}Bye.{C.RESET}") if __name__ == "__main__": main()