Update 2026-06-17 15:49:27
This commit is contained in:
commit
aba3118dfc
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
Logs/
|
||||
*.log
|
||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@ -0,0 +1,39 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Sanad Package 1 — Basic Communication.
|
||||
# BUILD CONTEXT MUST BE Packages/ (FROM the prebuilt sanad-base):
|
||||
# docker build -f Sanad_Package_1/Dockerfile -t sanad-p1:latest .
|
||||
# (the top-level Packages/docker-compose.yml sets context: . for this service.)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
ARG BASE_IMAGE=sanad-base:latest
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
# P1 (comms) extra system deps — PortAudio + a C toolchain so pyaudio's C
|
||||
# extension compiles on the slim base (python:3.10-slim has no compiler).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
portaudio19-dev libportaudio2 build-essential python3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# P1 Python deps (sanad-base is python:3.10 → google-genai installs cleanly).
|
||||
COPY Sanad_Package_1/requirements-p1.txt /tmp/requirements-p1.txt
|
||||
RUN python3 -m pip install --no-cache-dir -r /tmp/requirements-p1.txt
|
||||
|
||||
# P1 launcher + routes + entrypoint + config (Sanad source baked into sanad-base).
|
||||
COPY Sanad_Package_1/app_p1.py /app/app_p1.py
|
||||
COPY Sanad_Package_1/routes_p1.py /app/routes_p1.py
|
||||
COPY Sanad_Package_1/entrypoint.sh /app/entrypoint.sh
|
||||
COPY Sanad_Package_1/config /app/pkg1_config
|
||||
COPY Sanad_Package_1/static /app/pkg1_static
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Ship KEYLESS — strip any Gemini key baked into the Sanad config so the vendor
|
||||
# key never ships in the image; the customer adds their own via the dashboard.
|
||||
COPY Sanad_Package_1/strip_key.py /tmp/strip_key.py
|
||||
RUN python3 /tmp/strip_key.py && rm -f /tmp/strip_key.py
|
||||
|
||||
ENV SANAD_PACKAGE=P1 \
|
||||
SANAD_DASHBOARD_PORT=8011 \
|
||||
SANAD_DASHBOARD_HOST=0.0.0.0 \
|
||||
SANAD_P1_STATIC=/app/pkg1_static
|
||||
EXPOSE 8011
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
125
README.md
Normal file
125
README.md
Normal file
@ -0,0 +1,125 @@
|
||||
# Sanad Package 1 — Basic Communication
|
||||
|
||||
Hands-free conversation in **one operator-selected language** (Gemini Live),
|
||||
audio via the **G1 chest** or **any plugged USB mic/speaker (Anker)**. **No**
|
||||
voice-command motion, vision, recognition, or navigation. Dashboard on **:8011**.
|
||||
|
||||
## What it ships
|
||||
- `app_p1.py` — launcher: bootstraps the `Project.Sanad` namespace, constructs
|
||||
ONLY the comms subsystems (`brain`, `audio_mgr`, `voice_client`, `local_tts`,
|
||||
`typed_replay`, `live_sub`), injects a P1-scoped `Project.Sanad.main` shim, and
|
||||
mounts ONLY the P1 dashboard routers (`voice`, `audio`, `prompt`,
|
||||
`typed-replay`, `records`, `logs`, `live-subprocess`, `health`, `system`) +
|
||||
the logs websocket. Serves the real Sanad SPA with non-P1 tabs hidden.
|
||||
- `entrypoint.sh` — license gate (`license_check P1`; clean exit if unlicensed),
|
||||
resolves language/audio/port (env > license feature > `config/p1_config.json`).
|
||||
- `Dockerfile` / `requirements-p1.txt` — `FROM sanad-base`, adds PortAudio +
|
||||
`google-genai`.
|
||||
- `config/p1_config.json` — defaults (language, audio profile, port, tab set).
|
||||
- `docker-compose.p1.yml` — standalone run; top-level compose wires `--profile p1`.
|
||||
|
||||
It does **not** fork Sanad — it reuses the canonical source baked into
|
||||
`sanad-base`.
|
||||
|
||||
## Run & stop P1
|
||||
|
||||
**A) Docker (the productized way)** — from `Project/Packages` on the robot:
|
||||
```bash
|
||||
docker compose --profile base build # build sanad-base once
|
||||
docker compose --profile p1 up -d --build # run -> http://<robot>:8011
|
||||
docker compose --profile p1 logs -f p1 # view logs
|
||||
docker compose --profile p1 down # stop
|
||||
# audio: SANAD_AUDIO_PROFILE=builtin (chest) | plugged (USB/Anker)
|
||||
# language: license `language` feature, or SANAD_LANGUAGE=en docker compose --profile p1 up -d
|
||||
```
|
||||
|
||||
**B) Dev mode (no Docker)** — run P1 in the robot's `gemini_sdk` conda env via the
|
||||
control script (deployed to `~/sanad_deploy/Packages/Sanad_Package_1/p1ctl.sh`):
|
||||
```bash
|
||||
cd ~/sanad_deploy/Packages/Sanad_Package_1
|
||||
./p1ctl.sh start # launch on :8011 (coexists with Sanad on :8000)
|
||||
./p1ctl.sh status # process + /api/health
|
||||
./p1ctl.sh logs 80 # tail the P1 log
|
||||
./p1ctl.sh restart
|
||||
./p1ctl.sh stop
|
||||
```
|
||||
Deploy/update from the workstation first:
|
||||
`rsync -az --exclude __pycache__ Project/Packages Project/Sanad unitree@<robot>:~/sanad_deploy/`
|
||||
|
||||
**Logs:** the dashboard's **Logs** card streams live (`/ws/logs`) and the **⬇ Download**
|
||||
button saves the full bundle (`/api/logs/bundle`) as `sanad_p1_logs_<ts>.txt`.
|
||||
|
||||
## Endpoints (P1 subset)
|
||||
`/` (filtered SPA) · `/api/package` (manifest + license + api-key status) ·
|
||||
`/api/voice/*` · `/api/audio/*` · `/api/prompt/*` · `/api/typed-replay/*` ·
|
||||
`/api/records/*` · `/api/logs/*` · `/api/live-subprocess/*` · `/api/health` ·
|
||||
`/api/system/info` · `/ws/logs` · **`/api/p1/*`** (P1 settings, see below).
|
||||
|
||||
## The P1 dashboard (http://<robot>:8011)
|
||||
- **`/`** — a clean **P1 control page** with cards: Conversation (start/stop),
|
||||
Say-a-line, **Persona** (Save & Apply), **Gemini API key**, **Audio** (speaker
|
||||
profile + volume + mute + rescan), and a live Logs view. This is the everyday
|
||||
UI — no API knowledge needed.
|
||||
- **`/full`** — the complete Sanad SPA (advanced), with non-P1 tabs hidden
|
||||
(motion/recognition/nav/temperature/terminal belong to other packages).
|
||||
|
||||
What you can do (cards on `/`, also the matching endpoints):
|
||||
|
||||
| You want to… | Where / endpoint |
|
||||
|---|---|
|
||||
| **Talk to the robot** (start/stop the live conversation) | Voice tab · `POST /api/live-subprocess/start|stop`, `/api/voice/connect|disconnect` |
|
||||
| **Make it say a specific line** | Voice/Typed-replay · `POST /api/voice/generate`, `POST /api/typed-replay/say` |
|
||||
| **Change the robot persona** (who it is, tone, **language/dialect**) | Settings · `GET/POST /api/p1/persona` (or `/api/prompt`) |
|
||||
| **Set / update the Gemini API key** | Settings · `GET/POST /api/p1/api-key` |
|
||||
| **Pick speaker/mic** (chest vs Anker/USB), **volume**, mute | Audio · `/api/audio/devices|profiles|select-profile|select-manual|g1-speaker/volume|*/mute|refresh|reset` |
|
||||
| **Manage saved recordings** (save/replay/rename/delete) | Recordings · `/api/records/*`, `/api/typed-replay/*` |
|
||||
| **See logs / system / health** | Settings · `/api/logs`, `/ws/logs`, `/api/system/info`, `/api/health` |
|
||||
|
||||
## Change the robot persona
|
||||
The persona is the system prompt at `scripts/sanad_script.txt` (who Sanad is,
|
||||
tone, and the language/dialect it speaks). Edit it from the Settings tab or:
|
||||
```bash
|
||||
curl http://<robot>:8011/api/p1/persona # current persona + rules
|
||||
curl -X POST http://<robot>:8011/api/p1/persona \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"content":"You are Sanad, a friendly Emirati guide. Speak Khaleeji Arabic..."}'
|
||||
```
|
||||
`POST /api/p1/persona` writes the persona **and restarts the live session** so it
|
||||
takes effect immediately (the base `/api/prompt/update` writes the file but a
|
||||
running session keeps the old persona until restarted). This is also how you
|
||||
steer the conversation **language** (put the language directive in the persona).
|
||||
|
||||
## Set / update the Gemini API key
|
||||
Two ways, both available in P1:
|
||||
- **Base (Sanad):** `GET/POST /api/voice/api-key` — the SPA Voice/Settings tab
|
||||
uses this. POST persists to `data/motions/config.json`, hot-swaps the
|
||||
in-memory key, and disconnects the short-session client. The **live** Gemini
|
||||
subprocess must be restarted (Stop→Start) to pick it up.
|
||||
- **P1 convenience:** `GET/POST /api/p1/api-key` — same persist + hot-swap, and
|
||||
**also auto-restarts the live Gemini subprocess** so the new key applies
|
||||
immediately. `GET /api/p1/settings` returns api-key status + language + audio
|
||||
profile + whether a live session is running.
|
||||
|
||||
```bash
|
||||
# set or update the key (works for first-time set AND replacing an existing key)
|
||||
curl -X POST http://<robot>:8011/api/p1/api-key \
|
||||
-H 'Content-Type: application/json' -d '{"api_key":"AIza...."}'
|
||||
# check status (masked; never returns the full key)
|
||||
curl http://<robot>:8011/api/p1/api-key
|
||||
```
|
||||
Keys are validated (must start with `AIza`, length check), stored masked in any
|
||||
response, and persisted to `data/motions/config.json` (highest precedence, ahead
|
||||
of `SANAD_GEMINI_API_KEY` env and `core_config.json`).
|
||||
|
||||
## Plug-and-play status
|
||||
- **Base:** `python:3.10-slim` (multi-arch) → `google-genai` installs cleanly, no
|
||||
CUDA needed. Build on the Jetson (or x86) with `docker compose --profile base build`.
|
||||
- **Works out of the box** with a plugged USB speaker/mic. The entrypoint runs a
|
||||
**preflight** (python / google-genai / pyaudio / Unitree-SDK / audio profile)
|
||||
and prints clear guidance if something's missing.
|
||||
- **Language** is set via the **Persona** card (put the dialect/language directive
|
||||
in the system prompt — saving applies it to the live session immediately).
|
||||
- **Pending for true "pull-and-run":** prebuilt `linux/arm64` image in a registry;
|
||||
bundling `unitree_sdk2_python` for turnkey chest (`builtin`) audio (today: use
|
||||
`plugged`, or mount the SDK). In a multi-package deployment, audio output later
|
||||
routes through the `Sanad_Core` hwbroker audio-lock (P1 standalone speaks directly).
|
||||
371
app_p1.py
Normal file
371
app_p1.py
Normal file
@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sanad Package 1 — Basic Communication launcher.
|
||||
|
||||
P1 = hands-free conversation in ONE operator-selected language (Gemini Live),
|
||||
audio over the G1 chest OR any plugged USB device. NO voice-command motion,
|
||||
NO vision/recognition, NO navigation.
|
||||
|
||||
This is a *containerization wrapper* around the canonical Sanad source — it does
|
||||
NOT fork Sanad. It:
|
||||
1. bootstraps the `Project.Sanad` namespace (deployed layout),
|
||||
2. constructs ONLY the P1 comms subsystems,
|
||||
3. injects a P1-scoped `Project.Sanad.main` shim (the dashboard routers import
|
||||
their singletons from there — lazily, inside handlers),
|
||||
4. mounts ONLY the P1 dashboard routers + the logs websocket,
|
||||
5. serves the real Sanad SPA with non-P1 tabs hidden,
|
||||
6. runs uvicorn on the P1 port (default :8011).
|
||||
|
||||
Kept Python-3.8 compatible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
# ── 1. namespace bootstrap (mirrors Sanad/main.py, deployed layout) ──────────
|
||||
# In the image: /app/Sanad is the canonical package, /app is on sys.path.
|
||||
_APP = Path(os.environ.get("SANAD_APP_DIR", "/app"))
|
||||
_SANAD_DIR = _APP / "Sanad"
|
||||
if str(_APP) not in sys.path:
|
||||
sys.path.insert(0, str(_APP))
|
||||
|
||||
if "Project" not in sys.modules:
|
||||
_proj = types.ModuleType("Project")
|
||||
_proj.__path__ = [] # namespace package
|
||||
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]
|
||||
|
||||
# asyncio.to_thread shim for Py3.8 — before any Sanad module that uses it.
|
||||
from Project.Sanad.core import asyncio_compat # noqa: E402,F401
|
||||
from Project.Sanad.core.logger import get_logger # noqa: E402
|
||||
|
||||
log = get_logger("pkg1.app")
|
||||
|
||||
PACKAGE = "P1"
|
||||
PACKAGE_TITLE = "Sanad — Basic Communication (P1)"
|
||||
# SPA tab ids (from dashboard/static/index.html) this package SHOWS.
|
||||
P1_SPA_TABS = ["operations", "voice", "recordings", "settings"]
|
||||
P1_SPA_HIDE = ["motion", "recognition", "temp", "controller", "terminal"]
|
||||
|
||||
|
||||
def _safe(name, factory):
|
||||
try:
|
||||
return factory()
|
||||
except Exception:
|
||||
log.exception("P1: could not construct %s — degraded", name)
|
||||
return None
|
||||
|
||||
|
||||
# ── 2. construct ONLY P1 comms 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
|
||||
|
||||
brain = _safe("brain", Brain)
|
||||
audio_mgr = _safe("audio_mgr", AudioManager)
|
||||
voice_client = _safe("voice_client", GeminiVoiceClient)
|
||||
|
||||
local_tts = None
|
||||
try:
|
||||
from Project.Sanad.voice.local_tts import LocalTTSEngine
|
||||
local_tts = _safe("local_tts", LocalTTSEngine)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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
|
||||
|
||||
# P1 keeps the Gemini live supervisor (basic profile, motion paths off).
|
||||
live_sub = _safe("live_sub", lambda: GeminiSubprocess())
|
||||
|
||||
# Brain attachments (only what P1 has).
|
||||
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)
|
||||
|
||||
return dict(brain=brain, audio_mgr=audio_mgr, voice_client=voice_client,
|
||||
local_tts=local_tts, typed_replay=typed_replay, live_sub=live_sub)
|
||||
|
||||
|
||||
def _inject_main_shim(singletons):
|
||||
"""Create a P1-scoped `Project.Sanad.main` so dashboard routers resolve
|
||||
their `from Project.Sanad.main import <x>` against P1's subset. Excluded
|
||||
subsystems are present as None (routers guard for None)."""
|
||||
shim = types.ModuleType("Project.Sanad.main")
|
||||
# P1 singletons
|
||||
for k, v in singletons.items():
|
||||
setattr(shim, k, v)
|
||||
# excluded subsystems — must EXIST (as None) so lazy imports never ImportError
|
||||
for k in ("arm", "wake_mgr", "macro_rec", "macro_play", "teacher",
|
||||
"live_voice", "camera", "gallery", "zone_gallery",
|
||||
"loco_controller", "movement_dispatch"):
|
||||
if not hasattr(shim, k):
|
||||
setattr(shim, k, None)
|
||||
shim.SUBSYSTEMS = { # type: ignore[attr-defined]
|
||||
"brain": singletons.get("brain"),
|
||||
"audio_mgr": singletons.get("audio_mgr"),
|
||||
"voice_client": singletons.get("voice_client"),
|
||||
"local_tts": singletons.get("local_tts"),
|
||||
"typed_replay": singletons.get("typed_replay"),
|
||||
"live_sub": singletons.get("live_sub"),
|
||||
}
|
||||
sys.modules["Project.Sanad.main"] = shim
|
||||
return shim
|
||||
|
||||
|
||||
# ── 3. build the P1 FastAPI app ───────────────────────────────────────────────
|
||||
# (module, prefix, tag) — P1 subset of dashboard/app.py's _REST_ROUTES.
|
||||
_P1_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"),
|
||||
]
|
||||
_P1_WS = ["log_stream"]
|
||||
|
||||
|
||||
def _tab_filter_snippet():
|
||||
hide_ids = ",".join("#tab-%s" % t for t in P1_SPA_HIDE)
|
||||
hide_words = ["motion", "recognition", "temperature", "controller",
|
||||
"terminal", "replay", "macros", "zones", "places", "map", "tour"]
|
||||
import json as _json
|
||||
return (
|
||||
"<style>%s{display:none!important}</style>"
|
||||
"<script>window.SANAD_PACKAGE=%s;"
|
||||
"document.addEventListener('DOMContentLoaded',function(){"
|
||||
"var hideWords=%s;"
|
||||
"var nav=document.querySelectorAll('button,a,li,.nav-item,[role=\"tab\"]');"
|
||||
"nav.forEach(function(el){var t=(el.textContent||'').trim().toLowerCase();"
|
||||
"if(t&&t.length<20&&hideWords.indexOf(t)>=0){el.style.display='none';}"
|
||||
"var oc=(el.getAttribute&&el.getAttribute('onclick'))||'';"
|
||||
"%s.forEach(function(h){if(oc.indexOf('tab-'+h)>=0||oc.indexOf(\"'\"+h+\"'\")>=0){el.style.display='none';}});"
|
||||
"});});</script>"
|
||||
% (hide_ids,
|
||||
_json.dumps({"name": PACKAGE, "title": PACKAGE_TITLE, "tabs": P1_SPA_TABS}),
|
||||
_json.dumps(hide_words),
|
||||
_json.dumps(P1_SPA_HIDE))
|
||||
)
|
||||
|
||||
|
||||
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("P1: router %s failed — skipped", mod_name)
|
||||
|
||||
for m, p, t in _P1_REST:
|
||||
_register(m, p, t)
|
||||
for m in _P1_WS:
|
||||
_register(m, None, "websocket", package="Project.Sanad.dashboard.websockets")
|
||||
|
||||
# P1-specific routes (package-local, not part of Sanad): /api/p1/* —
|
||||
# first-class "set / update Gemini API key" with live-session restart.
|
||||
try:
|
||||
import routes_p1
|
||||
app.include_router(routes_p1.router, prefix="/api/p1", tags=["p1"])
|
||||
loaded.append("routes_p1")
|
||||
except Exception as exc:
|
||||
failed["routes_p1"] = str(exc)
|
||||
log.exception("P1: routes_p1 failed — /api/p1 unavailable")
|
||||
|
||||
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("P1: static mount failed")
|
||||
|
||||
@app.get("/api/package")
|
||||
async def package_info():
|
||||
from sanad_pkg import license as _lic
|
||||
lic = _lic.current()
|
||||
# Gemini API-key status (masked) — so the dashboard can show whether a
|
||||
# key is set without ever exposing it.
|
||||
api_key = {"has_key": False, "masked": "", "source": "default"}
|
||||
try:
|
||||
import Project.Sanad.config as _cfg_mod
|
||||
from Project.Sanad.dashboard.routes.voice import _mask_api_key
|
||||
_k = getattr(_cfg_mod, "GEMINI_API_KEY", "") or ""
|
||||
try:
|
||||
from Project.Sanad.config import load_config
|
||||
_stored = (load_config().get("gemini", {}) or {}).get("api_key")
|
||||
except Exception:
|
||||
_stored = None
|
||||
api_key = {"has_key": bool(_k), "masked": _mask_api_key(_k),
|
||||
"source": "config_file" if _stored else "default"}
|
||||
except Exception:
|
||||
log.exception("could not read api-key status")
|
||||
return {
|
||||
"package": PACKAGE,
|
||||
"title": PACKAGE_TITLE,
|
||||
"tabs": P1_SPA_TABS,
|
||||
"language": os.environ.get("SANAD_LANGUAGE", lic.feature("language", "ar")),
|
||||
"audio_profile": os.environ.get("SANAD_AUDIO_PROFILE", "builtin"),
|
||||
"api_key": api_key,
|
||||
"endpoints": {
|
||||
"api_key_get": "GET /api/p1/api-key",
|
||||
"api_key_set": "POST /api/p1/api-key {\"api_key\": \"AIza...\"}",
|
||||
"persona_get": "GET /api/p1/persona",
|
||||
"persona_set": "POST /api/p1/persona {\"content\": \"<system prompt>\"}",
|
||||
"settings": "GET /api/p1/settings",
|
||||
},
|
||||
"loaded_routes": loaded,
|
||||
"failed_routes": failed,
|
||||
"license": lic.summary(),
|
||||
}
|
||||
|
||||
# P1's own clean control page (cards). Falls back to the full SPA if missing.
|
||||
p1_static = Path(os.environ.get(
|
||||
"SANAD_P1_STATIC", str(Path(__file__).resolve().parent / "static")))
|
||||
|
||||
def _widget_html():
|
||||
"""The P1 Quick-Controls widget injected into the full SPA (cards on /full)."""
|
||||
w = p1_static / "p1_widget.html"
|
||||
try:
|
||||
return w.read_text(encoding="utf-8") if w.exists() else ""
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
def _filtered_spa():
|
||||
index = static_dir / "index.html"
|
||||
if not index.exists():
|
||||
return JSONResponse({"message": "full SPA index.html not found",
|
||||
"loaded": loaded, "failed": failed})
|
||||
try:
|
||||
html = index.read_text(encoding="utf-8")
|
||||
inject = _tab_filter_snippet() + _widget_html()
|
||||
html = html.replace("</body>", inject + "</body>", 1) if "</body>" in html else html + inject
|
||||
return HTMLResponse(html)
|
||||
except OSError as exc:
|
||||
return JSONResponse({"error": "index.html unreadable: %s" % exc}, status_code=500)
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
page = p1_static / "p1.html"
|
||||
if page.exists():
|
||||
try:
|
||||
return HTMLResponse(page.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
log.exception("could not read p1.html — falling back to SPA")
|
||||
return _filtered_spa()
|
||||
|
||||
@app.get("/full")
|
||||
async def full_dashboard():
|
||||
"""The complete Sanad SPA (advanced), with non-P1 tabs hidden."""
|
||||
return _filtered_spa()
|
||||
|
||||
log.info("P1 dashboard built — routers loaded=%s failed=%s", loaded, list(failed))
|
||||
return app
|
||||
|
||||
|
||||
def _init_dds_for_audio():
|
||||
"""Standalone P1 owns the ONE ChannelFactoryInitialize so the G1 chest
|
||||
AudioClient works — the same DDS init Sanad performs via arm.init(). Without
|
||||
it, AudioClient() fails ('NoneType._ref') and chest playback is silent.
|
||||
In bus/multi-package mode the hwbroker owns DDS, so P1 skips it there."""
|
||||
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("P1: DDS ChannelFactoryInitialize(0, %s) done — chest audio enabled", iface)
|
||||
except Exception:
|
||||
log.exception("P1: DDS init failed — chest audio unavailable (plugged/USB still works)")
|
||||
|
||||
|
||||
def _enforce_keyless_default():
|
||||
"""P1 ships KEYLESS — the customer adds their own Gemini key via the dashboard
|
||||
(saved to data/motions/config.json). Honor only an explicit vendor key
|
||||
(SANAD_GEMINI_API_KEY env) or a customer-saved key; otherwise IGNORE any key
|
||||
baked into the shipped Sanad config so the product never carries a vendor key."""
|
||||
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 # explicit/customer key present — keep it
|
||||
_cfg.GEMINI_API_KEY = ""
|
||||
try:
|
||||
import Project.Sanad.gemini.client as _gc
|
||||
_gc.GEMINI_API_KEY = ""
|
||||
except Exception:
|
||||
pass
|
||||
log.info("P1: keyless by default — customer must add 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", "8011"))
|
||||
|
||||
log.info("Sanad P1 (Basic Communication) starting — %s:%d lang=%s audio=%s",
|
||||
host, port,
|
||||
os.environ.get("SANAD_LANGUAGE", "?"),
|
||||
os.environ.get("SANAD_AUDIO_PROFILE", "builtin"))
|
||||
|
||||
# optional cross-container bus (no-op without SANAD_BUS_ADDR)
|
||||
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()
|
||||
_inject_main_shim(singletons)
|
||||
|
||||
import uvicorn
|
||||
app = build_app()
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
11
config/p1_config.json
Normal file
11
config/p1_config.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"_comment": "Sanad Package 1 (Basic Communication) defaults. Precedence: env var > license feature > this file. The license `language` feature, if set, overrides language_default.",
|
||||
"package": "P1",
|
||||
"title": "Sanad — Basic Communication",
|
||||
"language_default": "ar",
|
||||
"audio_profile_default": "builtin",
|
||||
"port": 8011,
|
||||
"voice_brain": "gemini",
|
||||
"spa_tabs": ["operations", "voice", "recordings", "settings"],
|
||||
"excluded": ["motion", "controller", "recognition", "zones", "navigation", "mask", "macros", "live_voice_triggers"]
|
||||
}
|
||||
38
docker-compose.p1.yml
Normal file
38
docker-compose.p1.yml
Normal file
@ -0,0 +1,38 @@
|
||||
# Standalone compose for Package 1 (Basic Communication).
|
||||
# Prereq: build the base image first:
|
||||
# docker build -f sanad-base/Dockerfile -t sanad-base:latest ..
|
||||
# Then from Packages/:
|
||||
# docker compose -f Sanad_Package_1/docker-compose.p1.yml up --build
|
||||
#
|
||||
# (The top-level Packages/docker-compose.yml wires this under the `p1` profile.)
|
||||
services:
|
||||
p1:
|
||||
build:
|
||||
context: .. # = Project/Packages
|
||||
dockerfile: Sanad_Package_1/Dockerfile
|
||||
args:
|
||||
BASE_IMAGE: sanad-base:latest
|
||||
image: sanad-p1:latest
|
||||
container_name: sanad-p1
|
||||
# Host networking is REQUIRED — the G1 DDS link + Gemini cloud + chest audio.
|
||||
network_mode: host
|
||||
restart: on-failure
|
||||
environment:
|
||||
SANAD_PACKAGE: P1
|
||||
SANAD_DASHBOARD_PORT: "8011"
|
||||
SANAD_DASHBOARD_HOST: "0.0.0.0"
|
||||
SANAD_VOICE_BRAIN: gemini
|
||||
SANAD_AUDIO_PROFILE: "${SANAD_AUDIO_PROFILE:-builtin}" # builtin (chest) | plugged (USB/Anker)
|
||||
SANAD_DDS_INTERFACE: "${SANAD_DDS_INTERFACE:-eth0}"
|
||||
SANAD_LICENSE: /etc/sanad/sanad.lic
|
||||
SANAD_PUBKEY: /etc/sanad/pubkey.ed25519
|
||||
SANAD_LICENSE_BIND: "${SANAD_LICENSE_BIND:-0}"
|
||||
# SANAD_LANGUAGE overrides the license `language` feature if set:
|
||||
SANAD_LANGUAGE: "${SANAD_LANGUAGE:-}"
|
||||
devices:
|
||||
- "/dev/snd:/dev/snd" # USB/plugged audio (Anker) via ALSA/Pulse
|
||||
volumes:
|
||||
- "${SANAD_LICENSE_FILE:-./licensing/sanad.lic.example}:/etc/sanad/sanad.lic:ro"
|
||||
- "../Sanad/data:/app/Sanad/data" # faces/recordings/state persist on host
|
||||
# Optional chest-audio over DDS — mount the vendored SDK if using 'builtin':
|
||||
# - "${UNITREE_SDK_DIR:-/home/unitree/unitree_sdk2_python}:/opt/unitree_sdk2_python:ro"
|
||||
68
entrypoint.sh
Executable file
68
entrypoint.sh
Executable file
@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sanad Package 1 (Basic Communication) entrypoint.
|
||||
# 1) license gate 2) resolve P1 env (env > license > config) 3) launch.
|
||||
set -u
|
||||
PKG="P1"
|
||||
CFG="/app/pkg1_config/p1_config.json"
|
||||
|
||||
# ── 1. license gate ──────────────────────────────────────────────────────────
|
||||
# license_check exits 0 only when entitled. If NOT entitled we exit the
|
||||
# CONTAINER cleanly (code 0) so a restart:on-failure policy won't crash-loop.
|
||||
if ! python3 -m sanad_pkg.license_check "$PKG"; then
|
||||
echo "[$PKG] not licensed for this robot — container exiting cleanly."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 2. resolve config (env wins, then license feature, then config file) ──────
|
||||
read_cfg() { # read_cfg <key>
|
||||
python3 - "$CFG" "$1" <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
try:
|
||||
print(json.load(open(sys.argv[1])).get(sys.argv[2], "") or "")
|
||||
except Exception:
|
||||
print("")
|
||||
PY
|
||||
}
|
||||
|
||||
if [ -z "${SANAD_LANGUAGE:-}" ]; then
|
||||
SANAD_LANGUAGE="$(python3 -c 'from sanad_pkg import license as L; print(L.feature("language","") or "")' 2>/dev/null || true)"
|
||||
[ -z "$SANAD_LANGUAGE" ] && SANAD_LANGUAGE="$(read_cfg language_default)"
|
||||
[ -z "$SANAD_LANGUAGE" ] && SANAD_LANGUAGE="ar"
|
||||
fi
|
||||
export SANAD_LANGUAGE
|
||||
export SANAD_VOICE_BRAIN="${SANAD_VOICE_BRAIN:-gemini}"
|
||||
[ -z "${SANAD_AUDIO_PROFILE:-}" ] && SANAD_AUDIO_PROFILE="$(read_cfg audio_profile_default)"
|
||||
export SANAD_AUDIO_PROFILE="${SANAD_AUDIO_PROFILE:-builtin}"
|
||||
export SANAD_DASHBOARD_HOST="${SANAD_DASHBOARD_HOST:-0.0.0.0}"
|
||||
[ -z "${SANAD_DASHBOARD_PORT:-}" ] && SANAD_DASHBOARD_PORT="$(read_cfg port)"
|
||||
export SANAD_DASHBOARD_PORT="${SANAD_DASHBOARD_PORT:-8011}"
|
||||
export PYTHONUNBUFFERED=1
|
||||
|
||||
# Jetson + Unitree SDK OpenMP load-order fix (only if the lib exists; override-able).
|
||||
if [ -z "${LD_PRELOAD:-}" ] && [ -f /usr/lib/aarch64-linux-gnu/libgomp.so.1 ]; then
|
||||
export LD_PRELOAD=/usr/lib/aarch64-linux-gnu/libgomp.so.1
|
||||
fi
|
||||
|
||||
echo "[$PKG] entitled — lang=$SANAD_LANGUAGE audio=$SANAD_AUDIO_PROFILE port=$SANAD_DASHBOARD_PORT brain=$SANAD_VOICE_BRAIN"
|
||||
|
||||
# ── 3. first-run preflight (clear, obvious diagnostics) ──────────────────────
|
||||
python3 - "$SANAD_AUDIO_PROFILE" <<'PY' || true
|
||||
import importlib.util as u, sys
|
||||
profile = sys.argv[1] if len(sys.argv) > 1 else "builtin"
|
||||
def has(m): return u.find_spec(m) is not None
|
||||
print("[P1] preflight:")
|
||||
ok = sys.version_info >= (3, 9)
|
||||
print(" python : %s %s" % (".".join(map(str, sys.version_info[:3])),
|
||||
"OK" if ok else "TOO OLD — google-genai needs >=3.9; rebuild from python:3.10 base"))
|
||||
print(" google-genai : %s" % ("OK" if has("google.genai")
|
||||
else "MISSING — live Gemini conversation will NOT work (check the build)"))
|
||||
print(" pyaudio : %s" % ("OK" if has("pyaudio") else "missing — mic/speaker capture limited"))
|
||||
sdk = has("unitree_sdk2py")
|
||||
print(" unitree SDK : %s" % ("OK" if sdk else "absent"))
|
||||
if profile == "builtin" and not sdk:
|
||||
print(" >> NOTE: audio profile 'builtin' (G1 chest) needs the Unitree SDK, which")
|
||||
print(" is absent. Plug in a USB speaker/mic and set SANAD_AUDIO_PROFILE=plugged,")
|
||||
print(" or mount a prebuilt unitree_sdk2_python. (plugged works with no SDK.)")
|
||||
PY
|
||||
|
||||
exec python3 /app/app_p1.py
|
||||
66
p1ctl.sh
Executable file
66
p1ctl.sh
Executable file
@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# p1ctl.sh — run / stop Sanad Package 1 (Basic Communication) on the robot
|
||||
# in the existing gemini_sdk conda env (dev mode; no Docker required).
|
||||
#
|
||||
# ./p1ctl.sh start # launch P1 dashboard on :8011 (coexists with Sanad :8000)
|
||||
# ./p1ctl.sh stop # stop P1
|
||||
# ./p1ctl.sh restart
|
||||
# ./p1ctl.sh status # process + /api/health
|
||||
# ./p1ctl.sh logs [N] # tail N lines of the P1 log
|
||||
#
|
||||
# Overridable env: SANAD_DEPLOY_ROOT (default ~/sanad_deploy), SANAD_P1_PY,
|
||||
# SANAD_DASHBOARD_PORT (8011), SANAD_AUDIO_PROFILE (builtin), SANAD_DDS_INTERFACE (eth0).
|
||||
set -u
|
||||
|
||||
ROOT="${SANAD_DEPLOY_ROOT:-$HOME/sanad_deploy}"
|
||||
PY="${SANAD_P1_PY:-$HOME/miniconda3/envs/gemini_sdk/bin/python}"
|
||||
PORT="${SANAD_DASHBOARD_PORT:-8011}"
|
||||
APP="$ROOT/Packages/Sanad_Package_1/app_p1.py"
|
||||
LOG="$ROOT/p1.log"
|
||||
|
||||
_start() {
|
||||
if pgrep -f app_p1.py >/dev/null 2>&1; then
|
||||
echo "P1 already running on :$PORT"; return 0
|
||||
fi
|
||||
[ -f "$APP" ] || { echo "ERROR: $APP not found (deploy first)"; return 1; }
|
||||
cd "$ROOT"
|
||||
export SANAD_APP_DIR="$ROOT" \
|
||||
SANAD_LICENSE="$ROOT/Packages/licensing/sanad.lic.example" \
|
||||
SANAD_PUBKEY="$ROOT/Packages/sanad_pkg/pubkey.ed25519" \
|
||||
SANAD_P1_STATIC="$ROOT/Packages/Sanad_Package_1/static" \
|
||||
PYTHONPATH="$ROOT:$ROOT/Packages" \
|
||||
SANAD_DASHBOARD_PORT="$PORT" SANAD_DASHBOARD_HOST="0.0.0.0" \
|
||||
SANAD_VOICE_BRAIN="gemini" \
|
||||
SANAD_AUDIO_PROFILE="${SANAD_AUDIO_PROFILE:-builtin}" \
|
||||
SANAD_DDS_INTERFACE="${SANAD_DDS_INTERFACE:-eth0}" \
|
||||
PYTHONUNBUFFERED=1
|
||||
[ -f /usr/lib/aarch64-linux-gnu/libgomp.so.1 ] && export LD_PRELOAD=/usr/lib/aarch64-linux-gnu/libgomp.so.1
|
||||
nohup "$PY" "$APP" > "$LOG" 2>&1 &
|
||||
sleep 3
|
||||
if pgrep -f app_p1.py >/dev/null 2>&1; then
|
||||
echo "P1 started -> http://$(hostname -I | awk '{print $1}'):$PORT (log: $LOG)"
|
||||
else
|
||||
echo "P1 failed to start. Last log lines:"; tail -20 "$LOG"
|
||||
fi
|
||||
}
|
||||
|
||||
_stop() {
|
||||
pgrep -f app_p1.py >/dev/null 2>&1 || { echo "P1 was not running."; return 0; }
|
||||
pkill -f app_p1.py 2>/dev/null # SIGTERM (graceful)
|
||||
# uvicorn waits for open websockets (e.g. a browser on /ws/logs) — force after 8s
|
||||
for _ in $(seq 1 8); do pgrep -f app_p1.py >/dev/null 2>&1 || break; sleep 1; done
|
||||
pgrep -f app_p1.py >/dev/null 2>&1 && pkill -9 -f app_p1.py 2>/dev/null
|
||||
sleep 1
|
||||
pgrep -f app_p1.py >/dev/null 2>&1 && echo "P1 still running (could not kill)." || echo "P1 stopped."
|
||||
}
|
||||
_status() { if pgrep -af app_p1.py; then echo -n "health: "; curl -s --max-time 4 "http://127.0.0.1:$PORT/api/health"; echo; else echo "P1 not running."; fi; }
|
||||
_logs() { tail -n "${1:-40}" "$LOG" 2>/dev/null || echo "no log at $LOG"; }
|
||||
|
||||
case "${1:-}" in
|
||||
start) _start ;;
|
||||
stop) _stop ;;
|
||||
restart) _stop; sleep 2; _start ;;
|
||||
status) _status ;;
|
||||
logs) shift; _logs "${1:-40}" ;;
|
||||
*) echo "usage: $0 {start|stop|restart|status|logs [N]}"; exit 2 ;;
|
||||
esac
|
||||
10
requirements-p1.txt
Normal file
10
requirements-p1.txt
Normal file
@ -0,0 +1,10 @@
|
||||
# Sanad Package 1 (Basic Communication) extra deps, on top of sanad-base.
|
||||
# Comms-minimal subset of Sanad/requirements.txt. sanad-base is python:3.10-slim,
|
||||
# so google-genai installs cleanly (no version gymnastics).
|
||||
google-genai>=1.0.0
|
||||
pyaudio
|
||||
soundfile
|
||||
requests
|
||||
# unitree_sdk2py is NOT on PyPI — needed only for the 'builtin' (chest)
|
||||
# audio profile over DDS. Provide it via the vendored unitree_sdk2_python
|
||||
# (COPY/volume) or run P1 with SANAD_AUDIO_PROFILE=plugged (PulseAudio).
|
||||
243
routes_p1.py
Normal file
243
routes_p1.py
Normal file
@ -0,0 +1,243 @@
|
||||
"""P1-specific dashboard routes (mounted at /api/p1 by app_p1.py).
|
||||
|
||||
First-class P1 settings that REUSE Sanad's canonical logic (no fork) and add the
|
||||
one thing the base routes don't: applying a change to the LIVE Gemini session
|
||||
immediately by restarting the voice subprocess (the child reads the API key +
|
||||
persona at spawn time).
|
||||
|
||||
/api/p1/api-key GET masked status | POST set/update + live-restart
|
||||
/api/p1/persona GET current persona+rules | POST update persona + live-restart
|
||||
/api/p1/settings one-shot view (api-key + persona + language + audio + live)
|
||||
|
||||
Kept Python-3.8 compatible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from Project.Sanad.core.logger import get_logger
|
||||
from Project.Sanad.dashboard.routes import voice as _voice # reuse api-key logic
|
||||
from Project.Sanad.dashboard.routes import prompt as _prompt # reuse persona logic
|
||||
from Project.Sanad.dashboard.routes import typed_replay as _tr # reuse local TTS say
|
||||
from sanad_pkg.bus import bus
|
||||
|
||||
# Bind request models as module-level names so FastAPI resolves body annotations
|
||||
# cleanly under `from __future__ import annotations` (dotted forward-refs are
|
||||
# version-fragile).
|
||||
ApiKeyPayload = _voice.ApiKeyPayload
|
||||
PromptUpdate = _prompt.PromptUpdate
|
||||
SayPayload = _tr.SayPayload
|
||||
|
||||
log = get_logger("pkg1.routes")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _restart_live_if_running() -> bool:
|
||||
"""Restart the live Gemini subprocess (if running) so a new key/persona
|
||||
takes effect immediately. Returns True if it was restarted."""
|
||||
try:
|
||||
from Project.Sanad.main import live_sub
|
||||
is_running = getattr(live_sub, "is_running", None)
|
||||
if live_sub is None or not callable(is_running) or not is_running():
|
||||
return False
|
||||
try:
|
||||
live_sub.stop()
|
||||
except Exception:
|
||||
log.exception("live_sub.stop() failed")
|
||||
try:
|
||||
await asyncio.to_thread(live_sub.start)
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("live_sub.start() failed")
|
||||
return False
|
||||
except Exception:
|
||||
log.exception("could not restart live subprocess")
|
||||
return False
|
||||
|
||||
|
||||
# ─────────────────────────── Gemini API key ───────────────────────────
|
||||
def _persist_and_hotswap_key(key: str) -> None:
|
||||
"""Persist gemini.api_key to data/motions/config.json (empty string => remove)
|
||||
and hot-swap the in-memory globals so it takes effect without a restart.
|
||||
Patches BOTH Project.Sanad.config and gemini.client (each binds its own ref)."""
|
||||
from Project.Sanad.config import load_config, save_config
|
||||
import Project.Sanad.config as _cfg_mod
|
||||
cfg = load_config() or {}
|
||||
g = cfg.get("gemini") if isinstance(cfg.get("gemini"), dict) else {}
|
||||
if key:
|
||||
g["api_key"] = key
|
||||
else:
|
||||
g.pop("api_key", None)
|
||||
cfg["gemini"] = g
|
||||
save_config(cfg)
|
||||
_cfg_mod.GEMINI_API_KEY = key
|
||||
try:
|
||||
import Project.Sanad.gemini.client as _gc
|
||||
_gc.GEMINI_API_KEY = key
|
||||
except Exception:
|
||||
log.exception("could not patch gemini.client.GEMINI_API_KEY")
|
||||
|
||||
|
||||
async def _disconnect_voice():
|
||||
try:
|
||||
from Project.Sanad.main import voice_client
|
||||
if voice_client is not None and getattr(voice_client, "connected", False):
|
||||
await voice_client.disconnect()
|
||||
except Exception:
|
||||
log.exception("voice_client disconnect failed")
|
||||
|
||||
|
||||
@router.get("/api-key")
|
||||
async def p1_get_api_key():
|
||||
"""Masked current key + where it came from (delegates to the voice route)."""
|
||||
return await _voice.get_api_key()
|
||||
|
||||
|
||||
@router.post("/api-key")
|
||||
async def p1_set_api_key(payload: ApiKeyPayload):
|
||||
"""ADD / update the Gemini API key. Relaxed validation — accepts any
|
||||
reasonable key (AIza… standard keys AND AQ.… / ephemeral tokens), not just
|
||||
AIza. Persists + hot-swaps + restarts the live session so it applies now."""
|
||||
key = (payload.api_key or "").strip()
|
||||
if len(key) < 10:
|
||||
raise HTTPException(400, "API key looks too short (paste the full key).")
|
||||
_persist_and_hotswap_key(key)
|
||||
await _disconnect_voice()
|
||||
restarted = await _restart_live_if_running()
|
||||
return {
|
||||
"ok": True,
|
||||
"masked": _voice._mask_api_key(key),
|
||||
"source": "config_file",
|
||||
"live_subprocess_restarted": restarted,
|
||||
"message": "API key added" + (" and applied (live session restarted)."
|
||||
if restarted else " — start the session to use it."),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api-key/delete")
|
||||
async def p1_delete_api_key():
|
||||
"""DELETE the Gemini API key — clears it from data/motions/config.json and
|
||||
in-memory. Conversation stops until a new key is added. (If config.py has a
|
||||
hardcoded fallback, that re-applies on the next process restart.)"""
|
||||
_persist_and_hotswap_key("")
|
||||
await _disconnect_voice()
|
||||
restarted = await _restart_live_if_running()
|
||||
return {
|
||||
"ok": True,
|
||||
"deleted": True,
|
||||
"live_subprocess_restarted": restarted,
|
||||
"message": "API key deleted. Add a new key to re-enable conversation.",
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────── Robot persona ───────────────────────────
|
||||
@router.get("/persona")
|
||||
async def p1_get_persona():
|
||||
"""Current persona system prompt + parsed rules + file paths."""
|
||||
return await _prompt.get_prompt()
|
||||
|
||||
|
||||
@router.post("/persona")
|
||||
async def p1_set_persona(payload: PromptUpdate):
|
||||
"""Change the robot persona — write scripts/sanad_script.txt (canonical
|
||||
prompt logic) and restart the live session so it speaks with the new
|
||||
persona immediately. The persona is also where you steer language/dialect."""
|
||||
result = await _prompt.update_prompt(payload) # atomic write to sanad_script.txt
|
||||
restarted = await _restart_live_if_running()
|
||||
result["live_subprocess_restarted"] = restarted
|
||||
result["message"] = (
|
||||
"Persona saved and applied — live session restarted."
|
||||
if restarted else
|
||||
"Persona saved. Start (or restart) the live session to use the new persona."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ─────────────────────────── say a line ───────────────────────────
|
||||
@router.post("/say")
|
||||
async def p1_say(payload: SayPayload):
|
||||
"""Speak a typed line. Standalone (no bus) → play locally via Sanad's
|
||||
typed-replay. Multi-package (SANAD_BUS_ADDR set) → synth via Gemini and hand
|
||||
the PCM to the hwbroker `speak.request`, so it plays under the audio-lock
|
||||
(refused/queued while the live conversation is speaking)."""
|
||||
text = (payload.text or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "text cannot be empty")
|
||||
|
||||
if not os.environ.get("SANAD_BUS_ADDR"):
|
||||
# standalone — no contention, play directly (reuse canonical typed-replay)
|
||||
return await _tr.say(payload)
|
||||
|
||||
# multi-package — route audio output through the hwbroker audio-lock
|
||||
from Project.Sanad.main import voice_client
|
||||
if voice_client is None:
|
||||
raise HTTPException(503, "voice client unavailable")
|
||||
if not getattr(voice_client, "connected", False):
|
||||
try:
|
||||
await voice_client.connect()
|
||||
except Exception as exc:
|
||||
raise HTTPException(503, "Gemini connect failed: %s" % exc)
|
||||
try:
|
||||
audio, _parts = await voice_client.send_text(text, owner="p1_say")
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, "Gemini error: %s" % exc)
|
||||
if not audio:
|
||||
return {"ok": False, "routed": "hwbroker", "reason": "no audio produced"}
|
||||
bus.emit_sync("speak.request", owner="p1",
|
||||
pcm_b64=base64.b64encode(audio).decode("ascii"),
|
||||
rate=24000, channels=1, sampwidth=2)
|
||||
return {"ok": True, "routed": "hwbroker (audio-lock)"}
|
||||
|
||||
|
||||
# ─────────────────────────── logs ───────────────────────────
|
||||
@router.post("/logs/delete")
|
||||
async def p1_delete_logs():
|
||||
"""Delete all log files on the robot. Active .log files are truncated (so the
|
||||
live logger keeps a valid handle); rotated/snapshot/bundle files are removed."""
|
||||
from Project.Sanad.config import LOGS_DIR
|
||||
cleared = []
|
||||
try:
|
||||
for p in sorted(LOGS_DIR.glob("*.log*")):
|
||||
try:
|
||||
if p.name.endswith(".log") and "_snapshot_" not in p.name:
|
||||
open(p, "w").close() # truncate active log
|
||||
else:
|
||||
p.unlink() # remove rotated/snapshot/bundle
|
||||
cleared.append(p.name)
|
||||
except Exception:
|
||||
log.exception("could not clear log %s", p.name)
|
||||
except Exception:
|
||||
log.exception("delete logs failed")
|
||||
return {"ok": True, "count": len(cleared), "cleared": cleared}
|
||||
|
||||
|
||||
# ─────────────────────────── combined view ───────────────────────────
|
||||
@router.get("/settings")
|
||||
async def p1_settings():
|
||||
"""One-shot P1 settings: api-key status + persona + language + audio + live."""
|
||||
key_status = await _voice.get_api_key()
|
||||
persona = ""
|
||||
try:
|
||||
persona = _prompt._load_system_prompt()
|
||||
except Exception:
|
||||
log.exception("could not load persona")
|
||||
live_running = False
|
||||
try:
|
||||
from Project.Sanad.main import live_sub
|
||||
is_running = getattr(live_sub, "is_running", None)
|
||||
live_running = bool(live_sub is not None and callable(is_running) and is_running())
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"package": "P1",
|
||||
"api_key": key_status,
|
||||
"persona_preview": (persona[:400] + ("…" if len(persona) > 400 else "")),
|
||||
"persona_chars": len(persona),
|
||||
"language": os.environ.get("SANAD_LANGUAGE", ""),
|
||||
"audio_profile": os.environ.get("SANAD_AUDIO_PROFILE", "builtin"),
|
||||
"live_running": live_running,
|
||||
}
|
||||
302
static/p1.html
Normal file
302
static/p1.html
Normal file
@ -0,0 +1,302 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
|
||||
<title>Sanad — Basic Communication · YS Lootah Tech</title>
|
||||
<style>
|
||||
:root{--bg:#0e1726;--card:#16223a;--line:#27365a;--ink:#e9f0fb;--mut:#90a2c4;
|
||||
--accent:#2f7ad6;--ok:#1f9d57;--warn:#e0a800;--bad:#d6455d;--radius:12px}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0}
|
||||
body{background:var(--bg);color:var(--ink);
|
||||
font-family:-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;
|
||||
font-size:14px;line-height:1.5;-webkit-text-size-adjust:100%}
|
||||
header{position:sticky;top:0;background:#0b1320;border-bottom:1px solid var(--line);
|
||||
padding:10px 18px;display:flex;align-items:center;gap:10px 13px;flex-wrap:wrap;z-index:5}
|
||||
.brand{display:flex;align-items:center;gap:10px;min-width:0}
|
||||
.brand .t{display:flex;flex-direction:column;line-height:1.15;min-width:0}
|
||||
.brand h1{font-size:15.5px;margin:0;color:#cfe0ff;white-space:nowrap}
|
||||
.brand .by{font-size:10.5px;color:var(--mut)}
|
||||
.pills{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.pill{font-size:11px;padding:3px 9px;border-radius:20px;border:1px solid var(--line);color:var(--mut);white-space:nowrap}
|
||||
.pill.on{background:rgba(31,157,87,.15);color:#7fe0a6;border-color:#1f9d57}
|
||||
.pill.off{background:rgba(214,69,93,.12);color:#f2a3ae;border-color:#d6455d}
|
||||
.pill.warn{background:rgba(224,168,0,.12);color:#ffd766;border-color:#e0a800}
|
||||
.spacer{flex:1}
|
||||
main{max-width:1100px;margin:0 auto;padding:16px 18px 8px;display:flex;flex-direction:column;gap:6px}
|
||||
.section{font-size:11px;text-transform:uppercase;letter-spacing:1.4px;color:var(--mut);
|
||||
font-weight:700;margin:14px 4px 4px;border-bottom:1px solid var(--line);padding-bottom:5px}
|
||||
.section:first-of-type{margin-top:2px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px;align-items:start}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}
|
||||
.card h2{margin:0 0 10px;font-size:14px;color:#bcd2ff;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.card h2 .sub{font-size:11px;color:var(--mut);font-weight:400}
|
||||
label{display:block;font-size:12px;color:var(--mut);margin:10px 0 4px}
|
||||
textarea,select,input[type=text],input[type=password]{
|
||||
width:100%;background:#0e1830;border:1px solid var(--line);color:var(--ink);
|
||||
border-radius:8px;padding:10px;font:inherit}
|
||||
textarea{min-height:118px;resize:vertical;font-family:'SFMono-Regular',Consolas,monospace;font-size:12.5px}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
button{background:var(--accent);color:#fff;border:0;border-radius:8px;padding:10px 14px;
|
||||
font:inherit;cursor:pointer;min-height:40px}
|
||||
button.sec{background:#26395f}button.ok{background:var(--ok)}button.bad{background:var(--bad)}
|
||||
button.sm{min-height:30px;padding:6px 12px}
|
||||
input[type=range]{flex:1;min-width:120px}
|
||||
.msg{font-size:12px;margin-top:8px;min-height:16px;word-break:break-word}
|
||||
.msg.ok{color:#7fe0a6}.msg.err{color:#f2a3ae}.msg.info{color:var(--mut)}
|
||||
.vol{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.vol b{width:42px;text-align:right}
|
||||
#logbox{background:#0a1020;border:1px solid var(--line);border-radius:8px;height:200px;overflow:auto;
|
||||
padding:8px;font-family:monospace;font-size:11.5px;color:#bcd;word-break:break-all}
|
||||
.hint{font-size:11px;color:var(--mut);margin-top:6px}
|
||||
.wide{grid-column:1/-1}
|
||||
footer{max-width:1100px;margin:14px auto 30px;padding:0 18px;color:var(--mut);font-size:11.5px;
|
||||
display:flex;align-items:center;gap:9px;flex-wrap:wrap}
|
||||
@media (max-width:720px){ .grid{grid-template-columns:1fr} .wide{grid-column:auto}
|
||||
.brand h1{font-size:14px;white-space:normal} header{padding:9px 13px} main{padding:13px} }
|
||||
@media (max-width:430px){ main{padding:11px} .card{padding:13px}
|
||||
.row button{flex:1 1 auto} button{padding:12px 14px;min-height:44px} textarea{min-height:104px} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 120 120" width="32" height="32" aria-label="YS Lootah Tech" role="img" style="flex:none">
|
||||
<rect x="3" y="3" width="114" height="114" rx="24" fill="#0a1a3f" stroke="#3a5da8" stroke-width="2.5"/>
|
||||
<text x="60" y="76" font-family="Arial,Helvetica,sans-serif" font-size="52" font-weight="800"
|
||||
fill="#d7e6ff" text-anchor="middle" letter-spacing="-3">YS</text>
|
||||
</svg>
|
||||
<div class="t">
|
||||
<h1>Sanad — Basic Communication</h1>
|
||||
<span class="by">powered by YS Lootah Tech</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<div class="pills">
|
||||
<span class="pill" id="pill-license">license …</span>
|
||||
<span class="pill" id="pill-live">session …</span>
|
||||
<span class="pill" id="pill-lang">lang …</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ───────── TALK ───────── -->
|
||||
<div class="section">Talk</div>
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<h2>🎙️ Conversation <span class="sub">live voice</span></h2>
|
||||
<div class="row">
|
||||
<button id="btn-start" class="ok">Start talking</button>
|
||||
<button id="btn-stop" class="bad sec">Stop</button>
|
||||
<button id="btn-refresh-live" class="sec">Refresh</button>
|
||||
</div>
|
||||
<div class="msg info" id="msg-live">Start the live session, then just speak to the robot.</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>💬 Say a line <span class="sub">type → robot speaks</span></h2>
|
||||
<textarea id="say-text" placeholder="اكتب جملة ليقولها سند… / Type a sentence for Sanad to say"></textarea>
|
||||
<div class="row" style="margin-top:8px"><button id="btn-say">Speak it</button></div>
|
||||
<div class="msg info" id="msg-say"></div>
|
||||
</section>
|
||||
|
||||
<section class="card wide">
|
||||
<h2>🪪 Robot persona <span class="sub">who Sanad is, tone & language/dialect — applied live</span></h2>
|
||||
<textarea id="persona-text" placeholder="Loading persona…"></textarea>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button id="btn-persona-save" class="ok">Save & Apply</button>
|
||||
<button id="btn-persona-reload" class="sec">Reload</button>
|
||||
<span class="hint">Put the language directive here (e.g. “Speak Khaleeji Arabic”). Saving restarts the live session so it applies immediately.</span>
|
||||
</div>
|
||||
<div class="msg info" id="msg-persona"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ───────── SETTINGS ───────── -->
|
||||
<div class="section">Settings</div>
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<h2>🔑 Gemini API key <span class="sub">add / delete</span></h2>
|
||||
<div class="msg info" id="key-status">…</div>
|
||||
<label for="key-input">Add / update key</label>
|
||||
<input type="password" id="key-input" placeholder="paste your Gemini key…" autocomplete="off"/>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button id="btn-key-save" class="ok">Add key</button>
|
||||
<button id="btn-key-show" class="sec" type="button">Show</button>
|
||||
<button id="btn-key-delete" class="bad">Delete key</button>
|
||||
</div>
|
||||
<div class="msg info" id="msg-key"></div>
|
||||
<div class="hint">Accepts any Gemini key (AIza… or AQ.… tokens). Stored masked; deleting stops conversation until a new key is added.</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>🔊 Audio <span class="sub">speaker / mic + volume</span></h2>
|
||||
<label for="profile-sel">Speaker & mic (profile)</label>
|
||||
<select id="profile-sel"><option>loading…</option></select>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button id="btn-profile-apply">Use this</button>
|
||||
<button id="btn-audio-refresh" class="sec">Rescan devices</button>
|
||||
</div>
|
||||
<label style="margin-top:12px">Robot (chest) volume</label>
|
||||
<div class="vol">
|
||||
<input type="range" id="vol" min="0" max="100" value="80"/>
|
||||
<b id="vol-val">80</b>
|
||||
<button id="btn-mute" class="sec" title="Mute / unmute chest speaker">Mute</button>
|
||||
</div>
|
||||
<div class="msg info" id="msg-audio"></div>
|
||||
<div class="hint">Plug an Anker/USB device → Rescan → pick it in the profile list. Chest volume needs the builtin (DDS) profile.</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ───────── DIAGNOSTICS ───────── -->
|
||||
<div class="section">Diagnostics</div>
|
||||
<div class="grid">
|
||||
<section class="card wide">
|
||||
<h2>📜 Logs <span class="sub">live</span>
|
||||
<span style="flex:1"></span>
|
||||
<button id="btn-logs-dl" class="sec sm">⬇ Download</button>
|
||||
<button id="btn-logs-del" class="bad sm">Delete logs</button>
|
||||
</h2>
|
||||
<div id="logbox">connecting…</div>
|
||||
<div class="msg info" id="msg-logs"></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<svg viewBox="0 0 120 120" width="18" height="18" role="img" aria-label="YS" style="flex:none">
|
||||
<rect x="3" y="3" width="114" height="114" rx="24" fill="#0a1a3f" stroke="#3a5da8" stroke-width="3"/>
|
||||
<text x="60" y="78" font-family="Arial" font-size="54" font-weight="800" fill="#d7e6ff" text-anchor="middle" letter-spacing="-3">YS</text>
|
||||
</svg>
|
||||
<span>Sanad — powered by <strong>YS Lootah Tech</strong></span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
async function api(method, path, body){
|
||||
const opt = {method, headers:{}};
|
||||
if(body!==undefined){opt.headers["Content-Type"]="application/json";opt.body=JSON.stringify(body);}
|
||||
const r = await fetch(path, opt);
|
||||
let data=null; try{data=await r.json();}catch(e){}
|
||||
if(!r.ok){ const m=(data&&(data.detail||data.error||data.message))||(r.status+" "+r.statusText);
|
||||
throw new Error(typeof m==="string"?m:JSON.stringify(m)); }
|
||||
return data||{};
|
||||
}
|
||||
function setMsg(id,text,kind){const el=$(id);el.textContent=text||"";el.className="msg "+(kind||"info");}
|
||||
function pill(id,text,cls){const el=$(id);el.textContent=text;el.className="pill "+(cls||"");}
|
||||
|
||||
async function loadStatus(){
|
||||
try{ const p = await api("GET","/api/package");
|
||||
pill("pill-license", p.license&&p.license.valid?"licensed":"unlicensed", p.license&&p.license.valid?"on":"off");
|
||||
pill("pill-lang", "lang: "+(p.language||"?"));
|
||||
}catch(e){ pill("pill-license","status error","off"); }
|
||||
refreshLive();
|
||||
}
|
||||
async function refreshLive(){
|
||||
try{ const s = await api("GET","/api/live-subprocess/status");
|
||||
const running = s.state==="running"||s.running===true;
|
||||
pill("pill-live", running?"session: live":"session: stopped", running?"on":"off");
|
||||
}catch(e){ pill("pill-live","session: n/a","warn"); }
|
||||
}
|
||||
|
||||
// conversation
|
||||
$("btn-start").onclick = async()=>{ setMsg("msg-live","starting…");
|
||||
try{ await api("POST","/api/live-subprocess/start"); setMsg("msg-live","Live session started — speak now.","ok"); }
|
||||
catch(e){ setMsg("msg-live","Start failed: "+e.message,"err"); } refreshLive(); };
|
||||
$("btn-stop").onclick = async()=>{ setMsg("msg-live","stopping…");
|
||||
try{ await api("POST","/api/live-subprocess/stop"); setMsg("msg-live","Stopped.","ok"); }
|
||||
catch(e){ setMsg("msg-live","Stop failed: "+e.message,"err"); } refreshLive(); };
|
||||
$("btn-refresh-live").onclick = refreshLive;
|
||||
|
||||
// say
|
||||
$("btn-say").onclick = async()=>{ const t=$("say-text").value.trim();
|
||||
if(!t){setMsg("msg-say","Type something first.","err");return;}
|
||||
setMsg("msg-say","speaking…");
|
||||
try{ await api("POST","/api/p1/say",{text:t}); setMsg("msg-say","Done.","ok"); }
|
||||
catch(e){ setMsg("msg-say","Failed: "+e.message,"err"); } };
|
||||
|
||||
// persona
|
||||
async function loadPersona(){ try{ const p=await api("GET","/api/p1/persona");
|
||||
$("persona-text").value = p.system_prompt||""; setMsg("msg-persona","Loaded.","info"); }
|
||||
catch(e){ setMsg("msg-persona","Load failed: "+e.message,"err"); } }
|
||||
$("btn-persona-save").onclick = async()=>{ setMsg("msg-persona","saving…");
|
||||
try{ const r=await api("POST","/api/p1/persona",{content:$("persona-text").value});
|
||||
setMsg("msg-persona",(r.message||"Saved."),"ok"); refreshLive(); }
|
||||
catch(e){ setMsg("msg-persona","Save failed: "+e.message,"err"); } };
|
||||
$("btn-persona-reload").onclick = loadPersona;
|
||||
|
||||
// api key
|
||||
async function loadKey(){ try{ const k=await api("GET","/api/p1/api-key");
|
||||
$("key-status").textContent = k.has_key
|
||||
? ("Current: "+(k.masked||"set")+" (source: "+(k.source||"?")+")") : "No key set."; }
|
||||
catch(e){ $("key-status").textContent="status error"; } }
|
||||
$("btn-key-show").onclick = ()=>{ const i=$("key-input"); i.type=i.type==="password"?"text":"password"; };
|
||||
$("btn-key-save").onclick = async()=>{ const k=$("key-input").value.trim();
|
||||
if(!k){ setMsg("msg-key","Paste a key first.","err"); return; }
|
||||
setMsg("msg-key","saving…");
|
||||
try{ const r=await api("POST","/api/p1/api-key",{api_key:k});
|
||||
setMsg("msg-key",(r.message||"Added."),"ok"); $("key-input").value=""; loadKey(); refreshLive(); }
|
||||
catch(e){ setMsg("msg-key","Failed: "+e.message,"err"); } };
|
||||
$("btn-key-delete").onclick = async()=>{
|
||||
if(!confirm("Delete the Gemini API key? Conversation will stop until you add a new one.")) return;
|
||||
setMsg("msg-key","deleting…");
|
||||
try{ const r=await api("POST","/api/p1/api-key/delete");
|
||||
setMsg("msg-key",(r.message||"Deleted."),"ok"); loadKey(); refreshLive(); }
|
||||
catch(e){ setMsg("msg-key","Failed: "+e.message,"err"); } };
|
||||
|
||||
// audio
|
||||
async function loadProfiles(){ try{ const d=await api("GET","/api/audio/profiles");
|
||||
const sel=$("profile-sel"); sel.innerHTML="";
|
||||
(d.profiles||[]).forEach(p=>{ const o=document.createElement("option");
|
||||
o.value=p.id; o.textContent=(p.label||p.name||p.id)+(p.available?" ✓ plugged":""); sel.appendChild(o); });
|
||||
if(!sel.options.length){ sel.innerHTML="<option>no profiles</option>"; }
|
||||
}catch(e){ setMsg("msg-audio","Profiles: "+e.message,"err"); } }
|
||||
$("btn-profile-apply").onclick = async()=>{ const id=$("profile-sel").value; setMsg("msg-audio","switching…");
|
||||
try{ await api("POST","/api/audio/select-profile",{profile_id:id}); setMsg("msg-audio","Switched to "+id+".","ok"); loadVol(); }
|
||||
catch(e){ setMsg("msg-audio","Switch failed: "+e.message,"err"); } };
|
||||
$("btn-audio-refresh").onclick = async()=>{ setMsg("msg-audio","rescanning…");
|
||||
try{ await api("POST","/api/audio/refresh"); await loadProfiles(); setMsg("msg-audio","Devices rescanned.","ok"); }
|
||||
catch(e){ setMsg("msg-audio","Rescan failed: "+e.message,"err"); } };
|
||||
async function loadVol(){ try{ const v=await api("GET","/api/audio/g1-speaker/volume");
|
||||
if(typeof v.current_volume==="number"){ $("vol").value=v.current_volume; $("vol-val").textContent=v.current_volume; }
|
||||
if(v.available===false){ setMsg("msg-audio","Chest volume unavailable (plugged profile / no SDK) — use OS volume for USB.","info"); }
|
||||
}catch(e){} }
|
||||
let volTimer=null;
|
||||
$("vol").oninput = ()=>{ $("vol-val").textContent=$("vol").value;
|
||||
clearTimeout(volTimer); volTimer=setTimeout(async()=>{
|
||||
try{ await api("POST","/api/audio/g1-speaker/volume",{level:parseInt($("vol").value,10)}); }
|
||||
catch(e){ setMsg("msg-audio","Volume: "+e.message,"err"); } },300); };
|
||||
$("btn-mute").onclick = async()=>{ try{ await api("POST","/api/audio/g1-speaker/mute"); loadVol(); setMsg("msg-audio","Toggled mute.","ok"); }
|
||||
catch(e){ setMsg("msg-audio","Mute: "+e.message,"err"); } };
|
||||
|
||||
// logs
|
||||
function connectLogs(){ try{
|
||||
const proto = location.protocol==="https:"?"wss:":"ws:";
|
||||
const ws = new WebSocket(proto+"//"+location.host+"/ws/logs");
|
||||
const box=$("logbox"); let first=true;
|
||||
ws.onopen=()=>{ box.textContent=""; };
|
||||
ws.onmessage=(ev)=>{ if(first){box.textContent="";first=false;}
|
||||
let line=ev.data; try{const j=JSON.parse(ev.data); line=j.line||j.message||ev.data;}catch(e){}
|
||||
box.textContent += line+"\n"; box.scrollTop=box.scrollHeight; };
|
||||
ws.onerror=()=>{ box.textContent="(logs stream unavailable)"; };
|
||||
ws.onclose=()=>{ setTimeout(connectLogs, 4000); };
|
||||
}catch(e){ $("logbox").textContent="(logs unavailable)"; } }
|
||||
$("btn-logs-dl").onclick = async()=>{ setMsg("msg-logs","preparing download…");
|
||||
try{ const r = await fetch("/api/logs/bundle?lines=2000"); if(!r.ok) throw new Error(r.status);
|
||||
const blob = await r.blob(); const ts = new Date().toISOString().replace(/[:.]/g,"-").slice(0,19);
|
||||
const a=document.createElement("a"); a.href=URL.createObjectURL(blob); a.download="sanad_p1_logs_"+ts+".txt";
|
||||
document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),5000);
|
||||
setMsg("msg-logs","Downloaded sanad_p1_logs_"+ts+".txt","ok");
|
||||
}catch(e){ setMsg("msg-logs","Download failed: "+e.message,"err"); } };
|
||||
$("btn-logs-del").onclick = async()=>{
|
||||
if(!confirm("Delete ALL log files on the robot?")) return;
|
||||
setMsg("msg-logs","deleting…");
|
||||
try{ const r=await api("POST","/api/p1/logs/delete"); $("logbox").textContent="";
|
||||
setMsg("msg-logs","Deleted "+(r.count||0)+" log file(s).","ok"); }
|
||||
catch(e){ setMsg("msg-logs","Delete failed: "+e.message,"err"); } };
|
||||
|
||||
loadStatus(); loadPersona(); loadKey(); loadProfiles(); loadVol(); connectLogs();
|
||||
setInterval(refreshLive, 6000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
86
static/p1_widget.html
Normal file
86
static/p1_widget.html
Normal file
@ -0,0 +1,86 @@
|
||||
<!-- P1 Quick Controls — injected into /full (the advanced Sanad SPA).
|
||||
Self-contained + namespaced (p1w-) so it never clashes with the SPA DOM.
|
||||
Reuses the same P1 endpoints as the clean control page. -->
|
||||
<style>
|
||||
#p1w-fab{position:fixed;right:18px;bottom:18px;z-index:99999;background:#2f7ad6;color:#fff;
|
||||
border:0;border-radius:30px;padding:11px 16px;font:600 13px -apple-system,Segoe UI,Roboto,Arial;
|
||||
box-shadow:0 4px 14px rgba(0,0,0,.4);cursor:pointer}
|
||||
#p1w-root{position:fixed;right:18px;bottom:70px;z-index:99999;width:340px;max-height:78vh;overflow:auto;
|
||||
background:#16223a;color:#e9f0fb;border:1px solid #2f4670;border-radius:12px;padding:14px;
|
||||
display:none;box-shadow:0 10px 30px rgba(0,0,0,.5);
|
||||
font:13px -apple-system,Segoe UI,Roboto,Arial}
|
||||
#p1w-root.open{display:block}
|
||||
#p1w-root h3{margin:2px 0 10px;font-size:14px;color:#bcd2ff}
|
||||
#p1w-root .c{border:1px solid #27365a;border-radius:9px;padding:10px;margin:8px 0;background:#111d33}
|
||||
#p1w-root .c h4{margin:0 0 7px;font-size:12px;color:#9cc1ff}
|
||||
#p1w-root label{display:block;font-size:11px;color:#90a2c4;margin:6px 0 3px}
|
||||
#p1w-root input,#p1w-root textarea,#p1w-root select{width:100%;background:#0e1830;border:1px solid #27365a;
|
||||
color:#e9f0fb;border-radius:7px;padding:7px;font:inherit}
|
||||
#p1w-root textarea{min-height:74px;resize:vertical;font-family:monospace;font-size:11.5px}
|
||||
#p1w-root button{background:#2f7ad6;color:#fff;border:0;border-radius:7px;padding:7px 11px;cursor:pointer;font:inherit}
|
||||
#p1w-root button.sec{background:#26395f}#p1w-root button.ok{background:#1f9d57}#p1w-root button.bad{background:#d6455d}
|
||||
#p1w-root .row{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:7px}
|
||||
#p1w-root .m{font-size:11px;color:#90a2c4;margin-top:5px;min-height:14px}
|
||||
#p1w-root .m.ok{color:#7fe0a6}#p1w-root .m.err{color:#f2a3ae}
|
||||
#p1w-root .vv{width:34px;text-align:right}
|
||||
</style>
|
||||
<button id="p1w-fab" type="button">⚙ P1 Controls</button>
|
||||
<div id="p1w-root">
|
||||
<h3>Sanad P1 — Quick Controls</h3>
|
||||
<div class="c"><h4>🎙️ Conversation</h4>
|
||||
<div class="row"><button id="p1w-start" class="ok">Start</button>
|
||||
<button id="p1w-stop" class="bad sec">Stop</button>
|
||||
<span class="m" id="p1w-m-live"></span></div></div>
|
||||
<div class="c"><h4>💬 Say a line</h4>
|
||||
<input id="p1w-say" type="text" placeholder="type a sentence…"/>
|
||||
<div class="row"><button id="p1w-say-btn">Speak</button><span class="m" id="p1w-m-say"></span></div></div>
|
||||
<div class="c"><h4>🪪 Persona (applied live)</h4>
|
||||
<textarea id="p1w-persona" placeholder="loading…"></textarea>
|
||||
<div class="row"><button id="p1w-persona-save" class="ok">Save & Apply</button>
|
||||
<span class="m" id="p1w-m-persona"></span></div></div>
|
||||
<div class="c"><h4>🔊 Audio</h4>
|
||||
<label>Speaker / mic profile</label><select id="p1w-prof"></select>
|
||||
<div class="row"><button id="p1w-prof-apply">Use</button>
|
||||
<button id="p1w-rescan" class="sec">Rescan</button></div>
|
||||
<label>Chest volume</label>
|
||||
<div class="row"><input id="p1w-vol" type="range" min="0" max="100" style="flex:1"/>
|
||||
<b class="vv" id="p1w-vol-v"></b></div>
|
||||
<div class="m" id="p1w-m-audio"></div></div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var $=function(id){return document.getElementById(id);};
|
||||
async function api(method,path,body){var o={method:method,headers:{}};
|
||||
if(body!==undefined){o.headers["Content-Type"]="application/json";o.body=JSON.stringify(body);}
|
||||
var r=await fetch(path,o);var d=null;try{d=await r.json();}catch(e){}
|
||||
if(!r.ok){var m=(d&&(d.detail||d.error||d.message))||(r.status);throw new Error(typeof m==="string"?m:JSON.stringify(m));}return d||{};}
|
||||
function msg(id,t,k){var e=$(id);if(e){e.textContent=t||"";e.className="m "+(k||"");}}
|
||||
var loaded=false;
|
||||
$("p1w-fab").onclick=function(){var r=$("p1w-root");r.classList.toggle("open");
|
||||
if(r.classList.contains("open")&&!loaded){loaded=true;init();}};
|
||||
async function init(){
|
||||
try{var pe=await api("GET","/api/p1/persona");$("p1w-persona").value=pe.system_prompt||"";}catch(e){}
|
||||
try{var pr=await api("GET","/api/audio/profiles");var s=$("p1w-prof");s.innerHTML="";
|
||||
(pr.profiles||[]).forEach(function(x){var o=document.createElement("option");o.value=x.id;
|
||||
o.textContent=(x.name||x.id)+(x.available?" ✓":"");s.appendChild(o);});}catch(e){}
|
||||
try{var v=await api("GET","/api/audio/g1-speaker/volume");if(typeof v.current_volume==="number"){
|
||||
$("p1w-vol").value=v.current_volume;$("p1w-vol-v").textContent=v.current_volume;}}catch(e){}
|
||||
refreshLive();
|
||||
}
|
||||
async function refreshLive(){try{var s=await api("GET","/api/live-subprocess/status");
|
||||
msg("p1w-m-live",(s.state==="running"||s.running)?"live":"stopped",(s.state==="running"||s.running)?"ok":"");}catch(e){}}
|
||||
$("p1w-start").onclick=async function(){try{await api("POST","/api/live-subprocess/start");}catch(e){msg("p1w-m-live",e.message,"err");}refreshLive();};
|
||||
$("p1w-stop").onclick=async function(){try{await api("POST","/api/live-subprocess/stop");}catch(e){msg("p1w-m-live",e.message,"err");}refreshLive();};
|
||||
$("p1w-say-btn").onclick=async function(){var t=$("p1w-say").value.trim();if(!t){return;}msg("p1w-m-say","…");
|
||||
try{await api("POST","/api/p1/say",{text:t});msg("p1w-m-say","done","ok");}catch(e){msg("p1w-m-say",e.message,"err");}};
|
||||
$("p1w-persona-save").onclick=async function(){msg("p1w-m-persona","saving…");
|
||||
try{var r=await api("POST","/api/p1/persona",{content:$("p1w-persona").value});msg("p1w-m-persona",r.message||"saved","ok");refreshLive();}
|
||||
catch(e){msg("p1w-m-persona",e.message,"err");}};
|
||||
$("p1w-prof-apply").onclick=async function(){msg("p1w-m-audio","…");
|
||||
try{await api("POST","/api/audio/select-profile",{profile_id:$("p1w-prof").value});msg("p1w-m-audio","switched","ok");}catch(e){msg("p1w-m-audio",e.message,"err");}};
|
||||
$("p1w-rescan").onclick=async function(){msg("p1w-m-audio","rescanning…");
|
||||
try{await api("POST","/api/audio/refresh");await init();msg("p1w-m-audio","rescanned","ok");}catch(e){msg("p1w-m-audio",e.message,"err");}};
|
||||
var vt=null;$("p1w-vol").oninput=function(){$("p1w-vol-v").textContent=$("p1w-vol").value;clearTimeout(vt);
|
||||
vt=setTimeout(async function(){try{await api("POST","/api/audio/g1-speaker/volume",{level:parseInt($("p1w-vol").value,10)});}catch(e){msg("p1w-m-audio",e.message,"err");}},300);};
|
||||
})();
|
||||
</script>
|
||||
24
strip_key.py
Normal file
24
strip_key.py
Normal file
@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build-time: blank any Gemini API key baked into the Sanad config so the P1
|
||||
image ships KEYLESS — the customer adds their own via the dashboard. Idempotent
|
||||
and best-effort (never fails the build)."""
|
||||
import json
|
||||
|
||||
BASE = "/app/Sanad"
|
||||
for rel, section in (("config/core_config.json", "gemini_defaults"),
|
||||
("data/motions/config.json", "gemini")):
|
||||
path = "%s/%s" % (BASE, rel)
|
||||
try:
|
||||
with open(path) as f:
|
||||
d = json.load(f)
|
||||
except Exception:
|
||||
continue
|
||||
sec = d.get(section)
|
||||
if isinstance(sec, dict) and sec.get("api_key"):
|
||||
sec["api_key"] = ""
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
json.dump(d, f, ensure_ascii=False, indent=2)
|
||||
print("strip_key: blanked %s.api_key in %s" % (section, rel))
|
||||
except Exception as exc:
|
||||
print("strip_key: could not write %s: %s" % (rel, exc))
|
||||
34
test_p1.sh
Normal file
34
test_p1.sh
Normal file
@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke-test Sanad Package 1. Usage: ./test_p1.sh [host:port] (default 127.0.0.1:8011)
|
||||
# Read-only by default (no key delete / no log delete / no speak).
|
||||
H="${1:-127.0.0.1:8011}"; B="http://$H"; pass=0; fail=0
|
||||
chk(){ # chk METHOD PATH EXPECT desc
|
||||
local code
|
||||
if [ "$1" = GET ]; then code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "$B$2")
|
||||
else code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 -X "$1" "$B$2"); fi
|
||||
if [ "$code" = "$3" ]; then printf " PASS [%s] %-34s %s\n" "$code" "$1 $2" "$4"; pass=$((pass+1))
|
||||
else printf " FAIL [%s≠%s] %-30s %s\n" "$code" "$3" "$1 $2" "$4"; fail=$((fail+1)); fi
|
||||
}
|
||||
echo "== Sanad P1 smoke test @ $B =="
|
||||
chk GET /api/health 200 "health"
|
||||
chk GET /api/package 200 "manifest + license + key status"
|
||||
chk GET /api/p1/api-key 200 "key status (masked)"
|
||||
chk GET /api/p1/persona 200 "persona"
|
||||
chk GET /api/p1/settings 200 "combined settings"
|
||||
chk GET /api/audio/profiles 200 "audio profiles"
|
||||
chk GET /api/audio/g1-speaker/volume 200 "chest volume"
|
||||
chk GET /api/live-subprocess/status 200 "conversation status"
|
||||
chk GET /api/system/info 200 "system info"
|
||||
chk GET /api/logs/ 200 "logs list"
|
||||
chk POST /api/audio/refresh 200 "rescan devices"
|
||||
echo "== $pass passed, $fail failed =="
|
||||
echo "-- manifest --"
|
||||
curl -s --max-time 6 "$B/api/package" | python3 -c '
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
print(" package :", d.get("package"))
|
||||
print(" license :", (d.get("license") or {}).get("valid"), " packages:", (d.get("license") or {}).get("packages"))
|
||||
print(" key :", "set" if (d.get("api_key") or {}).get("has_key") else "NONE (customer adds own)")
|
||||
print(" language:", d.get("language"), " audio:", d.get("audio_profile"))
|
||||
' 2>/dev/null || true
|
||||
echo " profiles:"; curl -s --max-time 6 "$B/api/audio/profiles" | python3 -c "import sys,json;print(' ',[p.get('id') for p in json.load(sys.stdin).get('profiles',[])])" 2>/dev/null || true
|
||||
Loading…
x
Reference in New Issue
Block a user