2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:59:59 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00
2026-07-04 23:28:37 +04:00

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):

# 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):

  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

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 dockerdocker-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:
    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 gatepython3 -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 entitledmask_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.jsonspa_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 WebNav3ClientWEB_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.

Description
No description provided
Readme
Languages
Python 74.9%
Shell 18.7%
Dockerfile 6.4%