fwmanager: encode boot-progress checkpointing in the device table

Liveness can be more than one timeout: a device may pass several boot
checkpoints, and the signal may be polled or queried rather than
device-pushed. Replace boot_timeout with an ordered checkpoint list,
each pairing a BootSignal (boot-complete GPIO, heartbeat, MCTP ready,
version query) with its own window: last checkpoint reached means
booted, an expired window fails the boot. BootSignal is generic over
the id its boot monitor reads a boot-complete line by, as DeviceConfig
is over its reset signal. The mock nic exercises the multi-checkpoint
path.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
Refs: 9elements/openprot#1
diff --git a/services/fwmanager/api/src/config.rs b/services/fwmanager/api/src/config.rs
index 7149465..0a946cd 100644
--- a/services/fwmanager/api/src/config.rs
+++ b/services/fwmanager/api/src/config.rs
@@ -19,6 +19,40 @@
     LivenessAndAttestation,
 }
 
+/// How the orchestrator observes a device's boot-progress signal.
+///
+/// Generic over the id type `G` the board's boot monitor uses to read a
+/// boot-complete line, for the same reason `DeviceConfig` is generic over
+/// its reset signal: signal ids are board-specific.
+///
+/// Intentionally exhaustive (not `#[non_exhaustive]`): adding a signal
+/// kind is a breaking change, so every consumer that dispatches on it is
+/// forced to handle the new kind explicitly.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BootSignal<G> {
+    /// The device raises a boot-complete GPIO line.
+    GpioBootComplete(G),
+    /// The device sends a heartbeat message.
+    Heartbeat,
+    /// The device's MCTP endpoint answers as ready.
+    MctpReady,
+    /// The device answers a firmware version query.
+    VersionQuery,
+}
+
+/// One boot-progress checkpoint: a signal the orchestrator waits for, and
+/// how long it waits.
+#[derive(Debug, Clone, Copy)]
+pub struct BootCheckpoint<G> {
+    /// Names the checkpoint in timeout reports.
+    pub name: &'static str,
+    pub signal: BootSignal<G>,
+    /// How long the orchestrator waits for `signal` before it declares the
+    /// checkpoint — and the device's boot — failed. Expiry is the
+    /// orchestrator's own judgment; hung devices report nothing.
+    pub window: core::time::Duration,
+}
+
 /// One managed downstream device, as declared by the board config.
 ///
 /// Generic over the board's reset signal type `R`, which must match the
@@ -30,26 +64,39 @@
 /// construct this struct by literal, which the attribute would forbid.
 /// Adding a field is a breaking change that updates every board table.
 #[derive(Debug, Clone, Copy)]
-pub struct DeviceConfig<R> {
+pub struct DeviceConfig<R, G: 'static> {
     pub name: &'static str,
     /// Reset signal id, passed to HalBootControl::new.
     pub reset_signal: R,
-    /// How long the orchestrator waits for this device to report Booted
-    /// before it declares a timeout.
-    pub boot_timeout: core::time::Duration,
+    /// Boot-progress checkpoints, in the order the device passes them.
+    /// The device counts as booted when the last one is reached; a
+    /// checkpoint whose window expires fails the boot.
+    pub checkpoints: &'static [BootCheckpoint<G>],
     pub commit_policy: CommitPolicy,
 }
 
 /// Checks a device table. Board configs call this in a const context so a
 /// bad table fails the build.
-pub const fn validate<R>(devices: &[DeviceConfig<R>]) {
+pub const fn validate<R, G>(devices: &[DeviceConfig<R, G>]) {
     let mut i = 0;
     while i < devices.len() {
         assert!(!devices[i].name.is_empty(), "device name must not be empty");
         assert!(
-            !devices[i].boot_timeout.is_zero(),
-            "boot timeout must not be zero"
+            !devices[i].checkpoints.is_empty(),
+            "device must declare at least one boot checkpoint"
         );
+        let mut c = 0;
+        while c < devices[i].checkpoints.len() {
+            assert!(
+                !devices[i].checkpoints[c].name.is_empty(),
+                "checkpoint name must not be empty"
+            );
+            assert!(
+                !devices[i].checkpoints[c].window.is_zero(),
+                "checkpoint window must not be zero"
+            );
+            c += 1;
+        }
         i += 1;
     }
 }
diff --git a/target/mock/devices.rs b/target/mock/devices.rs
index 5f9fa17..ea5b7df 100644
--- a/target/mock/devices.rs
+++ b/target/mock/devices.rs
@@ -7,26 +7,45 @@
 
 #![no_std]
 
-use fwmanager_api::config::{CommitPolicy, DeviceConfig};
+use core::time::Duration;
+
+use fwmanager_api::config::{BootCheckpoint, BootSignal, CommitPolicy, DeviceConfig};
 
 /// Declaration order is the boot order: the orchestrator releases devices
 /// top to bottom, one at a time.
 ///
-/// The mock board's reset controller addresses lines by plain index, so its
-/// reset id type is `u8`.
-pub const MANAGED_DEVICES: &[DeviceConfig<u8>] = &[
+/// The mock board's reset controller and boot monitor both address
+/// signals by plain index, so both id types are `u8`.
+pub const MANAGED_DEVICES: &[DeviceConfig<u8, u8>] = &[
     // Direct-flash SPI device (BMC archetype): the eRoT fronts its flash.
+    // Single checkpoint: it raises a boot-complete GPIO.
     DeviceConfig {
         name: "bmc",
         reset_signal: 7,
-        boot_timeout: core::time::Duration::from_secs(90),
+        checkpoints: &[BootCheckpoint {
+            name: "boot-complete",
+            signal: BootSignal::GpioBootComplete(12),
+            window: Duration::from_secs(90),
+        }],
         commit_policy: CommitPolicy::Liveness,
     },
-    // PLDM device (NIC archetype): self-updating, SPDM-capable.
+    // PLDM device (NIC archetype): self-updating, SPDM-capable. Two
+    // checkpoints, exercising the multi-checkpoint path.
     DeviceConfig {
         name: "nic",
         reset_signal: 3,
-        boot_timeout: core::time::Duration::from_secs(30),
+        checkpoints: &[
+            BootCheckpoint {
+                name: "mctp-ready",
+                signal: BootSignal::MctpReady,
+                window: Duration::from_secs(20),
+            },
+            BootCheckpoint {
+                name: "heartbeat",
+                signal: BootSignal::Heartbeat,
+                window: Duration::from_secs(10),
+            },
+        ],
         commit_policy: CommitPolicy::LivenessAndAttestation,
     },
 ];