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-guidecontainer (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-navis NOT built here — run it separately and pointWEB_NAV3_URLat 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):
# 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:
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):
- Gemini API key → paste your key.
- Persona / language → set who the robot is (P4 defaults to multilingual;
pin a single language with
SANAD_LANGUAGE=arif desired). - Navigation → confirm the livemap shows the map from
sanad-nav. If it is empty, start the separate web_nav3 / Nav2 stack and setWEB_NAV3_URL— until then tours run as preset (narrate-in-place) stops. - Operations → build a tour (ordered stops: place + narration + optional gesture/greeting), then Start.
Manage it
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.ymlalready setsrestart: unless-stopped, so P4 returns after a reboot. - No Docker? (dev mode) run against
./vendorin the robot'sgemini_sdkconda env: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 theProject.Sanadnamespace, 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-scopedProject.Sanad.mainshim, 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/tourCRUD + 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, optionalWITH_UNITREE_SDK=1chest-audio build. Nosanad-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(refreshvendor/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/):
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:
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.