Pierre Rouanet 9cfd24e49f Build the robot's GStreamer plugins natively, from pinned sources
Two plugins the micro duck robot needs and no archive provides:
`libgstrockchipmpp.so` for hardware H.264 through Rockchip MPP, and
`libgstrswebrtc.so`/`libgstrsrtp.so` for webrtcsink. Debian ships no Rockchip
encoder in any suite, Radxa's prebuilt plugin is decode-only (measured on a
Zero 3W: mppvideodec and mppjpegdec, nothing else — 1.14.4 predates the
encoders), and gstreamer1.0-plugins-rs exists in no Debian suite at all.
`webrtcbin` is deliberately absent: Debian has it.

Native arm64 in a debian:trixie container, which is the point. The daemon
cross-compiles with cargo-zigbuild and its one C dependency is already the
documented cost of that; GStreamer would be a far larger second one, and both
routes — x86 multiarch or a sysroot with a meson cross file — link against an
approximation of the target rather than the target. Here nothing is
approximated.

Public because arm64 runners are free on public repositories, and more
importantly because a release asset is fetched by the updater's preinstall
hook, which runs with a cleared environment and no token. That is the same
arrangement the daemon already relies on for ONNX Runtime.

Pins are commits or tags, never branches, in one file both the build and the
release manifest read — a plugin whose version nobody can name is what makes a
media bug unreproducible, which is exactly what the third-party debs we
rejected could not answer. gst-plugins-rs is pinned at 0.15.3 rather than the
0.14.5 the reachy_mini SDK documents: 0.14.5 is the floor that matters, since
the tags below it miss a webrtcsink deadlock fix between remote description and
ICE handling, and both series declare a GStreamer v1_22 floor against a robot
running 1.26.2.

Two guards written from reading the trees rather than hoping.
`gst/rockchipmpp/meson.build` ends in `if not mpp_dep.found() → subdir_done()`,
so a missing librockchip-mpp-dev makes meson skip the plugin and *succeed*;
pkg-config is checked first and the .so checked for after. And the Radxa debs
are installed as one closure, because dpkg -i resolves nothing for direct
downloads — learning that one package at a time cost three rounds on a board.

Building also drops rkximage and kmssrc, the X11 and KMS sinks in the same
tree, which a headless robot has no use for and which are why the prebuilt deb
depends on libx11-6.

Assisted-by: Claude:claude-opus-5[1m] shellcheck
2026-08-24 16:02:47 +02:00

246 lines
11 KiB
Bash
Executable File

