"""Zones tab — zone → place → linked-faces management + "go here" destination. Hierarchy (replaces the old flat places): Zone (name + description) └─ Place (name + description + optional reference photos + linked face ids) Routes live under /api/zones. Toggle + CRUD changes write data/.recognition_state.json (the SAME file faces use); the Gemini child polls it at 1 Hz and re-primes / announces mid-session. The "go here" endpoints set a navigation target the robot will head to once N2 locomotion is wired — for now they just record the target and feed Gemini the place's reference. """ from __future__ import annotations import asyncio import io from typing import Optional from fastapi import APIRouter, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel from Project.Sanad.config import BASE_DIR from Project.Sanad.core.logger import get_logger from Project.Sanad.dashboard.routes._safe_io import check_upload_size from Project.Sanad.vision import recognition_state log = get_logger("zones_routes") router = APIRouter() STATE_PATH = BASE_DIR / "data" / ".recognition_state.json" # ── lazy subsystem accessors ──────────────────────────────── def _get_camera(): try: from Project.Sanad.main import camera # type: ignore return camera except Exception: return None def _get_zone_gallery(): try: from Project.Sanad.main import zone_gallery # type: ignore return zone_gallery except Exception: return None def _get_face_gallery(): try: from Project.Sanad.main import gallery # type: ignore return gallery except Exception: return None def _require_zones(): g = _get_zone_gallery() if g is None: raise HTTPException(503, "Zone gallery subsystem unavailable.") return g def _bump_zones_version() -> int: cur = recognition_state.read(STATE_PATH) v = cur.zones_version + 1 recognition_state.mutate(STATE_PATH, zones_version=v) return v def _validate_image(content: bytes, filename: str | None = None) -> None: check_upload_size(content) if len(content) < 16: raise HTTPException(400, "Image too small / empty.") if not (content[:3] == b"\xff\xd8\xff" or content[:8] == b"\x89PNG\r\n\x1a\n"): raise HTTPException(400, f"Only JPEG/PNG accepted (got {filename or 'unknown'}).") def _safe_photo_name(name: str) -> None: if "/" in name or ".." in name or "\x00" in name: raise HTTPException(400, "Invalid photo name.") def _resolve_faces(face_ids: list[int]) -> list[dict]: """Turn linked face ids into [{id, name}] using the face gallery.""" g = _get_face_gallery() out = [] for fid in face_ids: name = None if g is not None: try: e = g.get(fid) name = e.name if e else None except Exception: name = None out.append({"id": fid, "name": name}) return out def _place_to_dict(p) -> dict: d = p.to_dict() d["faces"] = _resolve_faces(p.face_ids) return d def _zone_to_dict(z) -> dict: return { "id": z.id, "name": z.name, "description": z.description, "linked_map": getattr(z, "linked_map", None), "added_at": z.added_at, "places": [_place_to_dict(p) for p in z.places], } async def _maybe_drive_to_place(zone, place) -> Optional[dict]: """If the place links a nav2 place AND its zone's map is the one currently localized, actually DRIVE there (arbiter-gated + arm arrival monitor). Returns the drive outcome, or None when the place isn't drivable (no link). Best-effort: never raises into the caller.""" nav_place = getattr(place, "nav_place", None) linked_map = getattr(zone, "linked_map", None) if not nav_place or not linked_map: return None try: from Project.Sanad.dashboard.routes import navigation as navmod from Project.Sanad.dashboard.routes import _arbiter except Exception: return {"ok": False, "reason": "nav_unavailable"} client = getattr(navmod, "_CLIENT", None) if client is None: return {"ok": False, "reason": "nav_unavailable"} try: st = await asyncio.to_thread(client.status) body = st.as_dict() if hasattr(st, "as_dict") else dict(st) except Exception as exc: # noqa: BLE001 return {"ok": False, "reason": "status_error", "detail": str(exc)[:120]} if not body.get("bringup_alive"): return {"ok": False, "reason": "no_map"} # The robot can only drive in the currently-localized map. Require the # zone's linked map to match (compare on the sanitized .db stem). active = (body.get("active_map") or "").strip().lower() want = (linked_map or "").strip().lower() if want.endswith(".db"): want = want[:-3] if active and want and active != want: return {"ok": False, "reason": "wrong_map", "active": body.get("active_map"), "want": linked_map} if not _arbiter.acquire_nav(): return {"ok": False, "reason": "manual_armed"} drive = await asyncio.to_thread(client.goto, nav_place) if isinstance(drive, dict) and not drive.get("ok", True): _arbiter.release_nav() return {"ok": False, "reason": "dispatch_failed", "detail": drive} try: from Project.Sanad.navigation.goal_monitor import arm_goal arm_goal(nav_place) except Exception: pass return {"ok": True, "resolved": nav_place} def _nav_target_dict(st, gallery) -> Optional[dict]: zid, pid = st.nav_target_zone_id, st.nav_target_place_id if not zid or not pid: return None zone_name = place_name = None if gallery is not None: try: z = gallery.get_zone(zid) zone_name = z.name if z else None p = gallery.get_place(zid, pid) place_name = p.name if p else None except Exception: pass return {"zone_id": zid, "place_id": pid, "zone_name": zone_name, "place_name": place_name} # ── state + toggle ────────────────────────────────────────── @router.get("/state") async def get_state(): st = recognition_state.read(STATE_PATH) g = _get_zone_gallery() zones_count = places_count = 0 if g is not None: try: zones = g.list_zones() zones_count = len(zones) places_count = sum(len(z.places) for z in zones) except Exception: pass return { "zone_rec_enabled": st.zone_rec_enabled, "zones_version": st.zones_version, "zones_count": zones_count, "places_count": places_count, "nav_target": _nav_target_dict(st, g), } @router.post("/zone-rec") async def set_zone_rec(on: bool = Query(...)): """Enable / disable the robot's knowledge of zones & places (hot).""" st = recognition_state.mutate(STATE_PATH, zone_rec_enabled=bool(on)) log.info("zone recognition %s", "ON" if on else "OFF") return {"ok": True, "zone_rec_enabled": st.zone_rec_enabled} @router.post("/sync") async def sync_zones(): v = _bump_zones_version() log.info("zones sync requested → v.%d", v) return {"ok": True, "zones_version": v} # ── zones CRUD ────────────────────────────────────────────── class NamePayload(BaseModel): name: Optional[str] = None class DescribePayload(BaseModel): description: Optional[str] = None class FacesPayload(BaseModel): face_ids: list[int] = [] class LinkMapPayload(BaseModel): # nav2 map .db basename (e.g. "office.db"); None/"" unlinks. map: Optional[str] = None class NavPlacePayload(BaseModel): # nav2 place name in the zone's linked map; None/"" unlinks. nav_place: Optional[str] = None @router.get("") async def list_zones(): g = _require_zones() zones = g.list_zones() return {"zones": [_zone_to_dict(z) for z in zones], "total": len(zones)} @router.post("/create") async def create_zone(name: Optional[str] = Query(default=None), description: Optional[str] = Query(default=None)): g = _require_zones() if not (name or "").strip() and not (description or "").strip(): raise HTTPException(400, "A zone needs at least a name or a description.") z = g.create_zone(name=name, description=description) _bump_zones_version() return {"ok": True, "zone": _zone_to_dict(z)} @router.post("/{zone_id}/rename") async def rename_zone(zone_id: int, payload: NamePayload): g = _require_zones() try: g.rename_zone(zone_id, payload.name) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) _bump_zones_version() return {"ok": True, "zone": _zone_to_dict(g.get_zone(zone_id))} @router.post("/{zone_id}/describe") async def describe_zone(zone_id: int, payload: DescribePayload): g = _require_zones() try: g.describe_zone(zone_id, payload.description) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) _bump_zones_version() return {"ok": True, "zone": _zone_to_dict(g.get_zone(zone_id))} @router.delete("/{zone_id}") async def delete_zone(zone_id: int): g = _require_zones() try: g.delete_zone(zone_id) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) # If the active destination was inside this zone, clear it. st = recognition_state.read(STATE_PATH) if st.nav_target_zone_id == zone_id: recognition_state.mutate(STATE_PATH, nav_target_zone_id=0, nav_target_place_id=0) _bump_zones_version() return {"ok": True, "deleted": zone_id} # ── places CRUD (within a zone) ───────────────────────────── @router.post("/{zone_id}/places/create") async def create_place( zone_id: int, name: Optional[str] = Query(default=None), description: Optional[str] = Query(default=None), face_ids: list[int] = Query(default=[]), nav_place: Optional[str] = Query(default=None), files: Optional[list[UploadFile]] = File(default=None), ): g = _require_zones() if g.get_zone(zone_id) is None: raise HTTPException(404, f"zone_{zone_id} not found") if not (name or "").strip() and not (description or "").strip(): raise HTTPException(400, "A place needs at least a name or a description.") image_bytes: list[bytes] = [] for f in (files or []): content = await f.read() if not content: continue _validate_image(content, f.filename) image_bytes.append(content) p = g.create_place(zone_id, name=name, description=description, face_ids=face_ids, image_bytes_list=image_bytes or None, nav_place=nav_place) _bump_zones_version() return {"ok": True, "place": _place_to_dict(p)} @router.post("/{zone_id}/link_map") async def link_zone_map(zone_id: int, payload: LinkMapPayload): """Bind (or unbind) the zone to a nav2 map .db. Required before its places can link to that map's nav places / before Gemini Nav can drive in it.""" g = _require_zones() try: g.set_zone_map(zone_id, payload.map) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) _bump_zones_version() return {"ok": True, "zone": _zone_to_dict(g.get_zone(zone_id))} @router.post("/{zone_id}/places/{place_id}/nav_link") async def link_place_nav(zone_id: int, place_id: int, payload: NavPlacePayload): """Link (or unlink) a place to a nav2 place name in the zone's map — this is what makes the place drivable from voice / 'Go here'.""" g = _require_zones() try: g.set_place_nav(zone_id, place_id, payload.nav_place) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) _bump_zones_version() return {"ok": True, "place": _place_to_dict(g.get_place(zone_id, place_id))} @router.post("/{zone_id}/places/{place_id}/rename") async def rename_place(zone_id: int, place_id: int, payload: NamePayload): g = _require_zones() try: g.rename_place(zone_id, place_id, payload.name) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) _bump_zones_version() return {"ok": True, "place": _place_to_dict(g.get_place(zone_id, place_id))} @router.post("/{zone_id}/places/{place_id}/describe") async def describe_place(zone_id: int, place_id: int, payload: DescribePayload): g = _require_zones() try: g.describe_place(zone_id, place_id, payload.description) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) _bump_zones_version() return {"ok": True, "place": _place_to_dict(g.get_place(zone_id, place_id))} @router.post("/{zone_id}/places/{place_id}/faces") async def set_place_faces(zone_id: int, place_id: int, payload: FacesPayload): """Replace the set of saved faces linked to this place.""" g = _require_zones() try: g.set_place_faces(zone_id, place_id, payload.face_ids) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) _bump_zones_version() return {"ok": True, "place": _place_to_dict(g.get_place(zone_id, place_id))} @router.post("/{zone_id}/places/{place_id}/capture") async def capture_to_place(zone_id: int, place_id: int): g = _require_zones() cam = _get_camera() if cam is None or not cam.is_running(): raise HTTPException(409, "Camera is not running. Toggle Vision ON first.") jpeg = cam.get_fresh_frame(max_age_s=0.5, timeout_s=1.5) if not jpeg: raise HTTPException(409, "Camera has no frame yet.") try: fname = g.add_photo(zone_id, place_id, jpeg) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) _bump_zones_version() return {"ok": True, "added": fname, "place": _place_to_dict(g.get_place(zone_id, place_id))} @router.post("/{zone_id}/places/{place_id}/upload") async def upload_to_place(zone_id: int, place_id: int, files: list[UploadFile] = File(...)): g = _require_zones() if g.get_place(zone_id, place_id) is None: raise HTTPException(404, f"zone_{zone_id}/place_{place_id} not found") added: list[str] = [] for f in files: content = await f.read() _validate_image(content, f.filename) try: added.append(g.add_photo(zone_id, place_id, content)) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) _bump_zones_version() return {"ok": True, "added": added, "place": _place_to_dict(g.get_place(zone_id, place_id))} @router.delete("/{zone_id}/places/{place_id}") async def delete_place(zone_id: int, place_id: int): g = _require_zones() try: g.delete_place(zone_id, place_id) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) st = recognition_state.read(STATE_PATH) if st.nav_target_zone_id == zone_id and st.nav_target_place_id == place_id: recognition_state.mutate(STATE_PATH, nav_target_zone_id=0, nav_target_place_id=0) _bump_zones_version() return {"ok": True, "deleted": place_id} @router.delete("/{zone_id}/places/{place_id}/photo/{photo_name}") async def delete_place_photo(zone_id: int, place_id: int, photo_name: str): g = _require_zones() _safe_photo_name(photo_name) try: g.delete_photo(zone_id, place_id, photo_name) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) _bump_zones_version() return {"ok": True, "deleted": photo_name} @router.get("/{zone_id}/places/{place_id}/photo/{photo_name}") async def get_place_photo(zone_id: int, place_id: int, photo_name: str, download: int = Query(default=0)): g = _require_zones() _safe_photo_name(photo_name) path = g.get_photo(zone_id, place_id, photo_name) if path is None: raise HTTPException(404, "Photo not found.") media = "image/png" if path.suffix.lower() == ".png" else "image/jpeg" headers = {} if download: headers["Content-Disposition"] = ( f'attachment; filename="zone_{zone_id}_place_{place_id}_{photo_name}"') return FileResponse(path, media_type=media, headers=headers) @router.get("/{zone_id}/places/{place_id}/download.zip") async def download_place_zip(zone_id: int, place_id: int): g = _require_zones() try: data = g.zip_place(zone_id, place_id) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) return StreamingResponse( io.BytesIO(data), media_type="application/zip", headers={ "Content-Disposition": f'attachment; filename="zone_{zone_id}_place_{place_id}.zip"', "Content-Length": str(len(data)), }, ) # ── "go here" navigation target ───────────────────────────── @router.post("/{zone_id}/places/{place_id}/go") async def go_to_place(zone_id: int, place_id: int): """Set this place as the active destination AND, if the place links a nav2 place in this zone's (currently-localized) map, actually drive there. Two effects: (1) records nav_target so the Gemini child primes on the reference photo + announces the destination; (2) if drivable, dispatches a Nav2 goal (arbiter-gated, with arrival monitoring). A place with no nav link is announce-only, as before.""" g = _require_zones() p = g.get_place(zone_id, place_id) if p is None: raise HTTPException(404, f"zone_{zone_id}/place_{place_id} not found") recognition_state.mutate(STATE_PATH, nav_target_zone_id=zone_id, nav_target_place_id=place_id) log.info("nav target set → zone_%d/place_%d (%s)", zone_id, place_id, p.name or "(unnamed)") zone = g.get_zone(zone_id) drive = await _maybe_drive_to_place(zone, p) return {"ok": True, "nav_target": {"zone_id": zone_id, "place_id": place_id, "place_name": p.name}, "drive": drive} @router.post("/nav/clear") async def clear_nav_target(): recognition_state.mutate(STATE_PATH, nav_target_zone_id=0, nav_target_place_id=0) log.info("nav target cleared") return {"ok": True, "nav_target": None} def _resolve_map_path(client, linked_map: str) -> Optional[str]: """Find the .db path for a zone's linked map name via the nav client.""" want = (linked_map or "").strip().lower() want_stem = want[:-3] if want.endswith(".db") else want try: maps = client.list_maps() or [] except Exception: return None for m in maps: nm = (m.get("name") or "").strip().lower() if nm == want or (nm[:-3] if nm.endswith(".db") else nm) == want_stem: return m.get("path") return None @router.post("/{zone_id}/gemini_nav/start") async def gemini_nav_start(zone_id: int): """Enter 'Gemini Nav' for a zone: localize the zone's map, turn on camera + face + zone recognition + movement, ensure the Gemini session is live, and greet the user so they can converse to navigate. The robot only ever runs ONE map; this loads the zone's map in localize-only mode (so it cannot fresh-map while driving), exactly as the user requires. """ g = _require_zones() zone = g.get_zone(zone_id) if zone is None: raise HTTPException(404, f"zone_{zone_id} not found") linked_map = getattr(zone, "linked_map", None) if not linked_map: raise HTTPException(400, "This zone has no linked nav2 map — link one first.") # 1) Localize the zone's map (single bringup, mode 3 — no fresh mapping). loaded: dict = {"ok": False, "reason": "nav_unavailable"} try: from Project.Sanad.dashboard.routes import navigation as navmod client = getattr(navmod, "_CLIENT", None) if client is not None: db_path = await asyncio.to_thread(_resolve_map_path, client, linked_map) if db_path: loaded = await asyncio.to_thread(client.load_map, db_path) else: loaded = {"ok": False, "reason": "map_not_found", "map": linked_map} except Exception as exc: # noqa: BLE001 loaded = {"ok": False, "reason": "load_error", "detail": str(exc)[:160]} # 2) Camera + face + zone recognition + movement ON for the session. recognition_state.mutate(STATE_PATH, vision_enabled=True, face_rec_enabled=True, zone_rec_enabled=True, movement_enabled=True) _bump_zones_version() # 3) Ensure the Gemini session is live, then greet (zone + drivable places). session_started = False try: from Project.Sanad.main import live_sub if live_sub is not None: if hasattr(live_sub, "is_running") and not live_sub.is_running(): await asyncio.to_thread(live_sub.start) session_started = True drivable = [p.name or p.nav_place for p in zone.places if getattr(p, "nav_place", None)] zname = zone.name or f"zone {zone_id}" if drivable: placelist = ", ".join(str(x) for x in drivable) greet = (f"You are now in the '{zname}' zone. You can drive the " f"user to: {placelist}. Greet the user warmly in your " f"normal Khaleeji style and ask where they would like to go.") else: greet = (f"You are now in the '{zname}' zone, but no drivable " f"places are linked to its map yet. Greet the user and " f"say places still need to be linked before you can drive.") if hasattr(live_sub, "send_state"): live_sub.send_state("nav_zone", greet) except Exception as exc: # noqa: BLE001 log.warning("gemini_nav greet failed: %s", exc) return {"ok": True, "zone_id": zone_id, "zone": _zone_to_dict(zone), "loaded": loaded, "session_started": session_started}