fwmanager: Add BootControl trait and HAL adapter

Introduce the Boot Orchestrator's actuation capability: the BootControl
trait (hold_in_reset / release) and HalBootControl, which binds one HAL
ResetControl line to a managed device. Includes a host unit test verifying
that holding a device in reset asserts exactly its configured line.

Includes tests that release deasserts the device's configured line and that a
controller error surfaces through BootControl unchanged. Extend the fake
reset controller with opt-in failure injection to drive the error case.

Closes: https://github.com/9elements/openprot/issues/2

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Christina Quast <christina.quast@9elements.com>
diff --git a/services/fwmanager/api/BUILD.bazel b/services/fwmanager/api/BUILD.bazel
new file mode 100644
index 0000000..d78ebb6
--- /dev/null
+++ b/services/fwmanager/api/BUILD.bazel
@@ -0,0 +1,23 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test")
+
+rust_library(
+    name = "fwmanager_api",
+    srcs = [
+        "src/boot_control.rs",
+        "src/lib.rs",
+    ],
+    edition = "2024",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//hal/blocking",
+    ],
+)
+
+# Host tests: build on the host platform, no kernel/QEMU.
+rust_test(
+    name = "fwmanager_api_test",
+    crate = ":fwmanager_api",
+)
diff --git a/services/fwmanager/api/src/boot_control.rs b/services/fwmanager/api/src/boot_control.rs
new file mode 100644
index 0000000..6fce01d
--- /dev/null
+++ b/services/fwmanager/api/src/boot_control.rs
@@ -0,0 +1,206 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Actuation capability: hold a managed device in reset and release it.
+
+use openprot_hal_blocking::system_control::ResetControl;
+
+/// Actuation capability: hold a managed device in reset and release it.
+///
+/// Stateless pass-through by design — sequencing discipline (hold before
+/// release, release only after verification) belongs to the orchestrator
+/// flows, where it is observable behavior.
+///
+/// # How the orchestrator uses it
+///
+/// During verified release the orchestrator parks a device in
+/// reset, verifies its active slot while nothing is running, then releases
+/// it to boot the image it just checked:
+///
+/// ```ignore
+/// // `dev` is this device's BootControl, obtained from the registry.
+/// fn verified_release<D: BootControl>(dev: &mut D) -> Result<(), D::Error> {
+///     dev.hold_in_reset()?;        // freeze the device; its flash is now safe to inspect
+///     verify_active_slot()?;       // re-hash + signature check (a separate capability)
+///     dev.release()?;              // run the just-verified image
+///     Ok(())
+/// }
+/// ```
+///
+/// In a trial boot the same hold/release pair brackets a
+/// watchdog-bounded window; the new slot is committed only if a good boot is
+/// observed, otherwise the device falls back to the previous slot:
+///
+/// ```ignore
+/// dev.hold_in_reset()?;
+/// store.set_trial(new_slot)?;      // tentative boot selection — not yet committed
+/// dev.release()?;                  // boot the trial image
+/// match monitor.await_boot(window)? {
+///     Booted           => store.commit(new_slot)?,   // observed good => make it active
+///     Failed | Timeout => { /* nothing committed; previous slot still active */ }
+/// }
+/// ```
+pub trait BootControl {
+    /// The error type reported by this device's boot control.
+    type Error: core::fmt::Debug;
+
+    /// Holds the device in reset.
+    fn hold_in_reset(&mut self) -> Result<(), Self::Error>;
+
+    /// Releases the device from reset.
+    fn release(&mut self) -> Result<(), Self::Error>;
+}
+
+/// Binds one reset line of a HAL reset controller to one managed device.
+pub struct HalBootControl<C: ResetControl> {
+    controller: C,
+    reset_id: C::ResetId,
+}
+
+impl<C: ResetControl> HalBootControl<C> {
+    /// Creates the binding of `controller`'s line `reset_id` to a device.
+    pub fn new(controller: C, reset_id: C::ResetId) -> Self {
+        Self {
+            controller,
+            reset_id,
+        }
+    }
+
+    /// Read access to the underlying controller.
+    pub fn controller(&self) -> &C {
+        &self.controller
+    }
+}
+
+impl<C: ResetControl> BootControl for HalBootControl<C> {
+    type Error = C::Error;
+
+    fn hold_in_reset(&mut self) -> Result<(), Self::Error> {
+        self.controller.reset_assert(&self.reset_id)
+    }
+
+    fn release(&mut self) -> Result<(), Self::Error> {
+        self.controller.reset_deassert(&self.reset_id)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use core::time::Duration;
+    use openprot_hal_blocking::system_control::{Error, ErrorKind, ErrorType};
+
+    // Normally set in config.rs
+    const BMC_LINE: u8 = 7;
+
+    #[derive(Debug, PartialEq, Eq, Clone, Copy)]
+    enum Call {
+        Assert(u8),
+        Deassert(u8),
+    }
+
+    #[derive(Debug, PartialEq, Eq, Clone, Copy)]
+    struct MockError(ErrorKind);
+
+    impl Error for MockError {
+        fn kind(&self) -> ErrorKind {
+            self.0
+        }
+    }
+
+    /// Mock HAL reset controller: records every call it receives.
+    struct MockResetController {
+        calls: Vec<Call>,
+        fail: Option<ErrorKind>,
+    }
+
+    impl MockResetController {
+        fn new() -> Self {
+            Self {
+                calls: Vec::new(),
+                fail: None,
+            }
+        }
+
+        fn failing(kind: ErrorKind) -> Self {
+            Self {
+                calls: Vec::new(),
+                fail: Some(kind),
+            }
+        }
+
+        fn calls(&self) -> &[Call] {
+            &self.calls
+        }
+    }
+
+    impl ErrorType for MockResetController {
+        type Error = MockError;
+    }
+
+    impl ResetControl for MockResetController {
+        type ResetId = u8; // Reset line is GPIO here. Real driver should use Enum
+
+        fn reset_assert(&mut self, reset_id: &u8) -> Result<(), MockError> {
+            if let Some(kind) = self.fail {
+                return Err(MockError(kind));
+            }
+            self.calls.push(Call::Assert(*reset_id));
+            Ok(())
+        }
+
+        fn reset_deassert(&mut self, reset_id: &u8) -> Result<(), MockError> {
+            if let Some(kind) = self.fail {
+                return Err(MockError(kind));
+            }
+            self.calls.push(Call::Deassert(*reset_id));
+            Ok(())
+        }
+
+        fn reset_pulse(&mut self, _: &u8, _: Duration) -> Result<(), MockError> {
+            panic!(
+                "BootControl must never pulse: hold and release are distinct orchestrator steps"
+            );
+        }
+
+        fn reset_is_asserted(&self, _: &u8) -> Result<bool, MockError> {
+            panic!("BootControl does not query line state");
+        }
+    }
+
+    // `hold_in_reset()` must assert exactly the configured line (BMC = 7)
+    // and nothing else.
+    #[test]
+    fn holding_a_device_in_reset_asserts_its_configured_line() {
+        let mut bmc = HalBootControl::new(MockResetController::new(), BMC_LINE);
+
+        bmc.hold_in_reset().expect("hold_in_reset failed");
+
+        assert_eq!(bmc.controller().calls(), &[Call::Assert(BMC_LINE)]);
+    }
+
+    #[test]
+    fn holding_a_device_in_reset_deasserts_its_configured_line() {
+        let mut bmc = HalBootControl::new(MockResetController::new(), BMC_LINE);
+
+        bmc.hold_in_reset().expect("hold_in_reset failed");
+        bmc.release().expect("release failed");
+
+        assert_eq!(
+            bmc.controller().calls(),
+            &[Call::Assert(BMC_LINE), Call::Deassert(BMC_LINE)]
+        );
+    }
+
+    #[test]
+    fn controller_error_propagates_through_boot_control() {
+        let mut bmc = HalBootControl::new(
+            MockResetController::failing(ErrorKind::InvalidResetId),
+            BMC_LINE,
+        );
+        let err = bmc
+            .hold_in_reset()
+            .expect_err("expected the controller error to propagate");
+        assert_eq!(err.kind(), ErrorKind::InvalidResetId);
+    }
+}
diff --git a/services/fwmanager/api/src/lib.rs b/services/fwmanager/api/src/lib.rs
new file mode 100644
index 0000000..8ab488a
--- /dev/null
+++ b/services/fwmanager/api/src/lib.rs
@@ -0,0 +1,15 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Device-facing capability traits for the Boot Orchestrator.
+//!
+//! `BootControl` is the actuation capability: the orchestrator drives a
+//! single managed device's reset without knowing which controller line it
+//! maps to. The binding of a HAL reset controller line to a device happens
+//! once, in platform configuration, via [`HalBootControl`].
+
+#![cfg_attr(not(test), no_std)]
+
+mod boot_control;
+
+pub use boot_control::{BootControl, HalBootControl};