commit aba3118dfc2b41518bfa1bc108080b6cad891a65 Author: kassam Date: Wed Jun 17 15:49:29 2026 +0400 Update 2026-06-17 15:49:27 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c02264b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +Logs/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cdc3b13 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..10f43d9 --- /dev/null +++ b/README.md @@ -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://: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@:~/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_.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://:8011/api/p1/persona # current persona + rules +curl -X POST http://: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://:8011/api/p1/api-key \ + -H 'Content-Type: application/json' -d '{"api_key":"AIza...."}' +# check status (masked; never returns the full key) +curl http://: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). diff --git a/app_p1.py b/app_p1.py new file mode 100644 index 0000000..daa1877 --- /dev/null +++ b/app_p1.py @@ -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 ` 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 ( + "" + "" + % (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\": \"\"}", + "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("", inject + "", 1) if "" 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() diff --git a/config/p1_config.json b/config/p1_config.json new file mode 100644 index 0000000..0da5589 --- /dev/null +++ b/config/p1_config.json @@ -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"] +} diff --git a/docker-compose.p1.yml b/docker-compose.p1.yml new file mode 100644 index 0000000..89f148d --- /dev/null +++ b/docker-compose.p1.yml @@ -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" diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..87aca92 --- /dev/null +++ b/entrypoint.sh @@ -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 + 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 diff --git a/p1ctl.sh b/p1ctl.sh new file mode 100755 index 0000000..12d4d0b --- /dev/null +++ b/p1ctl.sh @@ -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 diff --git a/requirements-p1.txt b/requirements-p1.txt new file mode 100644 index 0000000..3992f64 --- /dev/null +++ b/requirements-p1.txt @@ -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). diff --git a/routes_p1.py b/routes_p1.py new file mode 100644 index 0000000..6690d1f --- /dev/null +++ b/routes_p1.py @@ -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, + } diff --git a/static/p1.html b/static/p1.html new file mode 100644 index 0000000..b61bcb3 --- /dev/null +++ b/static/p1.html @@ -0,0 +1,302 @@ + + + + + +Sanad — Basic Communication · YS Lootah Tech + + + +
+
+ + + YS + +
+

Sanad — Basic Communication

+ powered by YS Lootah Tech +
+
+ +
+ license … + session … + lang … +
+
+ +
+ +
Talk
+
+
+

🎙️ Conversation live voice

+
+ + + +
+
Start the live session, then just speak to the robot.
+
+ +
+

💬 Say a line type → robot speaks

+ +
+
+
+ +
+

🪪 Robot persona who Sanad is, tone & language/dialect — applied live

+ +
+ + + Put the language directive here (e.g. “Speak Khaleeji Arabic”). Saving restarts the live session so it applies immediately. +
+
+
+
+ + +
Settings
+
+
+

🔑 Gemini API key add / delete

+
+ + +
+ + + +
+
+
Accepts any Gemini key (AIza… or AQ.… tokens). Stored masked; deleting stops conversation until a new key is added.
+
+ +
+

🔊 Audio speaker / mic + volume

+ + +
+ + +
+ +
+ + 80 + +
+
+
Plug an Anker/USB device → Rescan → pick it in the profile list. Chest volume needs the builtin (DDS) profile.
+
+
+ + +
Diagnostics
+
+
+

📜 Logs live + + + +

+
connecting…
+
+
+
+
+ +
+ + + YS + + Sanad — powered by YS Lootah Tech +
+ + + + diff --git a/static/p1_widget.html b/static/p1_widget.html new file mode 100644 index 0000000..f369b20 --- /dev/null +++ b/static/p1_widget.html @@ -0,0 +1,86 @@ + + + +
+

Sanad P1 — Quick Controls

+

🎙️ Conversation

+
+ +
+

💬 Say a line

+ +
+

🪪 Persona (applied live)

+ +
+
+

🔊 Audio

+ +
+
+ +
+
+
+
+ diff --git a/strip_key.py b/strip_key.py new file mode 100644 index 0000000..f384e05 --- /dev/null +++ b/strip_key.py @@ -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)) diff --git a/test_p1.sh b/test_p1.sh new file mode 100644 index 0000000..a5e2d69 --- /dev/null +++ b/test_p1.sh @@ -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