289 lines
9.5 KiB
Python
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="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()
|