"""Controller tab — manual dashboard locomotion control (N2 Phase 1/2). Routes live under /api/controller. All WRITE actions (move / step / postures / modes / MotionSwitcher) require the in-memory "Enable movement" arm flag and return 409 when disarmed. Reads (/status, /joints, /msc, /status/summary), E-STOP and the arm toggle are ALWAYS available. `/status/summary` is the aggregate the dashboard polls for the global subsystem status strip (Camera / Face / Place / Movement). It is kept under /api/controller (final path /api/controller/status/summary) so no second router is needed; note /api/status (no /summary) is already used by the SPA, so the suffix matters. """ from __future__ import annotations import asyncio from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel from Project.Sanad.config import BASE_DIR from Project.Sanad.core.logger import get_logger from Project.Sanad.vision import recognition_state from Project.Sanad.dashboard.routes import _arbiter log = get_logger("controller_routes") router = APIRouter() STATE_PATH = BASE_DIR / "data" / ".recognition_state.json" # ── lazy subsystem accessors ──────────────────────────────── def _get_loco(): try: from Project.Sanad.main import loco_controller # type: ignore return loco_controller except Exception: return None def _get_camera(): try: from Project.Sanad.main import camera # type: ignore return camera except Exception: return None def _get_live_sub(): try: from Project.Sanad.main import live_sub # type: ignore return live_sub except Exception: return None def _get_dispatch(): try: from Project.Sanad.main import movement_dispatch # type: ignore return movement_dispatch except Exception: return None def _require_loco(): lc = _get_loco() if lc is None: raise HTTPException(503, "Locomotion controller subsystem unavailable.") return lc def _require_armed(lc): if not lc.is_armed(): raise HTTPException(409, "Movement is disarmed. Enable movement first.") def _claim_loco(): """Arbitration gate: refuse a leg command while a Nav2 goal owns the legs.""" if not _arbiter.acquire_loco(): raise HTTPException( 409, "Navigation (Nav2) is active. Cancel the nav goal before manual movement." ) def _cancel_nav(): """Cancel any in-flight Nav2 goal and clear the nav arbitration flag. Used by E-STOP so the global stop halts the legs no matter which stack is driving them. Calls the nav client in-process (no HTTP self-call); never raises into the caller. """ try: from Project.Sanad.dashboard.routes.navigation import _CLIENT as _nav_client if _nav_client is not None: _nav_client.cancel() except Exception: log.exception("estop nav cancel failed") finally: _arbiter.release_nav() # ── reads ─────────────────────────────────────────────────── @router.get("/status") async def get_status(): lc = _require_loco() return await asyncio.to_thread(lc.status) @router.get("/joints") async def get_joints(): lc = _require_loco() return await asyncio.to_thread(lc.joints) @router.get("/msc") async def get_msc(): lc = _require_loco() return await asyncio.to_thread(lc.msc_check) # ── arm flag / E-STOP (always available) ──────────────────── @router.post("/arm") async def set_arm(on: bool = Query(...)): lc = _require_loco() if on: # Refuse to arm manual loco while Nav2 owns the legs. _claim_loco() try: res = await asyncio.to_thread(lc.arm_movement) except Exception: _arbiter.release_loco() raise return res res = await asyncio.to_thread(lc.disarm_movement) _arbiter.release_loco() return res @router.post("/gemini-movement") async def set_gemini_movement(on: bool = Query(...)): """Enable / disable Gemini voice-driven locomotion (N2 Phase 3 gate). Writes recognition_state.movement_enabled — SEPARATE from the manual arm flag. The Gemini child announces the toggle (spoken), and the parent MovementDispatcher starts/stops acting on confirmation phrases. Default OFF. """ st = await asyncio.to_thread(recognition_state.mutate, STATE_PATH, movement_enabled=bool(on)) # Enabling Gemini movement also clears any E-STOP latch on the dispatcher. if on: md = _get_dispatch() if md is not None: try: md.clear_estop() except Exception: log.exception("clear_estop failed") log.info("gemini-movement %s", "ON" if on else "OFF") return {"ok": True, "movement_enabled": st.movement_enabled} @router.post("/estop") async def estop(): lc = _require_loco() res = await asyncio.to_thread(lc.estop) # Full stop: drop the manual arm flag AND latch the voice dispatcher off, so # no source (teleop, step, or voice dispatch) can keep driving the robot. The # dispatcher latch is used instead of flipping movement_enabled so the Gemini # child does not deliver a spoken "movement disabled" line during an E-STOP. try: await asyncio.to_thread(lc.disarm_movement) except Exception: log.exception("estop disarm failed") md = _get_dispatch() if md is not None: try: md.emergency_stop() except Exception: log.exception("estop dispatcher latch failed") # Cancel any in-flight Nav2 goal too: the legs have exactly one commander, # and an E-STOP must halt the legs whether loco or Nav2 is driving them. await asyncio.to_thread(_cancel_nav) _arbiter.release_loco() return {"ok": True, **res} @router.post("/stop") async def stop(): lc = _require_loco() # Allowed even when disarmed — StopMove is always safe. res = await asyncio.to_thread(lc.stop_move) return res # ── movement (armed) ──────────────────────────────────────── class MoveBody(BaseModel): vx: float = 0.0 vy: float = 0.0 vyaw: float = 0.0 run: bool = False @router.post("/move") async def move(body: MoveBody): lc = _require_loco() _require_armed(lc) _claim_loco() return await asyncio.to_thread(lc.move, body.vx, body.vy, body.vyaw, body.run) @router.post("/step") async def step(dir: str = Query(...)): lc = _require_loco() _require_armed(lc) _claim_loco() res = await asyncio.to_thread(lc.step, dir) if not res.get("ok"): raise HTTPException(400, res.get("reason", "step failed")) return res # ── modes / postures (armed) ──────────────────────────────── @router.post("/mode/prep") async def mode_prep(): lc = _require_loco() _require_armed(lc) return await asyncio.to_thread(lc.prep_mode) @router.post("/mode/ready") async def mode_ready(): lc = _require_loco() _require_armed(lc) return await asyncio.to_thread(lc.ready_start_mode) @router.post("/posture/{name}") async def posture(name: str): lc = _require_loco() _require_armed(lc) res = await asyncio.to_thread(lc.posture, name) if not res.get("ok") and res.get("reason"): raise HTTPException(400, res["reason"]) return res @router.post("/balance") async def balance(mode: int = Query(...)): lc = _require_loco() _require_armed(lc) return await asyncio.to_thread(lc.set_balance_mode, mode) @router.post("/height") async def height(h: float = Query(...)): lc = _require_loco() _require_armed(lc) return await asyncio.to_thread(lc.set_stand_height, h) # ── MotionSwitcher / reconnect (armed) ────────────────────── @router.post("/msc/select-ai") async def msc_select_ai(): lc = _require_loco() _require_armed(lc) return await asyncio.to_thread(lc.msc_select_ai) @router.post("/msc/release") async def msc_release(): lc = _require_loco() _require_armed(lc) return await asyncio.to_thread(lc.msc_release) @router.post("/reconnect") async def reconnect(): lc = _require_loco() _require_armed(lc) return await asyncio.to_thread(lc.reconnect) # ── aggregate subsystem summary (always available) ────────── @router.get("/status/summary") async def status_summary(): """Live on/off state for the header status strip. Never raises.""" try: st = recognition_state.read(STATE_PATH) except Exception: st = recognition_state.RecognitionState() cam = _get_camera() camera_running = False try: camera_running = bool(cam is not None and cam.is_running()) except Exception: camera_running = False lc = _get_loco() movement_armed = False try: movement_armed = bool(lc is not None and lc.is_armed()) except Exception: movement_armed = False sub = _get_live_sub() gemini_running = False try: runner = getattr(sub, "is_running", None) gemini_running = bool(callable(runner) and runner()) except Exception: gemini_running = False # Effective Gemini-movement = the file flag AND not latched off by an E-STOP. md = _get_dispatch() estopped = False try: estopped = bool(md is not None and md.is_estopped()) except Exception: estopped = False return { "vision_enabled": st.vision_enabled, "camera_running": camera_running, "face_rec_enabled": st.face_rec_enabled, "zone_rec_enabled": st.zone_rec_enabled, "movement_armed": movement_armed, "gemini_movement_enabled": st.movement_enabled and not estopped, "gemini_running": gemini_running, }