# SanadR1 — Gemini voice + dashboard for the Unitree R1 EDU backpack. # # BUILD THIS ON THE BACKPACK (native aarch64/arm64). Building on an x86 host # needs `docker buildx` + qemu. Needs internet at build time (CycloneDDS clone, # pip). Run with `--network host` (see docker-compose.yml) — the robot's DDS and # the on-board mic multicast (239.168.123.161:5555) do not traverse a NAT bridge. # Preferred launch: docker/build_and_run.sh (it also disables the old conda service). FROM arm64v8/python:3.10-slim-bookworm # ── System deps ────────────────────────────────────────────────────────────── # build-essential/cmake → compile the CycloneDDS *python binding* (no # aarch64 wheel) + PyAudio if it falls back to sdist # cyclonedds-dev/-tools → PREBUILT CycloneDDS 0.10.2 C lib + idlc for arm64 # (Debian bookworm ships 0.10.2-2 — exact robot # match; avoids the source compile that segfaults # under qemu cross-build and is slow even native) # portaudio19-dev/libasound2 → PyAudio must import (AudioManager needs it even # with no sound cards; audio itself is DDS/UDP) # iproute2 → REQUIRED: BuiltinMic._find_g1_local_ip() shells # `ip -4 -o addr` unguarded; missing `ip` = no mic # libgomp1 → provides libgomp.so.1 for the LD_PRELOAD below # curl → HEALTHCHECK RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential cmake \ cyclonedds-dev cyclonedds-tools \ portaudio19-dev libasound2 iproute2 libgomp1 \ ffmpeg \ pulseaudio pulseaudio-utils alsa-utils \ curl ca-certificates \ && rm -rf /var/lib/apt/lists/* # aarch64 "cannot allocate memory in static TLS block" guard on numpy/opencv # import (start_sanad.sh calls this a mandatory Jetson fix). Soname form is # resolved via ldconfig, so it can't point at a missing path (libgomp1 above). ENV LD_PRELOAD=libgomp.so.1 # cyclonedds' find helper (buildhelp/cyclone_search.py) hardcodes # $CYCLONEDDS_HOME/lib/libddsc.so, but Debian installs into the multiarch dir # /usr/lib/aarch64-linux-gnu. Symlink the two libs it checks into /usr/lib so # good_directory(/usr) passes. (Runtime loading uses the multiarch path already # on the default loader search, so no LD_LIBRARY_PATH is needed.) ENV CYCLONEDDS_HOME=/usr RUN ln -sf /usr/lib/aarch64-linux-gnu/libddsc.so /usr/lib/libddsc.so \ && ln -sf /usr/lib/aarch64-linux-gnu/libcycloneddsidl.so /usr/lib/libcycloneddsidl.so # ── Python dependencies (mask removed → no bleak) ──────────────────────────── COPY docker/requirements.docker.txt /tmp/requirements.txt RUN pip install --no-cache-dir -r /tmp/requirements.txt # CycloneDDS python binding, compiled against the C lib above (no aarch64 wheel). # cyclonedds 0.10.2's build helper imports wheel.bdist_wheel (removed in # wheel>=0.46), so pin the build toolchain before the --no-build-isolation build. RUN pip install --no-cache-dir "setuptools<80" "wheel<0.46" \ && pip install --no-cache-dir --no-build-isolation cyclonedds==0.10.2 # ── Unitree SDK (vendored wheel — not on PyPI) + native crc lib (wheel omits it) COPY docker/vendor/unitree_sdk2py-*.whl /tmp/ RUN pip install --no-cache-dir --no-deps /tmp/unitree_sdk2py-*.whl COPY docker/vendor/crc_aarch64.so docker/vendor/crc_amd64.so \ /usr/local/lib/python3.10/site-packages/unitree_sdk2py/utils/lib/ # ── Application code ───────────────────────────────────────────────────────── # main.py's deployed-layout shim imports THIS folder by its own name and aliases # Project.Sanad → it, inserting the folder's PARENT (/opt) into sys.path. What # matters: a non-'Project' parent + an __init__.py (both hold here). The name # 'SanadR1' is what the running instance is called, so keep it for clarity. WORKDIR /opt COPY . /opt/SanadR1 WORKDIR /opt/SanadR1 # Non-root app user for USB audio: Sanad refuses to touch audio as root, and # PulseAudio needs a per-user session. uid 1000 mirrors the host 'unitree'; put it # in the 'audio' group (Debian gid 29 == the host's) so it can reach /dev/snd. # The entrypoint (still root) chowns the mounted volumes, then drops to this user. RUN groupmod -g 29 audio 2>/dev/null || groupadd -g 29 audio ; \ useradd -m -u 1000 -o -s /bin/bash sanad \ && usermod -aG audio sanad \ && mkdir -p /run/user/1000 && chown sanad:sanad /run/user/1000 && chmod 700 /run/user/1000 \ && chown -R sanad:sanad /opt/SanadR1 # ── Runtime config (audio + DDS are network-based; no local sound hardware) ── ENV PYTHONUNBUFFERED=1 \ SANAD_DDS_INTERFACE=eth10 \ SANAD_DASHBOARD_HOST=0.0.0.0 \ SANAD_NAV_TOOLS=0 \ SANAD_AUDIO_PROFILE=builtin \ SANAD_VOICE_BRAIN=gemini \ PORT=8001 # SANAD_GEMINI_API_KEY is supplied at run time (compose env_file / -e), never baked. EXPOSE 8001 HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ CMD curl -fsS "http://127.0.0.1:${PORT:-8001}/" >/dev/null || exit 1 # entrypoint waits for eth10 (robot link) before launching, mirroring start_sanad.sh ENTRYPOINT ["/opt/SanadR1/docker/entrypoint.sh"]