Update 2026-06-17 15:49:27

This commit is contained in:
kassam 2026-06-17 15:49:29 +04:00
commit aba3118dfc
14 changed files with 1421 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
__pycache__/
*.pyc
Logs/
*.log

39
Dockerfile Normal file
View 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
View 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://&lt;robot&gt;: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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 &amp; 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 &amp; 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
View 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 &amp; 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
View 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
View 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