#!/usr/bin/env bash set -euo pipefail MEDIAMTX_VERSION="1.15.3" NEOLINK_VERSION="0.6.2" MEDIAMTX_BASE_URL="https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}" NEOLINK_BASE_URL="https://github.com/QuantumEntangledAndy/neolink/releases/download/v${NEOLINK_VERSION}" MEDIAMTX_BIN="/usr/local/bin/mediamtx" NEOLINK_BIN="/usr/local/bin/neolink" CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav" ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh" MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service" MULTIROVER_SERVICE="/etc/systemd/system/multirover.service" SNAPSHOT_DIR="/var/lib/rover-snapshots" REPLAY_SEGMENT_DIR="/var/lib/replay-segments" KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules" BLUETOOTH_OVERRIDE_DIR="/etc/systemd/system/bluetooth.service.d" BLUETOOTH_OVERRIDE="$BLUETOOTH_OVERRIDE_DIR/20-multirover-balance-board.conf" if [[ $EUID -ne 0 ]]; then echo "This installer must be run with sudo/root." >&2 exit 1 fi if [[ -z "${SUDO_USER:-}" || "${SUDO_USER}" == "root" ]]; then echo "Run this script via 'sudo' from the normal user that owns the repo." >&2 exit 1 fi TARGET_USER="$SUDO_USER" SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SERVER_DIR="$SCRIPT_DIR" BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native" BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker" CONFIG_PATH="$SERVER_DIR/config.yaml" ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh" CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py" install_google_tts_assets() { local asset_dir="/opt/roverd/googletts" local voice_dir="${asset_dir}/en-us-x-multi-r30" local dist_url="https://storage.googleapis.com/chromeos-localmirror/distfiles/googletts-26.5.tar.xz" local lib_member="" local arch_name arch_name=$(uname -m) # The PTZ camera is not a rover, so Google speech must be synthesized on the # server before neolink sends a WAV to the camera. These assets are the same # offline ChromeOS local TTS assets that rover installers already use; keeping # the layout identical lets the server helper and rover daemon share loader # assumptions. if [[ -f "${asset_dir}/libchrometts.so" && -f "${voice_dir}/pipeline.pb" ]]; then echo " Google TTS assets already installed" return fi case "$arch_name" in x86_64|amd64) lib_member="libchrometts_x86_64.so" ;; aarch64) lib_member="libchrometts_arm64.so" ;; armv7l|armv6l) lib_member="libchrometts_armv7.so" ;; *) echo "Unsupported Google TTS architecture: $arch_name" >&2 exit 1 ;; esac echo " Installing Google TTS assets -> $asset_dir" curl -L -o "$tmpdir/googletts-26.5.tar.xz" "$dist_url" tar -xf "$tmpdir/googletts-26.5.tar.xz" -C "$tmpdir" en-us-x-multi.zvoice "$lib_member" install -d -o root -g root -m 0755 "$asset_dir" install -o root -g root -m 0644 "$tmpdir/$lib_member" "${asset_dir}/libchrometts.so" rm -rf "$voice_dir" install -d -o root -g root -m 0755 "$voice_dir" # The .zvoice member is a zip archive inside the outer tar.xz. Match the # rover installers here; trying to untar it fails after the large download. unzip -q "$tmpdir/en-us-x-multi.zvoice" -d "$voice_dir" chown -R root:root "$asset_dir" find "$asset_dir" -type d -exec chmod 0755 {} + find "$asset_dir" -type f -exec chmod 0644 {} + } verify_google_tts_helper() { local smoke_wav="$tmpdir/chromegtts-smoke.wav" echo " Verifying Chrome Google TTS helper" # libchrometts is a native ChromeOS library. Rendering one tiny WAV during # install catches missing shared-library dependencies, bad asset extraction, # and helper path mistakes before multirover.service starts accepting PTZ TTS # requests that would fail later in logs. if ! "$CHROMEGTTS_WAV_BIN" \ --text "test" \ --voice tpf \ --pitch 1 \ --speed 1 \ --output "$smoke_wav"; then echo "Chrome Google TTS helper smoke render failed." >&2 return 1 fi if [[ ! -s "$smoke_wav" ]]; then echo "Chrome Google TTS helper did not create a WAV file." >&2 return 1 fi } echo "[1/6] Installing dependencies..." # The Kinect tooling uses a native libfreenect worker/probe rather than a # Python wrapper. Install both runtime and development headers here so a fresh # Fedora server can build the worker locally and then run it under the same # normal user that owns the rover service. dnf install -y \ nodejs \ npm \ curl \ tar \ unzip \ xz \ gcc-c++ \ make \ pkgconf-pkg-config \ flite \ espeak \ python3 \ libcxx \ libcxxabi \ gstreamer1 \ gstreamer1-plugins-base \ gstreamer1-plugins-good \ gstreamer1-plugins-bad-free \ gstreamer1-rtsp-server \ libfreenect \ libfreenect-devel \ libusb1-devel \ bluez \ wiiuse \ wiiuse-devel \ libcap >/dev/null NODE_BIN="$(command -v node)" echo " Installing Kinect udev rule -> $KINECT_UDEV_RULE" cat > "$KINECT_UDEV_RULE" <<'EOF' # Xbox 360 / Kinect v1 exposes motor, audio, and camera as separate Microsoft # USB devices. Fedora/OpenNI PrimeSense rules can leave the camera node as # root:primesense 0660, which makes libfreenect fail with LIBUSB_ERROR_ACCESS # when the rover server runs as the normal service user. This late 99-* rule is # intentionally broad for local rover hardware: every Microsoft Kinect sibling # gets world read/write access so the native libfreenect worker can open the # camera without running the whole server as root. SUBSYSTEM=="usb", ATTR{idVendor}=="045e", MODE="0666", GROUP="root", TAG+="uaccess" EOF chmod 644 "$KINECT_UDEV_RULE" udevadm control --reload-rules if [[ ! -f "$CHROMEGTTS_WAV_TEMPLATE" ]]; then echo "Chrome Google TTS WAV helper missing at $CHROMEGTTS_WAV_TEMPLATE" >&2 exit 1 fi echo " Installing Chrome Google TTS WAV helper -> $CHROMEGTTS_WAV_BIN" install -m 0755 "$CHROMEGTTS_WAV_TEMPLATE" "$CHROMEGTTS_WAV_BIN" echo "[2/6] Installing Node production deps..." runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR' && npm install --production" if [[ -f "$SERVER_DIR/src/services/kinectService/native/Makefile" ]]; then echo " Building native Kinect worker..." runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR/src/services/kinectService/native' && make" fi if [[ -f "$BALANCE_BOARD_NATIVE_DIR/Makefile" ]]; then echo " Building native Balance Board bridge..." runuser -u "$TARGET_USER" -- bash -c "cd '$BALANCE_BOARD_NATIVE_DIR' && make" if [[ ! -x "$BALANCE_BOARD_WORKER" ]]; then echo "Balance Board worker build did not create $BALANCE_BOARD_WORKER" >&2 exit 1 fi # Only this small audited bridge needs the management socket used for the # board's raw six-byte pairing PIN and the two reserved HID PSMs used by # front-button reconnects. Never grant either capability to node or the full # multirover service executable. setcap cap_net_admin,cap_net_bind_service+ep "$BALANCE_BOARD_WORKER" fi if [[ ! -f "$CONFIG_PATH" ]]; then cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH" chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH" echo "Copied config.example.yaml to config.yaml; edit it before exposing the service." fi # Bluetoothd remains responsible for discovery and the one-time bond, but its # generic input plugin otherwise reserves control PSM 0x11 and interrupt PSM # 0x13 before the Balance Board worker can listen for the board's front-button # reconnect. This dedicated rover server gives those two HID listeners to the # worker; every other BlueZ profile is left enabled. Clearing ExecStart is # required by systemd before replacing the vendor unit's command in a drop-in. install -d -m 0755 "$BLUETOOTH_OVERRIDE_DIR" cat > "$BLUETOOTH_OVERRIDE" <<'EOF' [Service] ExecStart= ExecStart=/usr/libexec/bluetooth/bluetoothd --noplugin=input EOF chmod 0644 "$BLUETOOTH_OVERRIDE" systemctl daemon-reload systemctl enable bluetooth.service systemctl restart bluetooth.service tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT arch=$(uname -m) case "$arch" in x86_64|amd64) mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz" neolink_pkg="neolink_linux_x86_64_ubuntu.zip" ;; aarch64) mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_arm64.tar.gz" neolink_pkg="neolink_linux_arm64.zip" ;; armv7l) mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_armv7.tar.gz" neolink_pkg="neolink_linux_armhf.zip" ;; *) echo "Unsupported architecture: $arch" >&2 exit 1 ;; esac echo "[3/6] Installing mediaMTX ${MEDIAMTX_VERSION}..." curl -L "$MEDIAMTX_BASE_URL/$mediamtx_pkg" -o "$tmpdir/mediamtx.tgz" tar -xzf "$tmpdir/mediamtx.tgz" -C "$tmpdir" mediamtx install -m 0755 "$tmpdir/mediamtx" "$MEDIAMTX_BIN" echo " Installing neolink ${NEOLINK_VERSION} -> $NEOLINK_BIN" curl -L "$NEOLINK_BASE_URL/$neolink_pkg" -o "$tmpdir/neolink.zip" unzip -q "$tmpdir/neolink.zip" -d "$tmpdir/neolink" neolink_extracted=$(find "$tmpdir/neolink" -type f -name neolink -perm /111 | head -n 1) if [[ -z "$neolink_extracted" ]]; then neolink_extracted=$(find "$tmpdir/neolink" -type f -name neolink | head -n 1) fi if [[ -z "$neolink_extracted" ]]; then echo "neolink binary missing from $neolink_pkg" >&2 exit 1 fi install -m 0755 "$neolink_extracted" "$NEOLINK_BIN" install_google_tts_assets if ! verify_google_tts_helper; then echo " Reinstalling Google TTS assets after failed verification" rm -rf /opt/roverd/googletts install_google_tts_assets verify_google_tts_helper fi if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2 exit 1 fi echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN" install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN" # Validate the new source of truth before disabling a working legacy service. The validator # performs the same build and YAML serialization as server startup without opening listeners # or leaving a process behind. runuser -u "$TARGET_USER" -- env \ SERVER_CONFIG="$CONFIG_PATH" \ ROVER_SNAPSHOT_WRITER_BIN="$ROVER_SNAPSHOT_WRITER_BIN" \ "$NODE_BIN" "$SERVER_DIR/scripts/validateMediaMtxConfig.js" # MediaMTX used to run as its own systemd service with a hand-maintained config in # /etc/mediamtx. Stop it before multirover starts the new child process, otherwise the two # processes race for every media listener. Both commands are deliberately idempotent so an # already-migrated server and a first-time installation follow the same path. echo " Disabling legacy mediamtx.service" systemctl disable --now mediamtx.service 2>/dev/null || true rm -f "$MEDIAMTX_SERVICE" rm -f /etc/mediamtx/mediamtx.yml echo "[4/6] Writing systemd units..." mkdir -p "$SNAPSHOT_DIR" chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR" mkdir -p "$REPLAY_SEGMENT_DIR" chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR" cat > "$MULTIROVER_SERVICE" <