The update hook installs the NPU, like every other setup script

`hooks/preinstall` runs `setup-gstreamer.sh` and `setup-rkaiq.sh` from the release
on every update, never fatally, with their reports in the update log. This branch
added a third setup script, packaged it, and never wired it in — so a release that
ships a duck detector also shipped a manual SSH step before the model in it could
run, on every board, for ever.

The hook's own comment already says why that is wrong: "This is what the split
between provisioning and updating is for. `provision.sh` installs the stack on a
new board; every board provisioned before that existed ... is fixed by an ordinary
update instead of by somebody remembering a command."

`install_npu` is `install_gstreamer` with different words. A board with no NPU
still walks, still streams and still sees — on its CPU, via the `.onnx`, which is
why `DetectParams::models()` is a list rather than a choice.

The header's claim that this is "deliberately not run by the update hook" goes
with it. The reason it gave was that the runtime is a download, and
`setup-gstreamer.sh` downloads Rockchip's MPP and RGA debs from the same hook.

Three things found while wiring it up, each from a board rather than from reading:

- **The overlay was not pinned to its script.** `setup-npu.sh` compiles
  rk3568-npu-enable.dts from beside itself, exactly as `setup-rkaiq.sh` compiles
  its LD_PRELOAD shim — and only the shim had a test saying it must be packaged.
  Without the .dts the hook installs the runtime, cannot find the overlay, and
  leaves the node disabled: an update that succeeded, a detector on the CPU for
  ever, and one warning in a log. `the_npu_overlay_travels_with_its_script` now
  fails the build instead.

- **`rknn_init` named two causes and not the one every stock board hits.** A
  disabled device-tree node is not "a model built for another platform" nor "a
  driver older than the runtime", and the runtime's own line for it — "failed to
  open rknpu module, need to insmod rknpu dirver!" — sends people after a module
  that is built in. It reads the node now and says which of the three it is.

- **`mediad.service` named the wrong device.** `/dev/dri/renderD129` is panfrost
  on a Radxa Zero 3, and renderD128 is rockchip-drm, both held before the NPU
  binds at all. The `render` group is still required; the number never was.

Assisted-by: Claude:claude-opus-5
This commit is contained in:
Pierre Rouanet 2026-08-27 14:45:21 +02:00
parent 6006231c3c
commit 15afc9baf1
6 changed files with 115 additions and 12 deletions

View File

@ -30,11 +30,16 @@ carrying the scale into the decoder and getting it wrong once, quietly.
The driver is the gate: it is part of the vendor kernel, mainline has none, and nothing in userspace
can work around its absence.
**An ordinary `robotctl update` does this.** `hooks/preinstall` runs the release's own copy beside
`setup-gstreamer.sh` and `setup-rkaiq.sh`, never fatally, with its report in the update log — so a
board provisioned before the NPU existed is fixed by an update rather than by somebody remembering
a command. Running it by hand is a retry:
```bash
sudo sh /opt/robot/daemon/current/scripts/setup-npu.sh
```
**Expect it to ask for a reboot the first time.** Armbian ships `npu@fde40000` as
**Expect the first update carrying this to ask for a reboot.** Armbian ships `npu@fde40000` as
`status = "disabled"` on every Radxa Zero 3, so a stock board has the hardware, the kernel and the
driver and still no NPU. The script writes the overlay that fixes it and says so; the node binds on
the next boot. `--no-enable-node` installs only the runtime, and `dmesg | grep rknpu` is how you

View File

