# Unitree G1 - Sanad Package 4 — Custom AI Guide Tour An **orchestrator** that composes whatever packages the customer bought into a configurable **guided tour** — ordered, narrated stops that the robot drives to, blocks on arrival, and (where entitled) greets people by name at. Dashboard on **:8014**. This package is **self-contained** — it vendors the Sanad engine under `vendor/` and builds `FROM python:3.10-slim-bookworm`, so a clone of this folder builds and runs with **no sibling folders** and **no `sanad-base`**. > **Two containers, one job.** P4 is the pure-Python **`sanad-guide`** container > (this package). Autonomous navigation lives in a **separate ROS2 stack**, > **`sanad-nav`** = the existing **web_nav3 / Nav2 / rosbridge** stack > (`Nav2_Projects/web_nav3`, default **:8765**). P4 **bridges to it over HTTP** > (`WEB_NAV3_URL`). `sanad-nav` is **NOT built here** — run it separately and > point `WEB_NAV3_URL` at it. Without it, tours **degrade gracefully** to preset > "narrate-in-place" stops (the nav client never raises). **P4 contains no > Nav2/ROS2.** The tour engine **degrades by entitlement**: no P3 → generic greeting instead of recognition; no P2 → single-language narration; no nav → preset stops. --- ## Install (per-robot, first run) The workstation is canonical — build and run Docker **on the robot**, but edit and stage from the workstation. **1. Get the package folder onto the robot** (rsync from the workstation): ```bash # from the workstation (Project/G1/Packages/) rsync -az --exclude __pycache__ --exclude .git \ Sanad_Package_4 unitree@10.255.254.86:~/sanad_deploy/ ``` Everything below runs **on the robot**, from `~/sanad_deploy/Sanad_Package_4`. **2. License.** The package ships with a **bundled demo license** (`license/sanad.lic.example`, robot_id `G1-SN-DEMO-0001`, unbound, expires 2030-01-01) that entitles **all four packages** (P1+P2+P3+P4). That is enough to **build and run** P4 as-is. For a **delivered robot you need a signed, per-robot** license — see **Licensing** below (and `../README.md` for the vendor-side signer). P4 has **no** `NEW_ROBOT_SETUP.md`; the full first-run flow is inline here. **3. Keyless by design.** The image ships with **no Gemini API key baked in**. The customer pastes **their own key** in the dashboard on first open (step 1 of the quick start). Nothing to configure at build time. --- ## Run with Docker (quick start) On the robot, **from this folder** (`Sanad_Package_4/`) — nothing else needed: ```bash cp .env.example .env # optional: license path / audio / nav URL docker compose up -d --build # build (vendored engine) + run # Jetson Docker without buildx: DOCKER_BUILDKIT=0 docker compose up -d --build ``` `network_mode: host` is **required** (G1 DDS discovery on `eth0`) and is already set in `docker-compose.yml`. Open **http://<robot-ip>:8014** and (the image ships **keyless**): 1. **Gemini API key** → paste your key. 2. **Persona / language** → set who the robot is (P4 defaults to multilingual; pin a single language with `SANAD_LANGUAGE=ar` if desired). 3. **Navigation** → confirm the **livemap** shows the map from `sanad-nav`. If it is empty, start the separate **web_nav3 / Nav2** stack and set `WEB_NAV3_URL` — until then tours run as preset (narrate-in-place) stops. 4. **Operations** → build a tour (ordered stops: place + narration + optional gesture/greeting), then **Start**. --- ## Manage it ```bash docker compose logs -f # live logs docker compose down # stop ./test_p4.sh :8014 # smoke test (expect 10/10 PASS) ``` - **Auto-start on boot:** `sudo systemctl enable docker` — `docker-compose.yml` already sets `restart: unless-stopped`, so P4 returns after a reboot. - **No Docker? (dev mode)** run against `./vendor` in the robot's `gemini_sdk` conda env: ```bash cd ~/sanad_deploy/Sanad_Package_4 ./p4ctl.sh start # launch on :8014 (runs against ./vendor) ./p4ctl.sh status # process + /api/health ./p4ctl.sh logs 80 # tail p4.log ./p4ctl.sh restart ./p4ctl.sh stop ``` - **Update from the workstation** first, then rebuild on the robot: `rsync -az --exclude __pycache__ Sanad_Package_4 unitree@10.255.254.86:~/sanad_deploy/` The **entrypoint** on every start: (1) **license gate** — `python3 -m sanad_pkg.license_check P4`; if **not** entitled the container exits **cleanly** (code 0) so the restart policy will not crash-loop; (2) resolves config with precedence **env var > license feature > `config/p4_config.json`**; (3) runs a **preflight** printing clear diagnostics (python / google-genai / pyaudio / bleak + Pillow + mask lib / unitree SDK / audio profile); (4) execs `app_p4.py`. --- ## What it ships - `app_p4.py` — the launcher: bootstraps the `Project.Sanad` namespace, constructs the orchestrator subsystems (`brain`, `audio_mgr`, `voice_client`, `live_sub`, `typed_replay`, `nav_client`, `zone_gallery`, `tour_store`, `tour_runtime`, and — **only if entitled** — `mask_face` (P2) / `camera`+`gallery` (P3)), injects a P4-scoped `Project.Sanad.main` shim, and mounts the P4 routers. Serves the real Sanad SPA with non-P4 tabs hidden. - `tour_engine.py` — the **new tour engine**: `TourStore` (tour CRUD/persistence) + `TourRuntime` (ordered narrated stops; blocks on arrival; graceful degradation by entitlement). - `routes_tour.py` — `/api/tour` CRUD + runtime control. - `routes_p4.py` — P4 settings (api-key / persona / say / logs / settings), same first-class convenience as P1, mounted at `/api/p4`. - `vendor/Sanad` + `vendor/sanad_pkg` + `vendor/mask` — the vendored Sanad engine, license/bus lib, and LED-mask lib. Refresh with `./sync_vendor.sh`. - `Dockerfile` — SELF-CONTAINED: `FROM python:3.10-slim-bookworm`, bakes the vendored engine, optional `WITH_UNITREE_SDK=1` chest-audio build. No `sanad-base`. - `docker-compose.yml` — standalone build + run (`context: .`), `network_mode: host`, license/pulse/data mounts. - `entrypoint.sh` — license gate → config resolve → preflight → launch. - `config/` — `p4_config.json` (defaults: port, tabs, audio profile) + `mask_config.json`. - `license/` — `pubkey.ed25519` (verification key) + `sanad.lic.example` (demo license for the default mount). - `sync_vendor.sh` (refresh `vendor/` from a monorepo checkout) · `test_p4.sh` (smoke test) · `p4ctl.sh` (dev mode) · `requirements.txt` · `strip_key.py` (build-time key scrub) · `data/` (persisted tours / zones / faces / photos / recordings / audio). It does **not** fork Sanad — it **vendors** the canonical source under `vendor/`, so the package builds and runs entirely on its own with **no sibling folders**. --- ## Configuration (env vars) Copy `.env.example` → `.env` (compose reads it automatically). All are optional — sensible defaults are baked in. | Var | Default | What it does | |---|---|---| | `WEB_NAV3_URL` | `http://127.0.0.1:8765` | **The separate `sanad-nav` (web_nav3 / Nav2 / rosbridge) stack.** P4 bridges to it over HTTP for autonomous tours. Unreachable → tours degrade to preset stops. | | `SANAD_ROBOT_NAME` | `sanad` | Robot name passed to the nav bridge. | | `SANAD_NAV_GOAL_TIMEOUT_S` | `240` | Max seconds P4 waits for a nav goal to complete before moving on. | | `SANAD_LICENSE_FILE` | `./license/sanad.lic.example` | Path to the signed license mounted read-only at `/etc/sanad/sanad.lic`. Point at your **real per-robot** `sanad.lic`. | | `SANAD_LICENSE_BIND` | `0` | `1` = enforce machine-fingerprint binding (also uncomment the `/etc/machine-id` mount). | | `SANAD_AUDIO_PROFILE` | `builtin` | `builtin` = G1 chest over DDS · `plugged` = USB (Anker) via PulseAudio. | | `SANAD_DDS_INTERFACE` | `eth0` | DDS interface to the G1 firmware (chest audio + discovery). | | `SANAD_LANGUAGE` | *(empty)* | Empty = **multilingual** auto-detect; set `ar`/`en` to pin one language. | | `SANAD_MASK_ADDRESS` | *(empty)* | Pin the LED mask's BLE MAC; empty = auto-discover by name prefix. | | `SANAD_PULSE_DIR` / `PULSE_SERVER` / `PULSE_COOKIE` | `/run/user/1000/pulse…` | Host PulseAudio socket + cookie so the root container can drive **plugged/Bluetooth** speaker volume. Chest (`builtin`) audio needs none of this. | | `WITH_UNITREE_SDK` | `1` | Bundle CycloneDDS + `unitree_sdk2_python` (chest audio works OOTB). `0` = leaner USB/plugged-only image. | | `BASE_OS_IMAGE` | `python:3.10-slim-bookworm` | Override only for a GPU/offline build. | | `SANAD_IMAGE` | `sanad-p4:latest` | Image name/tag (e.g. a registry path for pull-and-run). | **Audio volume (all speaker types, like SanadV3):** `POST /api/audio/g1-speaker/volume` drives the **chest** (DDS `SetVolume`) **and** the active PulseAudio sink (plugged/BT, e.g. JBL). For the plugged/BT half the container needs the host PulseAudio socket + cookie (mounted above). One-time host setup for a stable boot-time socket: `loginctl enable-linger unitree`. --- ## Dashboard & features **SPA tabs** (`config/p4_config.json` → `spa_tabs`): **operations · voice · navigation · livemap · mapeditor · mask · recordings · settings**. Non-P4 tabs (motion, controller, recognition, temp, terminal) are hidden. `/` serves the filtered SPA; `/full` serves the same view. **API endpoint groups** (confirmed from `routes_p4.py`, `routes_tour.py`, `app_p4.py`, `test_p4.sh`): | Group | Purpose | |---|---| | `/api/tour/*` | Tour CRUD + runtime: `GET/POST /api/tour/`, `GET /api/tour/{id}`, `DELETE /api/tour/{id}`, and control `POST /api/tour/start\|stop\|pause\|resume\|skip`, `GET /api/tour/status`. | | `/api/nav/*` | Navigation status/goals **via `WebNav3Client` → `WEB_NAV3_URL`** (the separate `sanad-nav` stack), e.g. `GET /api/nav/status`. | | `/api/zones/*` | Places / zones (`GET /api/zones/`) the tour stops reference. | | `/api/p4/*` | P4 settings: `GET/POST /api/p4/api-key` (+ `/api-key/delete`), `GET/POST /api/p4/persona`, `POST /api/p4/say`, `GET /api/p4/settings`. | | `/api/live-subprocess/*` | Start/stop the live Gemini conversation. | | `/api/voice/*` `/api/audio/*` `/api/prompt/*` `/api/typed-replay/*` `/api/records/*` | Base Sanad conversation, audio device/volume, persona, typed replay, saved recordings. | | `/api/mask/*` | **Only if P2/`mask` entitled** — LED face expressions + social. | | `/api/recognition/*` | **Only if P3/`face_rec` entitled** — personalized greetings. | | `/api/package` · `/api/system/info` · `/api/logs/*` · `/api/health` · `/ws/logs` | Manifest + license + feature flags, system, logs (live WS), health. | **Optional sub-capabilities are license-gated.** P2 mask and P3 camera/recognition subsystems are constructed **only if entitled**. The camera device mounts in `docker-compose.yml` are **commented by default** (`/dev/video0`) — uncomment and set your camera node to enable recognition (same absent-node hard-fail gotcha as P3). --- ## Licensing A signed **Ed25519** `sanad.lic` decides which packages a robot may run. Verification is fully **offline**: `pubkey.ed25519` is baked into the image at `/etc/sanad/pubkey.ed25519`, and `docker-compose.yml` mounts the license read-only (`${SANAD_LICENSE_FILE:-./license/sanad.lic.example}:/etc/sanad/sanad.lic:ro`). **Bundled demo license** (`license/sanad.lic.example`): robot_id `G1-SN-DEMO-0001`, unbound, expires 2030-01-01, entitles **all four** packages (P1+P2+P3+P4) with features `guide_tour`, `navigation`, `mask`, `face_rec`, `multilingual`, `places`, `memory`, … — enough to build and run P4 immediately. **What actually gates P4:** the entrypoint's `license_check P4` unlocks the container on the **`packages.P4`** flag alone — no feature is required. The `guide_tour`, `navigation`, and `mask` **features** don't gate entry; they toggle sub-behaviours inside `app_p4.py` and default to **on**, so `navigation` enables the web_nav3 bridge, `mask` enables per-stop face gestures (both optional). Set a feature `false` to disable that behaviour without removing the P4 package. **Sign a real per-robot license** (vendor side — keep `privkey.ed25519` **OFF** the robot; the signer lives in `../licensing/`): ```bash python licensing/sign_license.py gen-keys --out-dir . # once — generates the keypair # edit a claims.json: packages{P1..P4} + features{guide_tour, navigation, ...} # + robot_id (+ optional machine_fingerprint) python licensing/sign_license.py sign \ --key privkey.ed25519 --in claims.json --out sanad.lic ``` Put `sanad.lic` on the robot and point `SANAD_LICENSE_FILE` at it (or replace `license/sanad.lic.example`). **Optional hardware binding:** `SANAD_LICENSE_BIND=1` binds the license to the machine fingerprint (also uncomment the `/etc/machine-id` mount). Verify entitlement the way the entrypoint does: ```bash SANAD_LICENSE=license/sanad.lic python -m sanad_pkg.license_check P4 ``` See `../README.md` (Licensing) for the shared vendor-side flow. --- ## Status **Built + validated locally** — compiles, `license_check P4` reports entitled, `docker-compose.yml` YAML is valid, and functional tour/memory tests pass. The **remaining gate** is the **on-robot Docker build + `./test_p4.sh` smoke test** (P1 is already robot-tested; P2/P3/P4 are validated locally, not yet robot-built). **For fully autonomous tours** P4 also needs the **separate `sanad-nav` (web_nav3 / Nav2 / rosbridge) stack** running and reachable at `WEB_NAV3_URL`. Without it, tours still run — as preset, narrate-in-place stops.