Sanad_Package_4/README.md
2026-07-05 13:33:06 +00:00

252 lines
13 KiB
Markdown

# 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 <robot-ip>: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.