@ -148,6 +148,35 @@ pub struct Model {
// does not support.
unsafe impl Send for Model {}
/// Why `rknn_init` failed, in the order the causes actually occur.
///
/// **The device tree first, because it is the one every board starts in.** Armbian ships
/// `npu@fde40000` as `status = "disabled"` on the Radxa Zero 3, so a robot that nobody has run
/// `setup-npu.sh` on has the hardware, the kernel, the driver and the runtime, and still no NPU —
/// and the runtime's own log line for it is "failed to open rknpu module, need to insmod rknpu
/// dirver!", which sends people looking for a module that is built in.
///
/// The two causes this used to name are real and are still here, but they are what is left once
/// there is a device to talk to at all. Naming them first cost a bring-up session.
fn why_init_failed() -> String {
match std::fs::read("/proc/device-tree/npu@fde40000/status") {
Ok(bytes) if bytes.starts_with(b"disabled") => {
"The NPU is DISABLED in this board's device \
tree, which is how Armbian ships the Radxa Zero 3 so the driver never bound and \
there is nothing to open. Enable it and reboot: sudo sh \
/opt/robot/daemon/current/scripts/setup-npu.sh"
.to_owned()
}
// No node at all: not an RK3566, or a kernel whose device tree does not describe one.
Err(_) => "There is no npu@fde40000 in this board's device tree at all — a kernel that is \
not the Armbian vendor one is the usual cause, and mainline has no rknpu driver."
.to_owned(),
_ => "The node is enabled, so this is the model or the driver: a model built for another \
platform, or an NPU driver older than the runtime, are the two usual causes."
.to_owned(),
}
}
impl Model {
/// Load `librknnrt.so`, then the model, and ask the model what shape it wants.
pub fn open(path: &Path) -> Result<Self> {
@ -208,9 +237,9 @@ impl Model {
);
if code != 0 || context.is_null() {
bail!(
"rknn_init failed ({code}) on {}. A model built for another platform, or an \
NPU driver older than the runtime, are the two usual causes.",
path.display()
"rknn_init failed ({code}) on {}. {}",
path.display(),
why_init_failed()
);
}

View File

@ -176,6 +176,37 @@ install_rkaiq() {
fi
}
# The NPU: the runtime `duck-detect` dlopens, and the device-tree node it needs to reach one.
#
# Same terms as the two above, and here for the same reason they are: the script owns the pinned
# runtime tag, the overlay, and the driver report, and a robot should not need somebody to remember
# a command before the model in this very release can run. A board provisioned before the NPU
# existed is fixed by an ordinary update.
#
# **It can ask for a reboot, and that is why this says so rather than doing it.** Armbian ships
# `npu@fde40000` disabled on every Radxa Zero 3, so the first update carrying this enables the node
# and the NPU binds on the next boot. Until then the detector runs on the CPU via the `.onnx`, which
# is the whole reason `DetectParams::models()` is a list rather than a choice.
#
# Never fatal, for the reason in the header: a board with no NPU still walks, still streams, and
# still sees — just on its CPU.
install_npu() {
script=scripts/setup-npu.sh
if [ ! -f "$script" ]; then
say "no ${script} in this release; skipping the NPU"
return 0
fi
say "checking the NPU runtime"
if sh "$script"; then
say "NPU runtime ready"
else
say "the NPU did not finish setting up — the duck detector will run on the CPU, which is
slower and warmer but works, and everything else is unaffected. To retry:
sudo sh /opt/robot/daemon/current/scripts/setup-npu.sh"
fi
}
have="$(installed_onnx)"
if at_least "$have" "$ONNX_FLOOR"; then
@ -196,3 +227,4 @@ fi
install_gstreamer
install_rkaiq
install_npu

View File

@ -57,9 +57,11 @@ ExecStart=/opt/robot/daemon/current/bin/mediad
# padd's and tofd's sockets are handed to the `robot` group by those daemons themselves, so the
# pad tap and the depth stream are reachable through the same membership.
#
# **`render` is the NPU.** The rknpu driver registers as a DRM device — `/dev/dri/renderD129` on
# this board, `root:render` mode 660 — so the duck detector cannot open the NPU without this group,
# and the failure is an `rknn_init` that returns a number rather than anything about permissions.
# **`render` is the NPU.** The rknpu driver registers as a DRM device, `root:render` mode 660, so
# the duck detector cannot open the NPU without this group — and the failure is an `rknn_init` that
# returns a number rather than anything about permissions. Which `/dev/dri/renderD*` it takes is not
# fixed and is not worth writing down: on a Radxa Zero 3 the display and Mali already hold 128 and
# 129 before the NPU binds at all, so a number recorded here would name somebody else's device.
# Membership costs nothing on a board whose NPU is disabled; it is the difference between working
# and not on one where it is enabled.
User=mediad

View File

@ -26,11 +26,13 @@
#
# Idempotent: the runtime is only downloaded when it is missing or a different version is asked for.
#
# **Deliberately not run by provisioning or the update hook**, and for one reason only: the runtime
# is a download. Provisioning that fetches from github.com is provisioning that fails in a room with
# no route to it, and a robot that will not come up because a blob it does not yet need was
# unreachable is a bad trade. The device-tree half has no such excuse — it is offline, idempotent
# and needed by every board — which is why it happens here by default rather than on request.
# **Run by `hooks/preinstall` on every update**, beside `setup-gstreamer.sh` and `setup-rkaiq.sh`
# and on the same terms: never fatally, with its report in the update log. That is what the split
# between provisioning and updating is for — a board provisioned before the NPU existed is fixed by
# an ordinary update rather than by somebody remembering a command, and a release that ships a model
# should not also ship a manual step before the model can run.
#
# Running it by hand is then a retry, not the mechanism.
set -e
# Keep in step with `[workspace.metadata.rknpu]` in Cargo.toml — a test asserts they agree.

View File

@ -1303,6 +1303,39 @@ mod tests {
}
}
/// `setup-npu.sh` compiles a device-tree overlay from a .dts beside it, so the .dts has to be
/// packaged too.
///
/// The same shape as `the_rkaiq_shim_travels_with_its_script`, and not covered by
/// `every_script_the_hooks_run_is_packaged` for the same reason: the overlay is not a script
/// anything runs, it is a source the script compiles. Packaged without it, the hook installs
/// the runtime, cannot find the .dts, and leaves the NPU node disabled — so the detector runs
/// on the CPU for ever and the log line saying why is one warning in an update that succeeded.
#[test]
fn the_npu_overlay_travels_with_its_script() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("xtask/ has a parent");
const OVERLAY: &str = "deploy/overlays/rk3568-npu-enable.dts";
assert!(root.join(OVERLAY).exists(), "{OVERLAY} is missing");
let script = std::fs::read_to_string(root.join("scripts/setup-npu.sh")).unwrap();
assert!(
script.contains("rk3568-npu-enable.dts"),
"setup-npu.sh must name the overlay source it compiles"
);
for workflow in PACKAGING_SITES {
let text = std::fs::read_to_string(root.join(workflow))
.unwrap_or_else(|e| panic!("{workflow}: {e}"));
assert!(
text.contains(&format!("={OVERLAY}")),
"{workflow} packages setup-npu.sh but not {OVERLAY}, which it cannot run without"
);
}
}
/// `setup-npu.sh` pins the NPU runtime, and Cargo.toml pins it too.
///
/// Third instance of the same trap — after ONNX Runtime and the GStreamer plugins — and the