From 31fb35ae9fd83e83f18e09c4c919e60aca71dff0 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Fri, 24 Jul 2026 16:29:36 +0200 Subject: [PATCH 1/3] fwmanager: Add BootMonitor capability and GpioBootMonitor adapter BootMonitor reports a device's boot liveness as BootStatus in the api leaf crate; GpioBootMonitor implements it over one HAL GpioPort input line. MonitorError meets the core::error::Error bound and keeps the concrete HAL error reachable through source(). new() rejects empty pin masks; boot latches are cleared by the reset path, never the observer. Closes: https://github.com/9elements/openprot/issues/6 Assisted-by: Claude:claude-opus-4-8 Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast --- services/fwmanager/api/BUILD.bazel | 1 + services/fwmanager/api/src/boot_monitor.rs | 175 +++++++++ services/fwmanager/api/src/lib.rs | 12 +- services/fwmanager/hal-adapters/BUILD.bazel | 1 + .../hal-adapters/src/gpio_boot_monitor.rs | 350 ++++++++++++++++++ services/fwmanager/hal-adapters/src/lib.rs | 5 +- 6 files changed, 540 insertions(+), 4 deletions(-) create mode 100644 services/fwmanager/api/src/boot_monitor.rs create mode 100644 services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs diff --git a/services/fwmanager/api/BUILD.bazel b/services/fwmanager/api/BUILD.bazel index 7cec74f9..49662ed9 100644 --- a/services/fwmanager/api/BUILD.bazel +++ b/services/fwmanager/api/BUILD.bazel @@ -7,6 +7,7 @@ rust_library( name = "fwmanager_api", srcs = [ "src/boot_control.rs", + "src/boot_monitor.rs", "src/config.rs", "src/lib.rs", ], diff --git a/services/fwmanager/api/src/boot_monitor.rs b/services/fwmanager/api/src/boot_monitor.rs new file mode 100644 index 00000000..931970e9 --- /dev/null +++ b/services/fwmanager/api/src/boot_monitor.rs @@ -0,0 +1,175 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Observation capability: read a managed device's boot liveness. + +/// Liveness of a managed device's boot: Boot Confirmation only. +/// +/// Reports only that a device came up, never what booted; confirming the +/// running image is the one the RoT staged is attestation, a separate step. +/// `Failed` is optional device-reported evidence and never the only failure +/// path, since a hung device reports nothing — a stuck boot is caught by the +/// orchestrator's timeout, not by this enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootStatus { + /// Held in reset by the orchestrator; not yet released. + InReset, + /// Released, but boot completion not yet observed. + Booting, + /// Boot completion observed. + Booted, + /// Device reported a boot failure. + Failed, +} + +/// Observation capability: read a managed device's boot liveness. +/// +/// Pull-shaped: where the underlying signal is an edge or pulse, the interrupt +/// latches a flag beneath this seam and `boot_status` only reads it. No +/// callback registration, which would require allocation and invert control +/// into device implementations. +/// +/// The reported status must describe the **current** boot cycle. An +/// implementation backed by a latched signal must guarantee the latch is +/// cleared whenever the device re-enters reset, so evidence left over from a +/// previous boot never reads as [`BootStatus::Booted`]. This trait +/// deliberately has no re-arm operation: clearing is the reset path's job +/// (hardware tying the latch to the device's reset line, or the same platform +/// code that drives `BootControl`), not the observer's — a monitor that could +/// clear its own evidence would let a read race a reset. +pub trait BootMonitor { + /// The error type reported by this device's boot monitor. + /// + /// Requires [`core::error::Error`] (in `core` since Rust 1.81) so the + /// orchestrator gets `Display` and a `source()` cause chain, not just a + /// `Debug` dump. Error categories stay implementation-defined — this + /// crate names no error vocabulary of its own; a consumer that knows the + /// concrete adapter can recover its details by downcasting the + /// `&dyn core::error::Error`. + type Error: core::error::Error; + + /// Returns the current liveness of the device. + /// + /// # Errors + /// + /// Returns an error if the underlying liveness signal cannot be read. + fn boot_status(&self) -> Result; +} + +#[cfg(test)] +#[allow(clippy::bool_assert_comparison)] +mod tests { + use super::*; + use core::cell::Cell; + + // ── Trait contract ────────────────────────────────────────────────── + // MockMonitor implements the trait without any HAL dependency. If a + // HAL-specific bound sneaks back onto `Error`, this module stops + // compiling. + + struct MockMonitor { + ready_after: usize, + polls: Cell, + fail: bool, + } + + #[derive(Debug, PartialEq, Eq)] + struct MockFault; + + impl core::fmt::Display for MockFault { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("mock monitor fault") + } + } + + impl core::error::Error for MockFault {} + + impl BootMonitor for MockMonitor { + type Error = MockFault; + + fn boot_status(&self) -> Result { + if self.fail { + return Err(MockFault); + } + let polls = self.polls.get(); + self.polls.set(polls + 1); + Ok(if polls >= self.ready_after { + BootStatus::Booted + } else { + BootStatus::Booting + }) + } + } + + // A device that is still coming up reads Booting, then Booted once it + // is up. + #[test] + fn status_progresses_from_booting_to_booted() { + let mon = MockMonitor { + ready_after: 1, + polls: Cell::new(0), + fail: false, + }; + + assert_eq!( + mon.boot_status().expect("boot_status failed"), + BootStatus::Booting + ); + assert_eq!( + mon.boot_status().expect("boot_status failed"), + BootStatus::Booted + ); + } + + #[test] + fn errors_surface_through_the_generic_seam() { + let mon = MockMonitor { + ready_after: 0, + polls: Cell::new(0), + fail: true, + }; + + let err = comes_up_within(&mon, 1).expect_err("expected the monitor fault"); + + // Display comes from the core::error::Error bound, not a Debug dump. + assert_eq!(err.to_string(), "mock monitor fault"); + } + + // ── The orchestrator's future shape ───────────────────────────────── + // Usage examples for the future orchestrator, not API guarantees; move + // these to the orchestrator crate once it exists. + + /// Poll a monitor up to `poll_budget` times. `Booting` is not a failure; + /// `Ok(false)` means the budget ran out before the device came up. + fn comes_up_within(mon: &M, poll_budget: usize) -> Result { + for _ in 0..poll_budget { + if mon.boot_status()? == BootStatus::Booted { + return Ok(true); + } + } + Ok(false) + } + + // A device that comes up within the poll budget reads Booted. + #[test] + fn a_device_that_comes_up_within_budget_is_booted() { + let mon = MockMonitor { + ready_after: 2, + polls: Cell::new(0), + fail: false, + }; + + assert_eq!(comes_up_within(&mon, 5).expect("boot_status failed"), true); + } + + #[test] + fn a_device_that_never_comes_up_is_a_timeout_not_an_error() { + let mon = MockMonitor { + ready_after: usize::MAX, + polls: Cell::new(0), + fail: false, + }; + + assert_eq!(comes_up_within(&mon, 3).expect("boot_status failed"), false); + } +} diff --git a/services/fwmanager/api/src/lib.rs b/services/fwmanager/api/src/lib.rs index 72d53ef0..f379d10b 100644 --- a/services/fwmanager/api/src/lib.rs +++ b/services/fwmanager/api/src/lib.rs @@ -7,18 +7,24 @@ //! single managed device's reset without knowing which controller line it //! maps to. //! +//! `BootMonitor` is the observation capability: the orchestrator reads a +//! device's boot liveness. +//! //! This crate is a dependency-free leaf: it holds the capability contracts //! and the schema for the per-board device table, and everything depends //! downward on it. Concrete adapters bind a trait to a signal source and //! live in their own crates, so naming a capability never drags in the stack -//! behind it — the HAL-backed `HalBootControl` is in -//! `fwmanager-hal-adapters`; other backends implement the same trait from -//! their own transport crate. Config values live in the board device tables +//! behind it — the HAL-backed `HalBootControl` and `GpioBootMonitor` are in +//! `fwmanager-hal-adapters`; other backends (for example an MCTP-ready +//! `BootMonitor`) implement the same traits from their own transport crate. +//! Config values live in the board device tables //! (`target//devices.rs`). #![cfg_attr(not(test), no_std)] mod boot_control; +mod boot_monitor; pub mod config; pub use boot_control::BootControl; +pub use boot_monitor::{BootMonitor, BootStatus}; diff --git a/services/fwmanager/hal-adapters/BUILD.bazel b/services/fwmanager/hal-adapters/BUILD.bazel index aff19e34..14e65f0d 100644 --- a/services/fwmanager/hal-adapters/BUILD.bazel +++ b/services/fwmanager/hal-adapters/BUILD.bazel @@ -6,6 +6,7 @@ load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") rust_library( name = "fwmanager_hal_adapters", srcs = [ + "src/gpio_boot_monitor.rs", "src/hal_boot_control.rs", "src/lib.rs", ], diff --git a/services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs b/services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs new file mode 100644 index 00000000..b4c22841 --- /dev/null +++ b/services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs @@ -0,0 +1,350 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! HAL-backed [`BootMonitor`]: read a device's boot-complete signal off a GPIO +//! input line. + +use fwmanager_api::{BootMonitor, BootStatus}; +use openprot_hal_blocking::gpio_port::{ + ActivePolarity, GpioError, GpioErrorKind, GpioPort, PinMask, +}; + +/// Adapts any HAL GPIO error into a [`core::error::Error`]. +/// +/// GPIO ports keep implementing the HAL `GpioError`/`kind()` pattern +/// unchanged; this wrapper supplies the `Display` and `core::error::Error` +/// machinery [`BootMonitor::Error`] requires, so no per-implementation work is +/// needed. The underlying category stays reachable via [`MonitorError::kind`], +/// and the concrete HAL error through the +/// [`source()`](core::error::Error::source) chain, downcast to +/// [`GpioCause`](GpioCause). +#[derive(Debug)] +pub struct MonitorError(GpioCause); + +/// The concrete HAL error underneath a [`MonitorError`], surfaced as its +/// [`source()`](core::error::Error::source). +/// +/// `GpioError` only guarantees `Debug`, so the HAL error cannot be the source +/// itself; this wrapper adds the `Display`/`Error` machinery. It renders the +/// full concrete error, not just its kind — the tail of a rendered error +/// chain carries the implementation's details. +#[derive(Debug)] +pub struct GpioCause(pub E); + +impl core::fmt::Display for MonitorError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "boot monitor error: {:?}", self.kind()) + } +} + +impl core::error::Error for MonitorError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + Some(&self.0) + } +} + +impl core::fmt::Display for GpioCause { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{:?}", self.0) + } +} + +impl core::error::Error for GpioCause {} + +impl GpioError for MonitorError { + fn kind(&self) -> GpioErrorKind { + let GpioCause(inner) = &self.0; + inner.kind() + } +} + +impl From for MonitorError { + fn from(err: E) -> Self { + Self(GpioCause(err)) + } +} + +/// Binds one input line of a HAL GPIO port to a managed device's boot-complete +/// signal. +/// +/// The input-side counterpart to [`HalBootControl`]: the `(port, ready_pin, +/// active)` binding is made once, in platform configuration, and the +/// orchestrator never learns which line belongs to which device. A signal that +/// latches high is read directly; an edge or pulse (for example a heartbeat) is +/// latched in hardware at bring-up so `read_input` still reflects it. +/// +/// Where a hardware latch is used, the platform must clear it whenever the +/// device re-enters reset (typically by wiring the latch's clear to the +/// device's reset line) — [`BootMonitor`] requires that evidence from a +/// previous boot never reads as [`BootStatus::Booted`], and this adapter only +/// reads the line, it cannot re-arm it. +/// +/// [`HalBootControl`]: crate::HalBootControl +pub struct GpioBootMonitor { + port: P, + ready_pin: P::Mask, + active: ActivePolarity, +} + +impl GpioBootMonitor

