339 lines
10 KiB
Python
339 lines
10 KiB
Python
"""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,
|
|
}
|