#!/bin/sh
# Build the aarch64 GStreamer plugins the micro duck robot needs, from the pins in `pins.env`.
#
# Runs natively on aarch64 — in CI that is a `debian:trixie` container on an arm64 runner, which
# is what makes the output link against exactly the library versions the robot has rather than
# Ubuntu's or a cross sysroot's approximation of them. It also runs unchanged on a board, which
# is only useful for debugging: an RK3566 compiles the Rust half slowly enough that nobody should
# wait for it.
#
# sudo sh scripts/build.sh both plugins
# sudo sh scripts/build.sh rockchip just the MPP encoders
# sudo sh scripts/build.sh webrtc just webrtcsink/webrtcsrc
#
# Output lands in `dist/`: the .so files, SHA256SUMS, and MANIFEST recording which upstream commit
# each one came from. The manifest is not bookkeeping — it is the difference between a media bug
# somebody can reproduce and one they cannot, and it is what the third-party debs we rejected did
# not have.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIST="${ROOT}/dist"
# Debian's own plugin directory for this arch, which is where the .so wants to *end up* on a
# robot's GST_PLUGIN_PATH. Built here into a prefix we own so nothing is installed system-wide by
# a build.
STAGE="${ROOT}/.stage"
# shellcheck disable=SC1091 # sibling file, in this repository.
. "${ROOT}/pins.env"
say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
WANT="${1:-both}"
case "$WANT" in
both|rockchip|webrtc) ;;
*) die "unknown target: ${WANT} (both, rockchip, webrtc)" ;;
esac
check_environment() {
[ "$(id -u)" = 0 ] || die "run as root — it installs build dependencies"
arch="$(uname -m)"
[ "$arch" = aarch64 ] \
|| die "this builds natively for aarch64 and this is ${arch}.
There is no cross-build here on purpose: linking against the robot's own Debian trixie
libraries is the reason CI uses an arm64 runner in a debian:trixie container."
command -v apt-get >/dev/null 2>&1 || die "expects a Debian userland"
}
apt_install() {
missing=""
for pkg in "$@"; do
dpkg -s "$pkg" >/dev/null 2>&1 || missing="$missing $pkg"
done
[ -n "$missing" ] || return 0
say "installing:$missing"
apt-get update -qq
# shellcheck disable=SC2086 # word-splitting the package list is the point
apt-get install -y -qq --no-install-recommends $missing \
|| die "apt failed installing:$missing"
}
# Rockchip's MPP and RGA, from Radxa's pool, runtime and headers together.
#
# `dpkg -i` resolves nothing here — these are direct downloads, not a configured apt source — so
# the full closure is named and installed in one call. Learning that one package at a time cost
# three rounds on a board: `rockchip-mpp-demos` needs `librockchip-vpu0` at an exact version, and
# the GStreamer plugin needs `librga2`.
install_rockchip_userspace() {
dpkg -s librockchip-mpp-dev >/dev/null 2>&1 && dpkg -s librga-dev >/dev/null 2>&1 && return 0
tmp="$(mktemp -d)"
# shellcheck disable=SC2064 # expand $tmp now, deliberately
trap "rm -rf '$tmp'" EXIT INT TERM
say "fetching Rockchip MPP ${MPP_VERSION} and RGA ${RGA_VERSION} from Radxa's pool"
for path in \
"m/mpp/librockchip-mpp1_${MPP_VERSION}_arm64.deb" \
"m/mpp/librockchip-vpu0_${MPP_VERSION}_arm64.deb" \
"m/mpp/librockchip-mpp-dev_${MPP_VERSION}_arm64.deb" \
"libr/librga/librga2_${RGA_VERSION}_arm64.deb" \
"libr/librga/librga-dev_${RGA_VERSION}_arm64.deb"
do
curl -fsSL -o "${tmp}/$(basename "$path")" "${RADXA_POOL}/${path}" \
|| die "cannot download ${RADXA_POOL}/${path}"
done
dpkg -i "${tmp}"/*.deb || die "dpkg -i failed on the Rockchip debs"
rm -rf "$tmp"
trap - EXIT INT TERM
}
build_rockchip() {
apt_install meson ninja-build build-essential pkg-config git curl ca-certificates \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libdrm-dev libglib2.0-dev
install_rockchip_userspace
# Verified, because meson's answer to a missing MPP is not an error:
# `gst/rockchipmpp/meson.build` ends in `if not mpp_dep.found() → subdir_done()`, so the whole
# plugin is *silently skipped* and the build succeeds having produced nothing.
for mod in rockchip_mpp librga; do
pkg-config --exists "$mod" \
|| die "pkg-config cannot find ${mod}. meson skips the plugin silently without it,
so this refuses here instead of shipping an empty release."
done
src="$(mktemp -d)"
say "gstreamer-rockchip @ ${GST_ROCKCHIP_REF}"
git clone -q --branch "$GST_ROCKCHIP_BRANCH" "$GST_ROCKCHIP_REPO" "${src}/s" \
|| die "cannot clone ${GST_ROCKCHIP_REPO}"
# Reset to the pin: `--depth 1` alone would take whatever the branch tip is today, which is
# the thing a pin exists to prevent.
git -C "${src}/s" checkout -q "$GST_ROCKCHIP_REF" \
|| die "${GST_ROCKCHIP_REF} is not on ${GST_ROCKCHIP_BRANCH}"
# `rkximage` and `kmssrc` are the X11 and KMS *sinks* in the same tree. A headless robot has
# no use for either, and they are why the prebuilt Radxa deb depends on libx11-6. Dropping
# them is the concrete thing building ourselves buys, beyond provenance.
say "configuring (rockchipmpp only; X11 and KMS sinks disabled)"
meson setup "${src}/b" "${src}/s" \
--prefix /usr --libdir lib --buildtype release \
-Drockchipmpp=enabled -Drga=enabled \
-Drkximage=disabled -Dkmssrc=disabled -Dvpxalphadec=disabled \
>"${src}/meson.log" 2>&1 || {
tail -40 "${src}/meson.log" >&2
die "meson setup failed; tail of its log above. The tree declares
meson_version >= 0.47 and was written against a far older meson, so a syntax rejection is the
failure to expect here. Installed: $(meson --version 2>/dev/null || echo unknown)."
}
ninja -C "${src}/b" >"${src}/ninja.log" 2>&1 || {
tail -40 "${src}/ninja.log" >&2
die "ninja failed; tail of its log above"
}
so="$(find "${src}/b" -name 'libgstrockchipmpp.so' -type f | head -1)"
[ -n "$so" ] || die "no libgstrockchipmpp.so was produced.
That is what a skipped subdir looks like rather than a compile error."
install -d "$DIST"
install -m 0644 "$so" "${DIST}/libgstrockchipmpp.so"
strip --strip-unneeded "${DIST}/libgstrockchipmpp.so"
printf 'libgstrockchipmpp.so %s %s\n' "$GST_ROCKCHIP_REPO" "$GST_ROCKCHIP_REF" \
>> "${DIST}/MANIFEST"
rm -rf "$src"
}
build_webrtc() {
# `libgstreamer-plugins-bad1.0-dev` is the load-bearing one: it carries
# `gstreamer-webrtc-1.0.pc` and `gstreamer-sdp-1.0.pc`, which is what the crate pkg-configs
# against. Everything else is what a Rust cdylib needs to link.
apt_install build-essential pkg-config git curl ca-certificates \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
libgstreamer-plugins-bad1.0-dev libssl-dev libglib2.0-dev
# rustup rather than Debian's rustc. gst-plugins-rs tracks a recent toolchain and a distro
# rustc that is a few months behind fails on an edition or a lint, months after anybody
# remembers this choice was made.
if ! command -v cargo >/dev/null 2>&1; then
say "installing a Rust toolchain"
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain stable >/dev/null \
|| die "rustup install failed"
fi
# shellcheck disable=SC1091 # written by rustup, just above.
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
command -v cargo-cbuild >/dev/null 2>&1 || {
say "installing cargo-c"
cargo install cargo-c --locked >/dev/null || die "cargo install cargo-c failed"
}
src="$(mktemp -d)"
say "gst-plugins-rs @ ${GST_PLUGINS_RS_REF}"
git clone -q --depth 1 --branch "$GST_PLUGINS_RS_REF" "$GST_PLUGINS_RS_REPO" "${src}/s" \
|| die "cannot clone ${GST_PLUGINS_RS_REPO} at ${GST_PLUGINS_RS_REF}"
install -d "$DIST" "$STAGE"
# Two plugins, not one: Pollen's desktop app ships `libgstrswebrtc.so` *and*
# `libgstrsrtp.so`, and the same stack wants both.
for crate in gst-plugin-webrtc gst-plugin-rsrtp; do
say "cargo cinstall ${crate}"
( cd "${src}/s" && cargo cinstall -p "$crate" --prefix "$STAGE" --libdir lib --release ) \
>"${src}/${crate}.log" 2>&1 || {
tail -40 "${src}/${crate}.log" >&2
die "${crate} failed to build; tail of its log above"
}
done
found=0
for so in "${STAGE}"/lib/gstreamer-1.0/*.so; do
[ -e "$so" ] || continue
found=1
base="$(basename "$so")"
install -m 0644 "$so" "${DIST}/${base}"
strip --strip-unneeded "${DIST}/${base}"
printf '%s %s %s\n' "$base" "$GST_PLUGINS_RS_REPO" "$GST_PLUGINS_RS_REF" \
>> "${DIST}/MANIFEST"
done
[ "$found" = 1 ] || die "cargo cinstall produced no plugin under ${STAGE}/lib/gstreamer-1.0"
rm -rf "$src" "$STAGE"
}
# What was built, and enough to verify it independently.
finish() {
( cd "$DIST" && rm -f SHA256SUMS && sha256sum ./*.so > SHA256SUMS )
printf '\n'
say "dist/"
for f in "${DIST}"/*.so; do
printf ' %-28s %8s bytes\n' "$(basename "$f")" "$(stat -c '%s' "$f")"
done
printf '\n'
say "provenance"
sed 's/^/ /' "${DIST}/MANIFEST"
printf '\n'
say "elements"
# Loaded from dist/ exactly as a robot will load them, so this is the real question rather
# than a proxy. `mpph264enc` may be missing here even when built: registration probes MPP, and
# a container without /dev/mpp_service — or a robot whose node is still 0600 root:root —
# cannot answer. Absence in CI is expected; absence on a board with the udev rule is not.
for plug in rockchipmpp rswebrtc rsrtp; do
GST_PLUGIN_PATH="$DIST" gst-inspect-1.0 "$plug" 2>/dev/null \
| sed -n 's/^ \([a-z0-9]*\): / \1 /p' || true
done
[ -e /dev/mpp_service ] || warn "no /dev/mpp_service here, so the MPP encoders cannot register
in this environment. That is normal in CI: the .so still contains them, and a robot with the
udev rule will see them. Verify there, not here."
}
main() {
check_environment
rm -rf "$DIST" && install -d "$DIST"
: > "${DIST}/MANIFEST"
case "$WANT" in
rockchip) build_rockchip ;;
webrtc) build_webrtc ;;
both) build_rockchip; build_webrtc ;;
esac
apt_install gstreamer1.0-tools
finish
}
# Called on the last line so a truncated download defines functions and then does nothing, rather
# than running half a build.
main "$@"