diff --git a/README.md b/README.md index 6d2ca2c..e8c180f 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,251 @@ -# Sanad Package 4 — Custom AI Guide Tour (SCAFFOLD / design only) +# Sanad Package 4 — Custom AI Guide Tour An **orchestrator** that composes whatever packages the customer bought into a -configurable guided tour, and **bundles autonomous navigation**. Dashboard on -**:8014**. License features: `guide_tour`, `navigation`, `mask`. +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**. -Planned containers: -- **`sanad-guide`** — tour engine: ordered stops (place + narration script + - optional gesture + greeting policy), scheduler, runtime state. Reads - entitlements and **degrades gracefully** (no P3 → generic greeting; no P2 → - single-language narration; no nav → preset/teleop stops). -- **`sanad-nav`** — bridge to `Nav2_Projects/sanad_nav3` (ROS2 Foxy Nav2 + Livox - MID-360 SLAM, markers/routes, patrol scheduler). Publishes `nav.goal`; emits - `loco.move` to the `Sanad_Core` hwbroker via the existing `cmd_vel_ipc_bridge`. +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`**. -Planned dashboard tabs: Tour Builder, Map & Navigation, Tour Runtime, -Capabilities, Face/Mask, Logs. +> **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.** -Depends on `Sanad_Core` (hwbroker) + optionally P1/P2 (narration) + P3 -(recognition). Owns the Livox MID-360 LiDAR. See the plan for the full design. +The tour engine **degrades by entitlement**: no P3 → generic greeting instead of +recognition; no P2 → single-language narration; no nav → preset stops. -**Build model (when implemented):** `sanad-guide` self-contained like P1 (a -`vendor/` engine copy + standalone `Dockerfile` + `sync_vendor.sh`, no -`sanad-base`). `sanad-nav` stays a separate ROS2-Foxy image, touching the mesh -only via ZMQ `loco.move`/`nav.goal`. +--- + +## 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.