Sanad_Package_4/app_p4.py

444 lines
18 KiB
Python

#!/usr/bin/env python3
"""Sanad Package 4 — Custom AI Guide Tour launcher (sanad-guide).
P4 orchestrates a configurable guided tour by COMPOSING already-shipping
primitives: nav goals + arrival feedback (WebNav3Client + goal_monitor + arbiter),
places (zone_gallery), narration (live_sub.send_state), personalized greeting
(recognition, if P3 entitled), gesture/expression (mask, if P2 entitled) — plus
the NEW tour engine (ordered narrated stops with blocking arrival sequencing).
TWO-CONTAINER split (forced by the ROS2/DDS constraints): this `sanad-guide`
container is pure Python (self-contained, no ROS2); it talks to the SEPARATE
`sanad-nav` (web_nav3 / Nav2 / rosbridge) over HTTP (WEB_NAV3_URL) + rosbridge.
Dashboard on :8014. Capabilities scale with the license (degrade gracefully).
Kept Python-3.8 compatible.
"""
from __future__ import annotations
import atexit
import importlib
import os
import sys
import types
from pathlib import Path
# ── 1. namespace bootstrap ───────────────────────────────────────────────────
_APP = Path(os.environ.get("SANAD_APP_DIR", "/app"))
if str(_APP) not in sys.path:
sys.path.insert(0, str(_APP))
_MASK_DIR = os.environ.setdefault("SANAD_MASK_DIR", str(_APP / "mask"))
if _MASK_DIR and _MASK_DIR not in sys.path:
sys.path.insert(0, _MASK_DIR)
if "Project" not in sys.modules:
_proj = types.ModuleType("Project"); _proj.__path__ = []
sys.modules["Project"] = _proj
if "Project.Sanad" not in sys.modules:
_sanad = importlib.import_module("Sanad")
sys.modules["Project.Sanad"] = _sanad
sys.modules["Project"].Sanad = _sanad # type: ignore[attr-defined]
sys.path.insert(0, str(Path(__file__).resolve().parent)) # package-local modules
from Project.Sanad.core import asyncio_compat # noqa: E402,F401
from Project.Sanad.core.logger import get_logger # noqa: E402
log = get_logger("pkg4.app")
PACKAGE = "P4"
PACKAGE_TITLE = "Sanad — Custom AI Guide Tour (P4)"
P4_SPA_TABS = ["operations", "voice", "navigation", "livemap", "mapeditor", "mask", "recordings", "settings"]
P4_SPA_HIDE = ["motion", "controller", "recognition", "temp", "terminal"]
# routes P4 does not mount → short-circuit client-side (no "Not Found" toasts).
P4_UNMOUNTED = ["/api/motion", "/api/controller", "/api/skills", "/api/macros",
"/api/replay", "/api/wake-phrases", "/api/live-voice", "/api/scripts",
"/api/recognition"]
def _lic():
from sanad_pkg import license as _l
return _l.current()
def _safe(name, factory):
try:
return factory()
except Exception:
log.exception("P4: could not construct %s — degraded", name)
return None
# ── 2. construct the orchestrator subsystems ─────────────────────────────────
def _build_singletons():
from Project.Sanad.core.brain import Brain
from Project.Sanad.voice.audio_manager import AudioManager
from Project.Sanad.gemini.client import GeminiVoiceClient
from Project.Sanad.gemini.subprocess import GeminiSubprocess
lic = _lic()
has_p2 = bool(lic.package("P2") or lic.feature("mask", False) or lic.feature("multilingual", False))
has_p3 = bool(lic.package("P3") or lic.feature("face_rec", False))
has_nav_lic = bool(lic.feature("navigation", True) or lic.package("P4"))
brain = _safe("brain", Brain)
audio_mgr = _safe("audio_mgr", AudioManager)
voice_client = _safe("voice_client", GeminiVoiceClient)
live_sub = _safe("live_sub", lambda: GeminiSubprocess())
typed_replay = None
if voice_client is not None and audio_mgr is not None:
try:
from Project.Sanad.voice.typed_replay import TypedReplayEngine
typed_replay = _safe("typed_replay", lambda: TypedReplayEngine(voice_client, audio_mgr))
except Exception:
pass
# NAV bridge — WebNav3Client (pure requests → the separate web_nav3 :8765).
nav_client = None
if has_nav_lic:
try:
from Project.Sanad.navigation import WebNav3Client
base = os.environ.get("WEB_NAV3_URL") or "http://127.0.0.1:8765"
robot = os.environ.get("SANAD_ROBOT_NAME") or "sanad"
nav_client = _safe("nav_client", lambda: WebNav3Client(base_url=base, robot=robot))
if nav_client is not None:
log.info("P4: WebNav3Client → %s (robot=%s)", base, robot)
except Exception:
log.exception("P4: WebNav3Client unavailable — tour degrades to preset stops")
zone_gallery = None
try:
from Project.Sanad.vision.zone_gallery import ZoneGallery
zone_gallery = _safe("zone_gallery", lambda: ZoneGallery())
except Exception:
log.exception("P4: ZoneGallery unavailable")
# Optional mask (P2) — per-stop gesture/expression.
mask_face = None
if has_p2:
try:
from Project.Sanad.face.mask_face import FaceController
mask_face = _safe("mask_face", FaceController)
except Exception:
log.exception("P4: FaceController unavailable")
# Optional recognition (P3) — personalized greetings at stops.
camera = gallery = None
if has_p3:
try:
from Project.Sanad.vision.camera import CameraDaemon
camera = _safe("camera", lambda: CameraDaemon())
except Exception:
log.exception("P4: CameraDaemon unavailable")
try:
from Project.Sanad.vision.face_gallery import FaceGallery
gallery = _safe("gallery", lambda: FaceGallery())
except Exception:
log.exception("P4: FaceGallery unavailable")
# attachments
for meth, val in (("attach_voice", voice_client), ("attach_audio_manager", audio_mgr)):
if brain is not None and val is not None and hasattr(brain, meth):
try:
getattr(brain, meth)(val)
except Exception:
log.exception("brain.%s failed", meth)
if live_sub is not None:
if audio_mgr is not None and hasattr(live_sub, "attach_audio_manager"):
try:
live_sub.attach_audio_manager(audio_mgr)
except Exception:
pass
if camera is not None and hasattr(live_sub, "attach_camera"):
try:
live_sub.attach_camera(camera)
except Exception:
pass
# NEW tour engine (store + runtime), bound to the nav primitives.
tour_store = tour_runtime = None
try:
from tour_engine import TourStore, TourRuntime
tour_store = _safe("tour_store", TourStore)
gm = arb = None
try:
gm = importlib.import_module("Project.Sanad.navigation.goal_monitor")
except Exception:
log.exception("P4: goal_monitor unavailable")
try:
arb = importlib.import_module("Project.Sanad.dashboard.routes._arbiter")
except Exception:
log.exception("P4: _arbiter unavailable")
if tour_store is not None:
tour_runtime = _safe("tour_runtime", lambda: TourRuntime(
tour_store, nav_client=nav_client, goal_monitor=gm, arbiter=arb,
live_sub=live_sub, mask_face=mask_face, log=log,
has_nav=bool(nav_client is not None), has_mask=bool(mask_face is not None)))
except Exception:
log.exception("P4: tour engine init failed")
log.info("P4 capabilities — nav=%s mask(P2)=%s recognition(P3)=%s",
nav_client is not None, mask_face is not None, camera is not None)
return dict(brain=brain, audio_mgr=audio_mgr, voice_client=voice_client,
typed_replay=typed_replay, live_sub=live_sub, nav_client=nav_client,
zone_gallery=zone_gallery, mask_face=mask_face, camera=camera,
gallery=gallery, tour_store=tour_store, tour_runtime=tour_runtime)
def _try(fn, *a):
try:
return fn(*a)
except Exception:
log.exception("callback failed")
def _wire_mask(s):
mask_face = s.get("mask_face"); live_sub = s.get("live_sub")
if mask_face is None or live_sub is None:
return
if hasattr(live_sub, "register_mouth_callback"):
_try(live_sub.register_mouth_callback, lambda lvl: getattr(mask_face, "_gemini_linked", False) and _try(mask_face.set_mouth, int(lvl)))
if hasattr(live_sub, "register_face_callback"):
_HOLD = {"heart": 2.6, "love": 2.6, "kiss": 2.4, "laugh": 2.2, "surprised": 1.8, "confused": 1.8}
_try(live_sub.register_face_callback, lambda n: getattr(mask_face, "_gemini_linked", False) and _try(mask_face.react, str(n), _HOLD.get(n, 1.6)))
if hasattr(live_sub, "register_social_callback"):
def _on_social(account):
if not getattr(mask_face, "_gemini_linked", False):
return
import threading as _th
def _run(acc=str(account)):
try:
from Project.Sanad.dashboard.routes.mask_social import show_social_on_mask
show_social_on_mask(acc)
except Exception:
log.exception("show_social_on_mask failed")
_th.Thread(target=_run, daemon=True, name="mask-social").start()
_try(live_sub.register_social_callback, _on_social)
try:
from Project.Sanad.core.event_bus import bus as _bus
_bus.on("brain.gestural_speaking_changed",
lambda enabled=False, **_k: (_try(mask_face.set_speaking, bool(enabled)),
(not enabled) and _try(mask_face.set_listening)))
_bus.on("voice.connected", lambda **_k: _try(mask_face.set_listening))
_bus.on("voice.disconnected", lambda **_k: _try(mask_face.set_idle))
log.info("P4: LED face wired (lip-sync + emotions + social + lifelike)")
except Exception:
log.exception("P4: mask bus hooks failed")
def _inject_main_shim(singletons):
shim = types.ModuleType("Project.Sanad.main")
for k, v in singletons.items():
setattr(shim, k, v)
for k in ("arm", "wake_mgr", "macro_rec", "macro_play", "teacher", "live_voice",
"loco_controller", "movement_dispatch", "local_tts"):
if not hasattr(shim, k):
setattr(shim, k, None)
shim.SUBSYSTEMS = {k: singletons.get(k) for k in ( # type: ignore[attr-defined]
"brain", "audio_mgr", "voice_client", "typed_replay", "live_sub", "nav_client",
"zone_gallery", "mask_face", "camera", "gallery", "tour_store", "tour_runtime")}
sys.modules["Project.Sanad.main"] = shim
return shim
# ── 3. build the P4 FastAPI app ───────────────────────────────────────────────
def _rest_routes():
lic = _lic()
rest = [
("health", "/api", "health"), ("system", "/api/system", "system"),
("voice", "/api/voice", "voice"), ("audio_control", "/api/audio", "audio"),
("prompt", "/api/prompt", "prompt"), ("typed_replay", "/api/typed-replay", "typed-replay"),
("records", "/api/records", "records"), ("logs", "/api/logs", "logs"),
("live_subprocess", "/api/live-subprocess", "live-subprocess"),
("navigation", "/api/nav", "nav"), ("zones", "/api/zones", "zones"),
]
if lic.package("P2") or lic.feature("mask", False):
rest += [("mask", "/api/mask", "mask"), ("mask_social", "/api/mask", "mask-social")]
if lic.package("P3") or lic.feature("face_rec", False):
rest += [("recognition", "/api/recognition", "recognition")]
# recognition is entitled → don't short-circuit it client-side.
try:
P4_UNMOUNTED.remove("/api/recognition")
except ValueError:
pass
return rest
_P4_WS = ["log_stream"]
def _tab_filter_snippet():
import json as _json
css = ",".join(".tab[onclick*=\"switchTab('%s')\"],#tab-%s" % (t, t) for t in P4_SPA_HIDE) + ",#status-pills"
return (
"<style>%s{display:none!important}</style>"
"<script>window.SANAD_PACKAGE=%s;"
"(function(){var B=%s,_f=window.fetch;"
"window.fetch=function(i,o){try{var u=(typeof i==='string')?i:(i&&i.url)||'',"
"p=u.replace(/^https?:\\/\\/[^/]+/,'');"
"for(var k=0;k<B.length;k++){if(p.indexOf(B[k])===0)"
"return Promise.resolve(new Response('{}',{status:200,headers:{'Content-Type':'application/json'}}));}"
"}catch(e){}return _f.apply(this,arguments);};})();</script>"
% (css, _json.dumps({"name": PACKAGE, "title": PACKAGE_TITLE, "tabs": P4_SPA_TABS}),
_json.dumps(P4_UNMOUNTED))
)
def build_app():
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse
from Project.Sanad.config import BASE_DIR
from Project.Sanad.core.config_loader import section as _cfg_section
app_cfg = _cfg_section("dashboard", "app")
app = FastAPI(title=PACKAGE_TITLE, version="0.1.0")
loaded, failed = [], {}
def _register(mod_name, prefix, tag, package="Project.Sanad.dashboard.routes"):
try:
mod = importlib.import_module("%s.%s" % (package, mod_name))
if not hasattr(mod, "router"):
raise AttributeError("no 'router'")
kw = {}
if prefix:
kw["prefix"] = prefix
if tag:
kw["tags"] = [tag]
app.include_router(mod.router, **kw)
loaded.append(mod_name)
except Exception as exc:
failed[mod_name] = str(exc)
log.exception("P4: router %s failed — skipped", mod_name)
for m, p, t in _rest_routes():
_register(m, p, t)
for m in _P4_WS:
_register(m, None, "websocket", package="Project.Sanad.dashboard.websockets")
try:
import routes_tour
app.include_router(routes_tour.router, prefix="/api/tour", tags=["tour"])
loaded.append("routes_tour")
except Exception as exc:
failed["routes_tour"] = str(exc)
log.exception("P4: routes_tour failed — /api/tour unavailable")
try:
import routes_p4
app.include_router(routes_p4.router, prefix="/api/p4", tags=["p4"])
loaded.append("routes_p4")
except Exception as exc:
failed["routes_p4"] = str(exc)
static_dir = BASE_DIR / app_cfg.get("static_subdir", "dashboard/static")
try:
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
except Exception:
log.exception("P4: static mount failed")
@app.get("/api/package")
async def package_info():
lic = _lic()
return {
"package": PACKAGE, "title": PACKAGE_TITLE, "tabs": P4_SPA_TABS,
"features": {"guide_tour": bool(lic.feature("guide_tour", True)),
"navigation": bool(lic.feature("navigation", True)),
"uses_p2_mask": bool(lic.package("P2") or lic.feature("mask", False)),
"uses_p3_recognition": bool(lic.package("P3") or lic.feature("face_rec", False))},
"endpoints": {"tour": "GET/POST /api/tour", "tour_start": "POST /api/tour/start",
"nav": "GET /api/nav/status", "places": "GET /api/zones/"},
"loaded_routes": loaded, "failed_routes": failed, "license": lic.summary(),
}
def _filtered_spa():
index = static_dir / "index.html"
if not index.exists():
return JSONResponse({"message": "SPA index.html not found", "loaded": loaded, "failed": failed})
try:
html = index.read_text(encoding="utf-8")
filt = _tab_filter_snippet()
if "</head>" in html:
html = html.replace("</head>", filt + "</head>", 1)
elif "</body>" in html:
html = html.replace("</body>", filt + "</body>", 1)
else:
html = filt + html
return HTMLResponse(html)
except OSError as exc:
return JSONResponse({"error": "index.html unreadable: %s" % exc}, status_code=500)
@app.get("/")
async def root():
return _filtered_spa()
@app.get("/full")
async def full_dashboard():
return _filtered_spa()
log.info("P4 dashboard built — routers loaded=%s failed=%s", loaded, list(failed))
return app
def _init_dds_for_audio():
if os.environ.get("SANAD_BUS_ADDR"):
return
try:
from unitree_sdk2py.core.channel import ChannelFactoryInitialize
iface = os.environ.get("SANAD_DDS_INTERFACE", "eth0")
ChannelFactoryInitialize(0, iface)
log.info("P4: DDS ChannelFactoryInitialize(0, %s) done — chest audio enabled", iface)
except Exception:
log.exception("P4: DDS init failed — chest audio unavailable (plugged still works)")
def _enforce_keyless_default():
import Project.Sanad.config as _cfg
env_key = (os.environ.get("SANAD_GEMINI_API_KEY") or "").strip()
saved = ""
try:
from Project.Sanad.config import load_config
saved = ((load_config().get("gemini") or {}).get("api_key") or "").strip()
except Exception:
pass
if env_key or saved:
return
_cfg.GEMINI_API_KEY = ""
try:
import Project.Sanad.gemini.client as _gc
_gc.GEMINI_API_KEY = ""
except Exception:
pass
log.info("P4: keyless by default — customer adds a Gemini key via the dashboard")
def main():
host = os.environ.get("SANAD_DASHBOARD_HOST", "0.0.0.0")
port = int(os.environ.get("SANAD_DASHBOARD_PORT", "8014"))
log.info("Sanad P4 (Custom AI Guide Tour) starting — %s:%d", host, port)
try:
from sanad_pkg.bus import bus
bus.connect()
except Exception:
log.exception("bus connect failed (continuing in-process)")
_init_dds_for_audio()
_enforce_keyless_default()
singletons = _build_singletons()
_wire_mask(singletons)
_inject_main_shim(singletons)
_mask = singletons.get("mask_face")
if _mask is not None and hasattr(_mask, "shutdown"):
atexit.register(lambda: _mask.shutdown())
_rt = singletons.get("tour_runtime")
if _rt is not None and hasattr(_rt, "stop"):
atexit.register(lambda: _try(_rt.stop))
import uvicorn
app = build_app()
uvicorn.run(app, host=host, port=port, log_level="info")
if __name__ == "__main__":
main()