Clean out the Pi Zero leftovers, rewrite the README, and add the one installer

Wi-Fi-off acceptance passed: with Wi-Fi blocked, live and after a reboot, the Pi
came up on the USB link alone, every node ran, and hermes mcp test robot and look()
worked. (A temporary guard that would have unblocked Wi-Fi was the safety net and is
removed; Wi-Fi is back on.)

Delete what the ROS design superseded: the Picamera2 MCP server and its unit, the old
installer and requirements, and the duplicate platform check. Move omarchy/ to
host/omarchy/. Rewrite README.md for this build: parts, physical steps (including the
Audio switch), setup, daily use, the troubleshooting learned the hard way, and the
security model.

pi/install.sh is now the single installer for a fresh Ubuntu 24.04 image, built from the
steps done by hand: ROS, the libcamera fork at a pinned commit, camera_ros, the venv and
models, the workspace, USB gadget, audio overlay, firewall (before PipeWire opens a
port), PipeWire, mixer and services. Run twice on the live robot, the second run changed
nothing and everything stayed healthy. It has not been run on a blank card.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
This commit is contained in:
2026-10-05 00:49:21 -07:00
co-authored by Claude Sonnet 5.5
parent 841ddc3573
commit 9952c92bc5
11 changed files with 330 additions and 645 deletions
+152 -83
View File
@@ -1,96 +1,165 @@
#!/usr/bin/env bash
# Robot setup for Raspberry Pi OS Lite (64-bit) on a Pi Zero 2 W.
# Run as your normal user (not root) from inside this "pi" folder:
#!/bin/bash
# Set up the robot's Raspberry Pi 4 from a fresh Ubuntu Server 24.04 (arm64) image.
#
# ./install.sh <omarchy-machine-ip> [--servos]
# rsync -a ./ hermes@<pi>:~/hermes-bot/
# ssh hermes@<pi> 'cd hermes-bot && sudo ./pi/install.sh'
#
# Safe to re-run. Reboot afterwards so the I2S sound card overlay takes effect.
# Re-runnable: every step checks before it changes anything, and the heavy builds skip themselves if
# they are already done. Needs the internet (apt, git, pip, model downloads). The first run takes a
# while (the libcamera build alone is about 15 minutes). Reboot afterwards: it changes /boot/firmware/
# config.txt for the USB gadget and the audio codec.
#
# The user (default "hermes", set ROBOT_USER) must already exist: create it in cloud-init user-data.
# Order matters: the firewall goes in before PipeWire's network server, which listens on every
# interface and is protected only by that firewall.
set -euo pipefail
HOST_IP="${1:-}"
SERVOS=0
[[ "${2:-}" == "--servos" ]] && SERVOS=1
ROBOT_USER="${ROBOT_USER:-hermes}"
PI_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO="$(dirname "$PI_DIR")"
# Raspberry Pi's fork of libcamera, at a commit known to capture on this kernel. Upstream libcamera,
# Ubuntu's 0.2.0 and ROS's 0.7.2 all crash in the Pi image-processing plugin when capturing.
LIBCAMERA_REF=6c1dd9d55573010f710c9e190a73e7e76f0d9432
CAMERA_ROS_TAG=0.7.0
MODEL_BASE=https://github.com/opencv/opencv_zoo/raw/main/models # a plain raw URL returns a Git LFS pointer
if [[ -z "$HOST_IP" || ! "$HOST_IP" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
echo "usage: $0 <omarchy-machine-ipv4> [--servos]" >&2
exit 1
fi
if [[ $EUID -eq 0 ]]; then
echo "Run this as your normal user; it calls sudo where needed." >&2
exit 1
export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a
REBOOT=0
[ "$(id -u)" = 0 ] || { echo "run with sudo" >&2; exit 1; }
HOME_DIR="$(getent passwd "$ROBOT_USER" | cut -d: -f6)"
[ -n "$HOME_DIR" ] || { echo "no user '$ROBOT_USER': create it in cloud-init user-data first" >&2; exit 1; }
grep -qs "Ubuntu 24.04" /etc/os-release || echo "warning: this is written for Ubuntu 24.04" >&2
step() { printf '\n==> %s\n' "$*"; }
as_user() { sudo -u "$ROBOT_USER" -H "$@"; }
cfg_sum() { sha256sum /boot/firmware/config.txt | cut -d' ' -f1; }
USER_UID="$(id -u "$ROBOT_USER")"
step "Base packages"
apt-get update -q
apt-get -y -q install git curl rsync software-properties-common openssl rfkill device-tree-compiler i2c-tools \
alsa-utils python3-numpy python3-pil python3-spidev python3-lgpio python3-rpi-lgpio python3-pip \
python3-venv pipewire pipewire-pulse wireplumber libspa-0.2-modules pulseaudio-utils \
meson ninja-build pkg-config python3-yaml python3-ply python3-jinja2 libyaml-dev libgnutls28-dev \
libssl-dev libudev-dev libevent-dev libdrm-dev libjpeg-dev libpng-dev libtiff-dev libboost-dev v4l-utils
step "Groups for hardware access"
for g in video audio i2c spi gpio dialout plugdev; do
getent group "$g" >/dev/null && usermod -aG "$g" "$ROBOT_USER" || echo " (no group $g on this image)"
done
step "ROS 2 Jazzy"
if ! dpkg -s ros-jazzy-ros-base >/dev/null 2>&1; then
add-apt-repository -y universe
. /etc/os-release
V="$(curl -s https://api.github.com/repos/ros-infrastructure/ros-apt-source/releases/latest | grep -F tag_name | awk -F\" '{print $4}')"
curl -fL -o /tmp/ros2-apt-source.deb \
"https://github.com/ros-infrastructure/ros-apt-source/releases/download/${V}/ros2-apt-source_${V}.${UBUNTU_CODENAME:-$VERSION_CODENAME}_all.deb"
dpkg -i /tmp/ros2-apt-source.deb
apt-get update -q
fi
# ros-jazzy-camera-ros is installed for its dependencies; the camera driver actually used is built below
apt-get -y -q install ros-jazzy-ros-base ros-dev-tools python3-colcon-common-extensions \
ros-jazzy-robot-state-publisher ros-jazzy-xacro ros-jazzy-camera-ros ros-jazzy-vision-msgs
HERE="$(cd "$(dirname "$0")" && pwd)"
ME="$(id -un)"
CONFIG_TXT=/boot/firmware/config.txt
[[ -f $CONFIG_TXT ]] || CONFIG_TXT=/boot/config.txt
echo "==> Installing packages"
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
python3-picamera2 python3-venv \
pipewire pipewire-pulse pipewire-alsa wireplumber libspa-0.2-modules \
pulseaudio-utils alsa-utils nftables openssl
echo "==> Enabling the I2S microphone/amplifier overlay in $CONFIG_TXT"
add_line() { grep -qxF "$1" "$CONFIG_TXT" || echo "$1" | sudo tee -a "$CONFIG_TXT" >/dev/null; }
add_line "dtparam=i2s=on"
add_line "dtoverlay=googlevoicehat-soundcard"
if [[ $SERVOS -eq 1 ]]; then
add_line "dtparam=i2c_arm=on"
fi
echo "==> Installing the MCP server to /opt/robot-mcp"
sudo mkdir -p /opt/robot-mcp
sudo chown "$ME:$ME" /opt/robot-mcp
cp "$HERE/robot_mcp_server.py" /opt/robot-mcp/
# --system-site-packages so the venv can see the apt-installed picamera2/libcamera
[[ -d /opt/robot-mcp/venv ]] || python3 -m venv --system-site-packages /opt/robot-mcp/venv
/opt/robot-mcp/venv/bin/pip install --upgrade pip >/dev/null
/opt/robot-mcp/venv/bin/pip install -r "$HERE/requirements.txt"
if [[ $SERVOS -eq 1 ]]; then
/opt/robot-mcp/venv/bin/pip install adafruit-circuitpython-servokit
fi
echo "==> Writing /etc/robot-mcp.env"
if ! sudo test -f /etc/robot-mcp.env; then
TOKEN="$(openssl rand -hex 32)"
sudo tee /etc/robot-mcp.env >/dev/null <<EOF
ROBOT_MCP_TOKEN=$TOKEN
ROBOT_MCP_PORT=8765
ROBOT_CAM_SIZE=1280x720
ROBOT_CAM_HFLIP=0
ROBOT_CAM_VFLIP=0
ROBOT_SERVOS=$SERVOS
EOF
sudo chown root:"$ME" /etc/robot-mcp.env
sudo chmod 640 /etc/robot-mcp.env
step "Raspberry Pi libcamera fork -> /opt/libcamera-rpi"
if [ -x /opt/libcamera-rpi/bin/cam ]; then
echo " already built"
else
sudo sed -i "s/^ROBOT_SERVOS=.*/ROBOT_SERVOS=$SERVOS/" /etc/robot-mcp.env
rm -rf /var/tmp/libcamera-rpi
git clone -q --no-checkout https://github.com/raspberrypi/libcamera.git /var/tmp/libcamera-rpi
git -C /var/tmp/libcamera-rpi fetch -q --depth 1 origin "$LIBCAMERA_REF"
git -C /var/tmp/libcamera-rpi checkout -q FETCH_HEAD
(cd /var/tmp/libcamera-rpi \
&& meson setup build --buildtype=release -Dprefix=/opt/libcamera-rpi -Dpipelines=rpi/vc4 -Dipas=rpi/vc4 \
-Dcam=enabled -Dqcam=disabled -Dgstreamer=disabled -Dpycamera=disabled -Dlc-compliance=disabled \
-Dtest=false -Ddocumentation=disabled -Dv4l2=false \
&& ninja -C build -j4 && ninja -C build install)
fi
echo "==> Installing the systemd service"
sed "s/__USER__/$ME/g" "$HERE/robot-mcp.service" | sudo tee /etc/systemd/system/robot-mcp.service >/dev/null
getent group i2c >/dev/null || sudo groupadd --system i2c
sudo systemctl daemon-reload
sudo systemctl enable robot-mcp.service
step "Python environment (~/venv: display, OpenCV 4.10 for YuNet, MCP SDK)"
# OpenCV's DNN needs 4.8+ for the YuNet model and apt has 4.6; numpy stays below 2 because the ROS
# Python packages are built against 1.x.
[ -d "$HOME_DIR/venv" ] || as_user python3 -m venv --system-site-packages "$HOME_DIR/venv"
as_user "$HOME_DIR/venv/bin/pip" install -q adafruit-circuitpython-rgb-display adafruit-blinka \
"opencv-python-headless==4.10.0.84" "numpy<2" "mcp>=2.3,<3"
echo "==> Configuring PipeWire to serve audio to $HOST_IP"
mkdir -p ~/.config/pipewire/pipewire-pulse.conf.d
cp "$HERE/pipewire/50-network-server.conf" ~/.config/pipewire/pipewire-pulse.conf.d/
# Run the audio stack at boot without anyone logging in.
sudo loginctl enable-linger "$ME"
systemctl --user enable pipewire.socket pipewire-pulse.socket wireplumber.service 2>/dev/null || true
step "Face models (~/models)"
as_user mkdir -p "$HOME_DIR/models"
fetch_model() { # name, path in the zoo
local dest="$HOME_DIR/models/$1"
if [ "$(stat -c %s "$dest" 2>/dev/null || echo 0)" -lt 100000 ]; then
as_user curl -fsSL -o "$dest" "$MODEL_BASE/$2/$1"
fi
[ "$(stat -c %s "$dest")" -gt 100000 ] || { echo " $1 looks like an LFS pointer, not the model" >&2; exit 1; }
}
fetch_model face_detection_yunet_2023mar.onnx face_detection_yunet
fetch_model face_recognition_sface_2021dec.onnx face_recognition_sface
echo "==> Restricting ports 4713 and 8765 to $HOST_IP"
sed "s/__HOST_IP__/$HOST_IP/g" "$HERE/nftables-robot.nft" | sudo tee /etc/nftables-robot.nft >/dev/null
grep -qxF 'include "/etc/nftables-robot.nft"' /etc/nftables.conf \
|| echo 'include "/etc/nftables-robot.nft"' | sudo tee -a /etc/nftables.conf >/dev/null
sudo systemctl enable nftables.service
sudo systemctl restart nftables.service
step "camera_ros $CAMERA_ROS_TAG, built against the libcamera fork (~/camera_ws)"
if [ -d "$HOME_DIR/camera_ws/install/camera_ros" ]; then
echo " already built"
else
as_user mkdir -p "$HOME_DIR/camera_ws/src"
[ -d "$HOME_DIR/camera_ws/src/camera_ros" ] || as_user git clone -q --depth 1 --branch "$CAMERA_ROS_TAG" \
https://github.com/christianrauch/camera_ros.git "$HOME_DIR/camera_ws/src/camera_ros"
as_user bash -c "source /opt/ros/jazzy/setup.bash && export PKG_CONFIG_PATH=/opt/libcamera-rpi/lib/aarch64-linux-gnu/pkgconfig \
&& cd '$HOME_DIR/camera_ws' && colcon build --symlink-install --cmake-args -DCMAKE_BUILD_TYPE=Release"
fi
echo
echo "Done. Reboot now: sudo reboot"
echo
echo "Your MCP token (put this in ~/.hermes/.env on the Omarchy machine as ROBOT_MCP_TOKEN):"
sudo grep '^ROBOT_MCP_TOKEN=' /etc/robot-mcp.env | cut -d= -f2
step "Environment script and the robot's ROS workspace (~/ros_ws)"
install -o "$ROBOT_USER" -g "$ROBOT_USER" -m 644 "$PI_DIR/robot-env.sh" "$HOME_DIR/robot-env.sh"
as_user mkdir -p "$HOME_DIR/ros_ws/src"
rsync -a --delete --exclude __pycache__ "$REPO/ros/" "$HOME_DIR/ros_ws/src/"
chown -R "$ROBOT_USER:$ROBOT_USER" "$HOME_DIR/ros_ws/src"
as_user bash -c "source /opt/ros/jazzy/setup.bash && source '$HOME_DIR/camera_ws/install/setup.bash' \
&& cd '$HOME_DIR/ros_ws' && colcon build --symlink-install"
step "USB gadget (NCM Ethernet + ACM serial) and the audio codec overlay"
before="$(cfg_sum)"
bash "$PI_DIR/install-usb-gadget.sh"
bash "$PI_DIR/install-audio-overlay.sh"
[ "$before" = "$(cfg_sum)" ] || REBOOT=1
step "Firewall (before anything listens on the network)"
bash "$PI_DIR/install-firewall.sh"
step "PipeWire for $ROBOT_USER (audio server, reachable only over the USB link)"
loginctl enable-linger "$ROBOT_USER"
as_user mkdir -p "$HOME_DIR/.config/pipewire/pipewire-pulse.conf.d"
install -o "$ROBOT_USER" -g "$ROBOT_USER" -m 644 "$PI_DIR/pipewire/50-network-server.conf" \
"$HOME_DIR/.config/pipewire/pipewire-pulse.conf.d/50-network-server.conf"
for _ in $(seq 20); do [ -S "/run/user/$USER_UID/bus" ] && break; sleep 1; done
as_user env XDG_RUNTIME_DIR="/run/user/$USER_UID" systemctl --user enable --now \
pipewire.socket pipewire-pulse.socket wireplumber.service \
|| echo " (the user session is not up yet; PipeWire starts at the next boot)"
step "Audio codec mixer and microphone gain"
if aplay -l 2>/dev/null | grep -q wm8960; then
alsactl -f "$PI_DIR/wm8960_asound.state" restore wm8960soundcard && alsactl store wm8960soundcard
# WirePlumber resets the codec's capture gain to +30 dB when it takes the device; 63% on PipeWire's
# cubic scale is -12 dB from that, about +17 dB.
as_user env XDG_RUNTIME_DIR="/run/user/$USER_UID" pactl set-source-volume @DEFAULT_SOURCE@ 63% \
|| echo " (could not set the source volume: run pactl set-source-volume @DEFAULT_SOURCE@ 63% later)"
else
echo " no WM8960 card yet: reboot, then run this script again"
REBOOT=1
fi
step "Services: the robot (face, camera, vision, audio, MCP bridge), the CPU governor, trimmed boot"
bash "$PI_DIR/install-robot-service.sh"
bash "$PI_DIR/trim-boot.sh"
cat <<EOF
Done.$([ "$REBOOT" = 1 ] && echo " REBOOT NOW: the USB gadget and the audio overlay only take effect then.")
Next:
- Slide the HAT's "Audio" switch (right edge) to ON, or every recording is silent.
- Plug the USB-C data cable into your computer, then on it: sudo host/omarchy/robot-usb-link.sh
- Hermes needs the MCP token. It is not printed here; read it with:
ssh $ROBOT_USER@10.55.0.2 sudo cat /etc/robot-mcp.env
then see host/omarchy/hermes-config-snippet.yaml.
- Check with: hermes mcp test robot
EOF
-3
View File
@@ -1,3 +0,0 @@
# picamera2 and libcamera come from apt (python3-picamera2), not pip.
# Written against the MCP Python SDK 2.x (MCPServer); 1.x used the older FastMCP name.
mcp>=2.3,<3
-17
View File
@@ -1,17 +0,0 @@
[Unit]
Description=Robot MCP server (camera and head for Hermes Agent)
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=__USER__
Group=__USER__
SupplementaryGroups=video i2c
EnvironmentFile=/etc/robot-mcp.env
ExecStart=/opt/robot-mcp/venv/bin/python /opt/robot-mcp/robot_mcp_server.py
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
-229
View File
@@ -1,229 +0,0 @@
#!/usr/bin/env python3
"""
Robot MCP server: runs on the Raspberry Pi and gives Hermes Agent the robot's eyes
(and, optionally, a neck).
Tools exposed
look() -> a JPEG still from the camera
turn_head(pan, tilt) -> aim the pan/tilt servos (only if ROBOT_SERVOS=1)
head_position() -> current pan/tilt (only if ROBOT_SERVOS=1)
Transport: MCP streamable HTTP at http://<pi>:<port>/mcp, guarded by a bearer token.
Audio is NOT handled here; it travels over PipeWire (see README).
Configuration is by environment variable (normally set in /etc/robot-mcp.env):
ROBOT_MCP_TOKEN required shared secret; Hermes sends "Authorization: Bearer <token>"
ROBOT_MCP_PORT 8765
ROBOT_MCP_BIND 0.0.0.0
ROBOT_CAM_SIZE 1280x720 still resolution handed to the model
ROBOT_CAM_QUALITY 85 JPEG quality
ROBOT_CAM_HFLIP 0 set 1 if the camera is mounted mirrored
ROBOT_CAM_VFLIP 0 set 1 (with HFLIP=1) if mounted upside down
ROBOT_SERVOS 0 set 1 to enable turn_head via a PCA9685 board
ROBOT_PAN_CHANNEL 0
ROBOT_TILT_CHANNEL 1
ROBOT_PAN_LIMIT 80 degrees either side of centre
ROBOT_TILT_LIMIT 45
ROBOT_PAN_INVERT 0 set 1 if "positive = left" comes out backwards
ROBOT_TILT_INVERT 0
"""
from __future__ import annotations
import hmac
import io
import logging
import os
import sys
import threading
import time
import uvicorn
from mcp.server.mcpserver import Image, MCPServer
from mcp.server.transport_security import TransportSecuritySettings
log = logging.getLogger("robot-mcp")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
def env_flag(name: str, default: str = "0") -> bool:
return os.environ.get(name, default).strip().lower() in ("1", "true", "yes", "on")
def env_int(name: str, default: int) -> int:
return int(os.environ.get(name, str(default)))
TOKEN = os.environ.get("ROBOT_MCP_TOKEN", "").strip()
PORT = env_int("ROBOT_MCP_PORT", 8765)
BIND = os.environ.get("ROBOT_MCP_BIND", "0.0.0.0")
CAM_W, CAM_H = (int(v) for v in os.environ.get("ROBOT_CAM_SIZE", "1280x720").lower().split("x"))
CAM_QUALITY = env_int("ROBOT_CAM_QUALITY", 85)
# --------------------------------------------------------------------------- camera
class Camera:
"""Keeps Picamera2 running so a still is ready in well under a second."""
def __init__(self) -> None:
from libcamera import Transform # type: ignore[import-not-found]
from picamera2 import Picamera2 # type: ignore[import-not-found]
self._lock = threading.Lock()
self.cam = Picamera2()
config = self.cam.create_still_configuration(
main={"size": (CAM_W, CAM_H)},
transform=Transform(hflip=env_flag("ROBOT_CAM_HFLIP"), vflip=env_flag("ROBOT_CAM_VFLIP")),
buffer_count=2,
)
self.cam.configure(config)
self.cam.options["quality"] = CAM_QUALITY
self.has_autofocus = "AfMode" in self.cam.camera_controls
self.cam.start()
time.sleep(1.0) # let auto-exposure settle once at startup
log.info("camera ready at %dx%d (autofocus: %s)", CAM_W, CAM_H, self.has_autofocus)
def snapshot(self) -> bytes:
with self._lock:
if self.has_autofocus:
# Camera Module 3: run one focus sweep so whatever is in front is sharp.
self.cam.autofocus_cycle()
buf = io.BytesIO()
self.cam.capture_file(buf, format="jpeg")
return buf.getvalue()
# --------------------------------------------------------------------------- servos
class Head:
"""Pan/tilt on a PCA9685 servo board. Angles are degrees from centre."""
def __init__(self) -> None:
from adafruit_servokit import ServoKit # type: ignore[import-not-found]
self._lock = threading.Lock()
self.kit = ServoKit(channels=16)
self.pan_ch = env_int("ROBOT_PAN_CHANNEL", 0)
self.tilt_ch = env_int("ROBOT_TILT_CHANNEL", 1)
self.pan_limit = env_int("ROBOT_PAN_LIMIT", 80)
self.tilt_limit = env_int("ROBOT_TILT_LIMIT", 45)
self.pan_sign = -1 if env_flag("ROBOT_PAN_INVERT") else 1
self.tilt_sign = -1 if env_flag("ROBOT_TILT_INVERT") else 1
self.pan = 0.0
self.tilt = 0.0
self.move(0.0, 0.0)
log.info("servos ready (pan ch %d, tilt ch %d)", self.pan_ch, self.tilt_ch)
def move(self, pan: float, tilt: float) -> tuple[float, float]:
pan = max(-self.pan_limit, min(self.pan_limit, pan))
tilt = max(-self.tilt_limit, min(self.tilt_limit, tilt))
with self._lock:
self.kit.servo[self.pan_ch].angle = 90 + self.pan_sign * pan
self.kit.servo[self.tilt_ch].angle = 90 + self.tilt_sign * tilt
self.pan, self.tilt = pan, tilt
time.sleep(0.4) # let the servos arrive and stop jittering before any photo
return pan, tilt
# --------------------------------------------------------------------------- MCP server
mcp = MCPServer(
"robot",
instructions=(
"This server is a small physical robot on the user's desk. "
"Call look() whenever you need to see what is in front of it; the image is current. "
"If turn_head is available, aim first and then look."
),
)
camera: Camera | None = None
head: Head | None = None
@mcp.tool()
def look() -> Image:
"""Take a photograph with the robot's camera and return it. Use this to see the room,
the desk, the person, or anything the user asks you to look at."""
if camera is None:
raise RuntimeError("camera is not available on the robot")
t0 = time.monotonic()
data = camera.snapshot()
log.info("look(): %d bytes in %.2fs", len(data), time.monotonic() - t0)
return Image(data=data, format="jpeg")
def register_head_tools() -> None:
@mcp.tool()
def turn_head(pan: float = 0.0, tilt: float = 0.0) -> str:
"""Aim the robot's head (and so its camera). Angles are in degrees from straight ahead.
pan: positive turns to the robot's left, negative to its right.
tilt: positive looks up, negative looks down.
Values beyond the mechanical limits are clamped. Call look() afterwards to see."""
assert head is not None
p, t = head.move(pan, tilt)
return f"head now at pan {p:+.0f} degrees, tilt {t:+.0f} degrees"
@mcp.tool()
def head_position() -> str:
"""Report where the robot's head is currently pointing."""
assert head is not None
return f"pan {head.pan:+.0f} degrees, tilt {head.tilt:+.0f} degrees"
# --------------------------------------------------------------------------- auth wrapper
class BearerAuth:
"""Minimal ASGI middleware: reject any HTTP request without the right bearer token.
Lifespan events pass straight through so the MCP session manager starts properly."""
def __init__(self, app, token: str) -> None:
self.app = app
self.expected = f"Bearer {token}".encode()
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
supplied = dict(scope.get("headers") or []).get(b"authorization", b"")
if not hmac.compare_digest(supplied, self.expected):
await send({"type": "http.response.start", "status": 401,
"headers": [(b"content-type", b"text/plain"),
(b"www-authenticate", b"Bearer")]})
await send({"type": "http.response.body", "body": b"unauthorized\n"})
return
await self.app(scope, receive, send)
def build_app():
app = mcp.streamable_http_app(
host=BIND,
# Bearer auth already stops drive-by requests; DNS-rebinding checks would only
# get in the way of reaching the Pi by IP or by robot.local.
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
)
return BearerAuth(app, TOKEN)
def main() -> None:
global camera, head
if len(TOKEN) < 16:
sys.exit("ROBOT_MCP_TOKEN is missing or too short (generate one with: openssl rand -hex 32)")
try:
camera = Camera()
except Exception as exc: # keep serving servos even if the camera ribbon is loose
log.error("camera failed to start: %s", exc)
if env_flag("ROBOT_SERVOS"):
try:
head = Head()
register_head_tools()
except Exception as exc:
log.error("servos failed to start, turn_head disabled: %s", exc)
log.info("serving MCP on http://%s:%d/mcp", BIND, PORT)
uvicorn.run(build_app(), host=BIND, port=PORT, log_level="warning")
if __name__ == "__main__":
main()