598 lines
22 KiB
Python
598 lines
22 KiB
Python
"""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}
|