Sanad_Package_1/vendor/Sanad/navigation/web_nav3_client.py

376 lines
16 KiB
Python

"""Thin HTTP client to the web_nav3 Nav2 stack for SanadV3.
web_nav3 exposes a FastAPI surface (default http://127.0.0.1:8765) that wraps
a ROS2/Nav2 + rosbridge bringup. SanadV3 talks to it over plain HTTP so it
never has to import ROS2 itself.
Design contract: NO method here ever raises into the caller. Every public
method returns either a clean dict {"ok": bool, "error": str|None, ...} or a
NavStatus. If web_nav3 is unreachable/down, callers (dashboard, voice) get a
graceful failure result and keep running.
"""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import requests
logger = logging.getLogger("sanadv3.navigation")
@dataclass
class NavStatus:
"""Snapshot of web_nav3 health (from GET /api/status)."""
bringup_alive: bool = False
rosbridge_alive: bool = False
reachable: bool = False
error: Optional[str] = None
log_tail: str = ""
# What the single bringup is doing right now (from /api/status):
# mode 1 fresh / 2 continue / 3 localize / None idle.
mode: Optional[int] = None
active_map: Optional[str] = None
mode_label: str = "IDLE"
mapping: bool = False
localizing: bool = False
def as_dict(self) -> Dict[str, Any]:
return {
"bringup_alive": self.bringup_alive,
"rosbridge_alive": self.rosbridge_alive,
"reachable": self.reachable,
"error": self.error,
"log_tail": self.log_tail,
"mode": self.mode,
"active_map": self.active_map,
"mode_label": self.mode_label,
"mapping": self.mapping,
"localizing": self.localizing,
}
class WebNav3Client:
"""Loosely-coupled HTTP client to web_nav3.
Args:
base_url: web_nav3 FastAPI base, e.g. "http://127.0.0.1:8765".
robot: robot name, sent as X-Robot-Name header on every request.
timeout: default per-request timeout (seconds).
"""
def __init__(
self,
base_url: str = "http://127.0.0.1:8765",
robot: str = "sanad",
timeout: float = 3.0,
) -> None:
self.base_url = base_url.rstrip("/")
self.robot = robot
self.timeout = float(timeout)
self._session = requests.Session()
# requests.Session is not guaranteed thread-safe, but route handlers
# call us from asyncio.to_thread workers (multiple tabs / overlapping
# status-poll + goto). Serialize Session access with a lock.
self._session_lock = threading.Lock()
# ------------------------------------------------------------------ #
# internals
# ------------------------------------------------------------------ #
def _headers(self) -> Dict[str, str]:
return {"X-Robot-Name": self.robot}
def _url(self, path: str) -> str:
if not path.startswith("/"):
path = "/" + path
return self.base_url + path
def _request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
"""Perform a request, always returning {"ok":bool, ...}; never raises."""
url = self._url(path)
try:
with self._session_lock:
resp = self._session.request(
method,
url,
params=params,
json=json_body,
headers=self._headers(),
timeout=timeout if timeout is not None else self.timeout,
)
except requests.RequestException as exc:
logger.debug("web_nav3 %s %s failed: %s", method, path, exc)
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
# Try to decode a JSON body either way (errors often carry detail).
body: Any = None
try:
body = resp.json()
except ValueError:
body = resp.text
if not resp.ok:
detail = body
if isinstance(body, dict):
detail = body.get("detail") or body.get("error") or body
return {
"ok": False,
"error": f"HTTP {resp.status_code}: {detail}",
"status_code": resp.status_code,
"data": body,
}
return {"ok": True, "error": None, "data": body, "status_code": resp.status_code}
# ------------------------------------------------------------------ #
# health
# ------------------------------------------------------------------ #
def reachable(self) -> bool:
"""Quick liveness check: short-timeout GET /api/status.
Reachable means web_nav3 ANSWERED — any HTTP response (including a 5xx
from a running-but-erroring backend) counts. Only a transport failure
(connection refused / timeout) means unreachable.
"""
try:
with self._session_lock:
self._session.get(
self._url("/api/status"),
headers=self._headers(),
timeout=min(self.timeout, 1.5),
)
return True
except requests.RequestException:
return False
def status(self) -> NavStatus:
"""Poll GET /api/status; returns a NavStatus (never raises)."""
res = self._request("GET", "/api/status")
if not res["ok"]:
# A 'status_code' key means web_nav3 answered with an HTTP error
# (e.g. 500) — it IS reachable, just erroring. Only a transport
# failure (no status_code) is genuinely unreachable.
answered = "status_code" in res
log_tail = ""
body = res.get("data")
if answered and isinstance(body, dict):
log_tail = str(body.get("log_tail", "") or "")
return NavStatus(reachable=answered, error=res["error"], log_tail=log_tail)
data = res.get("data") or {}
if not isinstance(data, dict):
return NavStatus(reachable=True, error="unexpected status payload")
return NavStatus(
bringup_alive=bool(data.get("bringup_alive", False)),
rosbridge_alive=bool(data.get("rosbridge_alive", False)),
reachable=True,
error=None,
log_tail=str(data.get("log_tail", "") or ""),
mode=data.get("mode"),
active_map=data.get("active_map"),
mode_label=str(data.get("mode_label", "IDLE") or "IDLE"),
mapping=bool(data.get("mapping", False)),
localizing=bool(data.get("localizing", False)),
)
# ------------------------------------------------------------------ #
# bringup lifecycle
# ------------------------------------------------------------------ #
def start(self, mode: int, db_path: Optional[str] = None) -> Dict[str, Any]:
"""POST /api/start. mode 1=fresh SLAM, 2/3=localization (need db_path)."""
body: Dict[str, Any] = {"mode": int(mode), "robot": self.robot}
if db_path is not None:
body["db_path"] = db_path
return self._request("POST", "/api/start", json_body=body, timeout=15.0)
def stop(self) -> Dict[str, Any]:
"""POST /api/stop — SIGINT the running bringup process group."""
return self._request("POST", "/api/stop", timeout=15.0)
def load_map(self, db_path: str) -> Dict[str, Any]:
"""POST /api/load_map — VIEW a saved map: stop any running bringup, then
start LOCALIZE-only (mode 3) against db_path. Use this for 'Load & View'
so it actually switches to the chosen map instead of attaching to a
mapping session already in progress."""
return self._request("POST", "/api/load_map",
json_body={"db_path": db_path}, timeout=25.0)
def shutdown(self) -> Dict[str, Any]:
"""POST /api/shutdown — kill ALL registered web_nav3 processes."""
return self._request("POST", "/api/shutdown", timeout=15.0)
# ------------------------------------------------------------------ #
# places / navigation
# ------------------------------------------------------------------ #
def list_places(self, map_name: Optional[str] = None) -> List[Dict[str, Any]]:
"""GET /api/places. Per-MAP when map_name given (each map keeps its own
places); else the legacy per-robot store. Returns a list (empty on error)."""
params: Dict[str, Any] = {"robot": self.robot}
if map_name:
params["map"] = map_name
res = self._request("GET", "/api/places", params=params)
if not res["ok"]:
logger.debug("list_places failed: %s", res["error"])
return []
data = res.get("data")
return data if isinstance(data, list) else []
def goto(self, name: str) -> Dict[str, Any]:
"""POST /api/places/goto?name=... — THE primary nav command.
Navigate to a saved place by name on the currently-running bringup.
"""
# Backend runs `timeout 5 ros2 topic pub --once` inside subprocess.run(
# timeout=12); on a Jetson the rclpy cold-start routinely exceeds the
# default 3s read-timeout, so override to match the backend's cap.
res = self._request("POST", "/api/places/goto", params={"name": name}, timeout=12.0)
if res["ok"]:
return {"ok": True, "error": None, "name": name, "data": res.get("data")}
return {"ok": False, "error": res["error"], "name": name}
def cancel(self) -> Dict[str, Any]:
"""Report that server-side goal-cancel is NOT available.
web_nav3 has no explicit goal-cancel HTTP endpoint, so a true
per-goal cancel must be performed client-side (browser) over rosbridge
by sending a CancelGoal to the /navigate_to_pose action server. That
keeps Nav2/SLAM/bridges alive — only the in-flight goal aborts.
Server-side there is nothing safe to do here (we must NOT call stop(),
which SIGINTs the whole bringup process group). Previously this returned
ok:True, which made the dashboard's rosbridge-down fallback toast
"cancel sent" while the robot kept driving. Return ok:False with an
explicit reason so callers never mistake this for a real cancel — the
only server-side way to halt motion is stop() (tears down bringup).
"""
return {
"ok": False,
"error": "cancel must be done client-side via rosbridge",
"note": "no server-side goal-cancel; use stop() to tear down bringup",
}
def save_here(self, name: str) -> Dict[str, Any]:
"""Save the robot's CURRENT pose as a named place.
POST /api/places/save_here?name=... — web_nav3 reads the current TF
pose (requires bringup running; backend takes ``name`` as a query arg).
CONFIG COUPLING: backend save_here writes the LEGACY places store
(~/marcus_nav2_test/places.json) regardless of robot, while save_at /
list_places are robot-scoped and only resolve to that legacy file when
web_nav3's robot_config.yaml ``robot_name`` equals this client's robot
(``self.robot``). For all three to share one store, web_nav3's
robot_name MUST match SANAD_ROBOT_NAME (default 'sanad'); if they drift,
'Save here' and click-to-add places silently target different files.
"""
# Same ros2-pub cold-start as goto(): override the 3s default so a slow
# (but successful) publish isn't reported as a failure.
return self._request(
"POST",
"/api/places/save_here",
params={"robot": self.robot, "name": name},
timeout=12.0,
)
def save_at(self, name: str, x: float, y: float, yaw: float,
map_name: Optional[str] = None) -> Dict[str, Any]:
"""Save a named place at an explicit (x, y, yaw) map pose.
POST /api/places/save_at — writes straight to places.json, so it works
even with no bringup running (used by click-to-add-place on the map).
Per-MAP when map_name given. Re-saving an existing name MOVES the place.
"""
params: Dict[str, Any] = {"robot": self.robot}
if map_name:
params["map"] = map_name
return self._request(
"POST",
"/api/places/save_at",
params=params,
json_body={
"name": name,
"x": float(x),
"y": float(y),
"yaw": float(yaw),
},
)
def delete_place(self, name: str, map_name: Optional[str] = None) -> Dict[str, Any]:
"""DELETE /api/places?name=... — remove a saved place (per-map)."""
params: Dict[str, Any] = {"robot": self.robot, "name": name}
if map_name:
params["map"] = map_name
return self._request("DELETE", "/api/places", params=params)
def rename_place(self, old: str, new: str,
map_name: Optional[str] = None) -> Dict[str, Any]:
"""POST /api/places/rename — rename a saved place (per-map)."""
params: Dict[str, Any] = {"robot": self.robot}
if map_name:
params["map"] = map_name
return self._request(
"POST", "/api/places/rename", params=params,
json_body={"old": old, "new": new},
)
# ------------------------------------------------------------------ #
# map editor — per-map occupancy edit overlay
# ------------------------------------------------------------------ #
def get_map_edits(self, map_name: str) -> Dict[str, Any]:
"""GET /api/map_edits?map=... — the saved edit overlay (erased points +
painted walls) for a map. Returns {ok, edits:[[wx,wy,v],...]}."""
res = self._request("GET", "/api/map_edits",
params={"robot": self.robot, "map": map_name})
if not res["ok"]:
return {"ok": False, "error": res["error"], "edits": []}
data = res.get("data") or {}
return {"ok": True, "edits": data.get("edits", []) if isinstance(data, dict) else []}
def save_map_edits(self, map_name: str, edits: list) -> Dict[str, Any]:
"""POST /api/map_edits?map=... — persist the edit overlay for a map.
edits = list of [world_x, world_y, value] (0=free/erase, 100=wall)."""
return self._request("POST", "/api/map_edits",
params={"robot": self.robot, "map": map_name},
json_body={"edits": edits})
# ------------------------------------------------------------------ #
# maps / missions
# ------------------------------------------------------------------ #
def list_maps(self) -> List[Dict[str, Any]]:
"""GET /api/maps. Returns a list (empty on any error)."""
res = self._request("GET", "/api/maps", params={"robot": self.robot})
if not res["ok"]:
logger.debug("list_maps failed: %s", res["error"])
return []
data = res.get("data")
return data if isinstance(data, list) else []
def list_missions(self) -> List[Dict[str, Any]]:
"""GET /api/missions. Returns a list (empty on any error)."""
res = self._request("GET", "/api/missions", params={"robot": self.robot})
if not res["ok"]:
logger.debug("list_missions failed: %s", res["error"])
return []
data = res.get("data")
return data if isinstance(data, list) else []
def run_mission(self, mission_id: Any) -> Dict[str, Any]:
"""POST /api/missions/run — start a saved mission by id."""
return self._request(
"POST",
"/api/missions/run",
params={"id": mission_id},
json_body={"id": mission_id, "robot": self.robot},
timeout=15.0,
)