Marcus/Client/marcus_cli.py

289 lines
9.5 KiB
Python

"""
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 <name> — switch camera (low/medium/high/full)
capture — take a photo
help — this menu
q — disconnect
""")
# ── MAIN ─────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Sanad CLI Client (talks to the Marcus brain over WebSocket)")
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} SANAD — 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()