{ + /// Binds `port`'s line `ready_pin`, asserted per `active`, as a device's + /// boot-complete signal. + /// + /// `ready_pin` must name exactly one line — boot-complete is a single + /// signal. Masks naming several lines are not supported (the `PinMask` + /// contract offers no way to reject them here, so they are unspecified + /// behavior, not a feature). + /// + /// # Panics + /// + /// Panics if `ready_pin` is empty. An empty mask is vacuously contained + /// in every sample, so it would report every device `Booted` from the + /// moment reset is released — a misbinding that must fail at + /// construction, in platform bring-up, not stay silent in the field. + pub fn new(port: P, ready_pin: P::Mask, active: ActivePolarity) -> Self { + assert!( + !ready_pin.is_empty(), + "GpioBootMonitor bound to an empty pin mask" + ); + Self { + port, + ready_pin, + active, + } + } +} + +// `P::Error: 'static` because `source()` hands out `&(dyn Error + 'static)` +// referencing the wrapped HAL error. Error types are plain data; this costs +// no real implementation anything. +impl BootMonitor for GpioBootMonitor

+where + P::Error: 'static, +{ + type Error = MonitorError; + + /// # Errors + /// + /// Propagates any error returned by the port's `read_input`. + fn boot_status(&self) -> Result { + let high = self.port.read_input()?.contains(self.ready_pin); + let booted = match self.active { + ActivePolarity::ActiveHigh => high, + ActivePolarity::ActiveLow => !high, + }; + Ok(if booted { + BootStatus::Booted + } else { + BootStatus::Booting + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openprot_hal_blocking::gpio_port::GpioErrorType; + + // BMC boot-complete on line 4. Normally set in config.rs. + const BMC_READY: Mask = Mask(1 << 4); + + /// Bitmask over a single mock GPIO bank. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct Mask(u8); + + impl PinMask for Mask { + fn empty() -> Self { + Self(0) + } + + fn all() -> Self { + Self(u8::MAX) + } + + fn is_empty(&self) -> bool { + self.0 == 0 + } + + fn contains(&self, other: Self) -> bool { + self.0 & other.0 == other.0 + } + + fn union(&self, other: Self) -> Self { + Self(self.0 | other.0) + } + + fn intersection(&self, other: Self) -> Self { + Self(self.0 & other.0) + } + + fn toggle(&self) -> Self { + Self(!self.0) + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct MockError(GpioErrorKind); + + impl GpioError for MockError { + fn kind(&self) -> GpioErrorKind { + self.0 + } + } + + /// Mock HAL GPIO port: returns a fixed input sample, with opt-in read + /// failure injection. Output-side methods must never be called. + struct MockGpioPort { + input: Mask, + fail: Option, + } + + impl MockGpioPort { + fn with_input(input: Mask) -> Self { + Self { input, fail: None } + } + + fn failing(kind: GpioErrorKind) -> Self { + Self { + input: Mask::empty(), + fail: Some(kind), + } + } + } + + impl GpioErrorType for MockGpioPort { + type Error = MockError; + } + + impl GpioPort for MockGpioPort { + type Config = (); + type Mask = Mask; + + fn read_input(&self) -> Result { + match self.fail { + Some(kind) => Err(MockError(kind)), + None => Ok(self.input), + } + } + + fn configure(&mut self, _: Mask, _: ()) -> Result<(), MockError> { + panic!("BootMonitor must never configure pins"); + } + + fn set_reset(&mut self, _: Mask, _: Mask) -> Result<(), MockError> { + panic!("BootMonitor must never drive outputs"); + } + + fn toggle(&mut self, _: Mask) -> Result<(), MockError> { + panic!("BootMonitor must never drive outputs"); + } + } + + // A ready line asserted high reads as Booted under active-high polarity. + #[test] + fn booted_when_ready_line_asserted() { + let mon = GpioBootMonitor::new( + MockGpioPort::with_input(BMC_READY), + BMC_READY, + ActivePolarity::ActiveHigh, + ); + + assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booted); + } + + // A deasserted ready line is not a failure — the device is still Booting + // until the orchestrator's own timeout fires. + #[test] + fn booting_when_ready_line_deasserted() { + let mon = GpioBootMonitor::new( + MockGpioPort::with_input(Mask::empty()), + BMC_READY, + ActivePolarity::ActiveHigh, + ); + + assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booting); + } + + // Active-low inverts the sense: a low line is asserted, hence Booted. + #[test] + fn respects_active_low_polarity() { + let mon = GpioBootMonitor::new( + MockGpioPort::with_input(Mask::empty()), + BMC_READY, + ActivePolarity::ActiveLow, + ); + + assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booted); + } + + // Only the configured line matters: an unrelated high line leaves the + // device Booting. + #[test] + fn ignores_other_lines() { + let mon = GpioBootMonitor::new( + MockGpioPort::with_input(Mask(1 << 2)), + BMC_READY, + ActivePolarity::ActiveHigh, + ); + + assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booting); + } + + // An empty mask would read as Booted forever (it is vacuously contained + // in every sample); binding one must die at construction. + #[test] + #[should_panic(expected = "empty pin mask")] + fn empty_ready_mask_panics_at_construction() { + GpioBootMonitor::new( + MockGpioPort::with_input(Mask::empty()), + Mask::empty(), + ActivePolarity::ActiveHigh, + ); + } + + // A controller error surfaces through BootMonitor unchanged. + #[test] + fn port_error_propagates_through_boot_monitor() { + let mon = GpioBootMonitor::new( + MockGpioPort::failing(GpioErrorKind::HardwareFailure), + BMC_READY, + ActivePolarity::ActiveHigh, + ); + + let err = mon + .boot_status() + .expect_err("expected the port error to propagate"); + assert_eq!(err.kind(), GpioErrorKind::HardwareFailure); + } + + /// Compile-time fence: the adapter error must satisfy `core::error::Error` + /// (Display + source) *and* the HAL `GpioError` (with `kind()`). Fails to + /// compile if either is dropped. + fn _assert_error_contract() {} + + #[test] + fn monitor_error_satisfies_the_full_contract() { + _assert_error_contract::>(); + } + + #[test] + fn monitor_error_renders_a_human_readable_message() { + let err = MonitorError::from(MockError(GpioErrorKind::HardwareFailure)); + + assert_eq!(err.to_string(), "boot monitor error: HardwareFailure"); + } + + // The cause chain: source() carries the concrete HAL error with its full + // details (not just the kind), and downcasting recovers the typed error. + #[test] + fn source_exposes_the_concrete_hal_error() { + let err = MonitorError::from(MockError(GpioErrorKind::HardwareFailure)); + + let src = core::error::Error::source(&err).expect("source must carry the HAL error"); + assert_eq!(src.to_string(), "MockError(HardwareFailure)"); + + let cause = src + .downcast_ref::>() + .expect("source must downcast to the concrete cause"); + assert_eq!(cause.0, MockError(GpioErrorKind::HardwareFailure)); + } +} diff --git a/services/fwmanager/hal-adapters/src/lib.rs b/services/fwmanager/hal-adapters/src/lib.rs index da8ebf30..f4eb93c4 100644 --- a/services/fwmanager/hal-adapters/src/lib.rs +++ b/services/fwmanager/hal-adapters/src/lib.rs @@ -5,13 +5,16 @@ //! //! Each type here implements a capability trait from `fwmanager-api` against //! a HAL-blocking trait: [`HalBootControl`] drives `BootControl` over a -//! `ResetControl` line. Adapters live in this crate — not in the leaf +//! `ResetControl` line, and [`GpioBootMonitor`] reads `BootMonitor` off a +//! `GpioPort` input line. Adapters live in this crate — not in the leaf //! `fwmanager-api` — so that depending on a capability contract never pulls //! in the HAL. A transport-backed adapter belongs in its own crate depending //! on its own stack, by the same rule. #![cfg_attr(not(test), no_std)] +mod gpio_boot_monitor; mod hal_boot_control; +pub use gpio_boot_monitor::{GpioBootMonitor, GpioCause, MonitorError}; pub use hal_boot_control::{BootError, HalBootControl}; From 225bc13be767fbed01b778f6a397342544e0ed96 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Wed, 29 Jul 2026 22:52:53 +0200 Subject: [PATCH 2/3] fwmanager: Drop InReset; document the BootStatus subset contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InReset is orchestrator state, not something a monitor can observe — a device held in reset reads exactly like one still booting, so the variant could never be reported. Remove it from BootStatus. Document on boot_status() that any given backend may only ever produce a subset of BootStatus depending on the signals it can access (a single ready pin yields only Booting/Booted); this is a capability difference between backends, and consumers must still handle the full set. Addresses review feedback on PR #368. Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast --- services/fwmanager/api/src/boot_monitor.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/services/fwmanager/api/src/boot_monitor.rs b/services/fwmanager/api/src/boot_monitor.rs index 931970e9..96956880 100644 --- a/services/fwmanager/api/src/boot_monitor.rs +++ b/services/fwmanager/api/src/boot_monitor.rs @@ -12,8 +12,6 @@ /// orchestrator's timeout, not by this enum. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BootStatus { - /// Held in reset by the orchestrator; not yet released. - InReset, /// Released, but boot completion not yet observed. Booting, /// Boot completion observed. @@ -50,6 +48,13 @@ pub trait BootMonitor { /// Returns the current liveness of the device. /// + /// Any given monitor may only ever produce a *subset* of [`BootStatus`], + /// depending on the signals it can access: a single ready pin yields only + /// `Booting`/`Booted`, while a fault-channel backend can also report + /// `Failed`. This is a capability difference between backends, not an + /// incomplete implementation. Consumers must still handle the full set — + /// they cannot know statically which backend they hold. + /// /// # Errors /// /// Returns an error if the underlying liveness signal cannot be read. From 66f76f85e953315dd3a79291183ac112990b4c5c Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Wed, 29 Jul 2026 22:53:06 +0200 Subject: [PATCH 3/3] fwmanager: Borrow the GPIO port in GpioBootMonitor Owning the port swallowed a whole GPIO bank per monitor, making a second monitor on the same bank impossible. On the AST1060 SGPIOM a bank packs 32 unrelated lines, so distinct devices' ready signals routinely share one bank by construction. The monitor only reads (read_input takes &self), so hold &'a P instead: several monitors can now watch different lines of the same bank, with platform configuration keeping the bank alive for as long as its monitors. Also note in the adapter docs that a single ready line only ever yields the Booting/Booted subset of BootStatus. Addresses review feedback on PR #368. Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast --- .../hal-adapters/src/gpio_boot_monitor.rs | 63 +++++++++---------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs b/services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs index b4c22841..d8bf0e12 100644 --- a/services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs +++ b/services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs @@ -73,6 +73,17 @@ impl From for MonitorError { /// latches high is read directly; an edge or pulse (for example a heartbeat) is /// latched in hardware at bring-up so `read_input` still reflects it. /// +/// The monitor only reads, so it *borrows* the port rather than owning it: +/// several monitors can watch different lines of the same bank (on the +/// AST1060 SGPIOM a bank packs 32 unrelated lines, so distinct devices' +/// ready signals routinely share one). Platform configuration keeps the bank +/// alive for as long as its monitors. +/// +/// A single ready line can only ever answer "up yet?", so this backend +/// reports the [`BootStatus::Booting`]/[`BootStatus::Booted`] subset — see +/// [`BootMonitor::boot_status`] on why that is a capability difference, not +/// an incomplete implementation. +/// /// Where a hardware latch is used, the platform must clear it whenever the /// device re-enters reset (typically by wiring the latch's clear to the /// device's reset line) — [`BootMonitor`] requires that evidence from a @@ -80,13 +91,13 @@ impl From for MonitorError { /// reads the line, it cannot re-arm it. /// /// [`HalBootControl`]: crate::HalBootControl -pub struct GpioBootMonitor { - port: P, +pub struct GpioBootMonitor<'a, P: GpioPort> { + port: &'a P, ready_pin: P::Mask, active: ActivePolarity, } -impl GpioBootMonitor

{ +impl<'a, P: GpioPort> GpioBootMonitor<'a, P> { /// Binds `port`'s line `ready_pin`, asserted per `active`, as a device's /// boot-complete signal. /// @@ -101,7 +112,7 @@ impl GpioBootMonitor

{ /// in every sample, so it would report every device `Booted` from the /// moment reset is released — a misbinding that must fail at /// construction, in platform bring-up, not stay silent in the field. - pub fn new(port: P, ready_pin: P::Mask, active: ActivePolarity) -> Self { + pub fn new(port: &'a P, ready_pin: P::Mask, active: ActivePolarity) -> Self { assert!( !ready_pin.is_empty(), "GpioBootMonitor bound to an empty pin mask" @@ -117,7 +128,7 @@ impl GpioBootMonitor

{ // `P::Error: 'static` because `source()` hands out `&(dyn Error + 'static)` // referencing the wrapped HAL error. Error types are plain data; this costs // no real implementation anything. -impl BootMonitor for GpioBootMonitor

+impl BootMonitor for GpioBootMonitor<'_, P> where P::Error: 'static, { @@ -242,11 +253,8 @@ mod tests { // A ready line asserted high reads as Booted under active-high polarity. #[test] fn booted_when_ready_line_asserted() { - let mon = GpioBootMonitor::new( - MockGpioPort::with_input(BMC_READY), - BMC_READY, - ActivePolarity::ActiveHigh, - ); + let port = MockGpioPort::with_input(BMC_READY); + let mon = GpioBootMonitor::new(&port, BMC_READY, ActivePolarity::ActiveHigh); assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booted); } @@ -255,11 +263,8 @@ mod tests { // until the orchestrator's own timeout fires. #[test] fn booting_when_ready_line_deasserted() { - let mon = GpioBootMonitor::new( - MockGpioPort::with_input(Mask::empty()), - BMC_READY, - ActivePolarity::ActiveHigh, - ); + let port = MockGpioPort::with_input(Mask::empty()); + let mon = GpioBootMonitor::new(&port, BMC_READY, ActivePolarity::ActiveHigh); assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booting); } @@ -267,11 +272,8 @@ mod tests { // Active-low inverts the sense: a low line is asserted, hence Booted. #[test] fn respects_active_low_polarity() { - let mon = GpioBootMonitor::new( - MockGpioPort::with_input(Mask::empty()), - BMC_READY, - ActivePolarity::ActiveLow, - ); + let port = MockGpioPort::with_input(Mask::empty()); + let mon = GpioBootMonitor::new(&port, BMC_READY, ActivePolarity::ActiveLow); assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booted); } @@ -280,11 +282,8 @@ mod tests { // device Booting. #[test] fn ignores_other_lines() { - let mon = GpioBootMonitor::new( - MockGpioPort::with_input(Mask(1 << 2)), - BMC_READY, - ActivePolarity::ActiveHigh, - ); + let port = MockGpioPort::with_input(Mask(1 << 2)); + let mon = GpioBootMonitor::new(&port, BMC_READY, ActivePolarity::ActiveHigh); assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booting); } @@ -294,21 +293,15 @@ mod tests { #[test] #[should_panic(expected = "empty pin mask")] fn empty_ready_mask_panics_at_construction() { - GpioBootMonitor::new( - MockGpioPort::with_input(Mask::empty()), - Mask::empty(), - ActivePolarity::ActiveHigh, - ); + let port = MockGpioPort::with_input(Mask::empty()); + GpioBootMonitor::new(&port, Mask::empty(), ActivePolarity::ActiveHigh); } // A controller error surfaces through BootMonitor unchanged. #[test] fn port_error_propagates_through_boot_monitor() { - let mon = GpioBootMonitor::new( - MockGpioPort::failing(GpioErrorKind::HardwareFailure), - BMC_READY, - ActivePolarity::ActiveHigh, - ); + let port = MockGpioPort::failing(GpioErrorKind::HardwareFailure); + let mon = GpioBootMonitor::new(&port, BMC_READY, ActivePolarity::ActiveHigh); let err = mon .boot_status()