|
| 1 | +//! Pure access-control decision state machine. |
| 2 | +//! |
| 3 | +//! This is a mechanical extraction of the body of `access_task` in |
| 4 | +//! `src/main.rs`. All side effects (door pulse, reader feedback, event |
| 5 | +//! buffer push, sync request, watchdog feed) are returned as an `Effect` |
| 6 | +//! enum instead of being performed directly, and all time is taken as an |
| 7 | +//! explicit `u64` (milliseconds since boot) parameter rather than read from |
| 8 | +//! `embassy_time::Instant::now()`. The cache is taken as a slice rather than |
| 9 | +//! a `Mutex` lock. |
| 10 | +//! |
| 11 | +//! This makes the firmware's authorization decisions deterministically |
| 12 | +//! testable from host tests under `cargo test --features sim`, without |
| 13 | +//! changing observable runtime behavior in the firmware. |
| 14 | +//! |
| 15 | +//! The firmware adapter in `main.rs` is responsible for: |
| 16 | +//! - selecting on `WIEGAND_CHANNEL` / `SYNC_COMPLETE` / `WATCHDOG_FEED` and |
| 17 | +//! mapping each to the corresponding `Input` variant, |
| 18 | +//! - calling `embassy_time::Instant::now().as_millis()` and passing it in, |
| 19 | +//! - locking the `FOBS` mutex and passing a slice, |
| 20 | +//! - dispatching each returned `Effect` to the corresponding `Signal` or |
| 21 | +//! the global `EVENT_BUFFER`. |
| 22 | +
|
| 23 | +use heapless::Vec as HVec; |
| 24 | + |
| 25 | +use crate::events::AccessEvent; |
| 26 | + |
| 27 | +/// Window during which a sync completion can retroactively grant a |
| 28 | +/// previously-denied credential. Matches `main.rs` (10 seconds). |
| 29 | +pub const RECHECK_DEADLINE_MS: u64 = 10_000; |
| 30 | + |
| 31 | +/// Number of effects emitted by a single `step()` call. The current |
| 32 | +/// implementation emits at most 3 (Record + Feedback + OpenDoor on grant; |
| 33 | +/// Record + Feedback + RequestSync on denial); 4 leaves headroom. |
| 34 | +pub const MAX_EFFECTS_PER_STEP: usize = 4; |
| 35 | + |
| 36 | +/// A credential read off the Wiegand reader. Already decoded into both the |
| 37 | +/// H10301 fob form and the byte-swapped NFC UID form so the core does not |
| 38 | +/// need to know about Wiegand framing. |
| 39 | +#[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 40 | +pub struct CardRead { |
| 41 | + pub fob: u32, |
| 42 | + pub nfc: u32, |
| 43 | +} |
| 44 | + |
| 45 | +/// Inputs that drive the access-control state machine. |
| 46 | +#[derive(Clone, Copy, Debug)] |
| 47 | +pub enum Input { |
| 48 | + /// A new credential was decoded by the Wiegand reader. |
| 49 | + Card(CardRead), |
| 50 | + /// The sync task finished a round-trip with the Conway server (success |
| 51 | + /// or failure). The fob cache slice passed to `step()` reflects any |
| 52 | + /// updates that resulted. |
| 53 | + SyncComplete, |
| 54 | + /// The 10-second tick that proves `access_task` is responsive; mapped |
| 55 | + /// to a hardware watchdog feed by the firmware adapter. |
| 56 | + WatchdogFeed, |
| 57 | +} |
| 58 | + |
| 59 | +/// The decision a `Card` step produced (used to drive reader LED/beeper). |
| 60 | +#[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 61 | +pub enum Outcome { |
| 62 | + Granted, |
| 63 | + Denied, |
| 64 | +} |
| 65 | + |
| 66 | +/// Side effects emitted by `step()`. The firmware adapter is the sole |
| 67 | +/// consumer; tests inspect them directly. |
| 68 | +#[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 69 | +pub enum Effect { |
| 70 | + /// Pulse the door relay (200ms in firmware). |
| 71 | + OpenDoor, |
| 72 | + /// Drive the reader LED/beeper. |
| 73 | + Feedback(Outcome), |
| 74 | + /// Push an entry into the event buffer for later upload. |
| 75 | + Record(AccessEvent), |
| 76 | + /// Ask the sync task to attempt an on-demand round-trip with Conway. |
| 77 | + RequestSync, |
| 78 | + /// Feed the hardware watchdog. |
| 79 | + FeedWatchdog, |
| 80 | +} |
| 81 | + |
| 82 | +/// Pure decision state for the access controller. Mirrors the locals |
| 83 | +/// inside `access_task`. |
| 84 | +#[derive(Clone, Debug)] |
| 85 | +pub struct AccessCore { |
| 86 | + /// `(fob, nfc, deadline_ms)` — a previously denied credential whose |
| 87 | + /// authorization will be re-checked when the next sync completes. |
| 88 | + pending_recheck: Option<(u32, u32, u64)>, |
| 89 | + /// Card reads received before this timestamp are silently dropped. |
| 90 | + backoff_until: u64, |
| 91 | + /// Number of consecutive denials. Drives exponential backoff (1, 2, 4, |
| 92 | + /// then 8s thereafter). Reset to 0 on any grant. |
| 93 | + failed_attempts: u8, |
| 94 | +} |
| 95 | + |
| 96 | +impl Default for AccessCore { |
| 97 | + fn default() -> Self { |
| 98 | + Self::new() |
| 99 | + } |
| 100 | +} |
| 101 | + |
| 102 | +impl AccessCore { |
| 103 | + pub const fn new() -> Self { |
| 104 | + Self { |
| 105 | + pending_recheck: None, |
| 106 | + backoff_until: 0, |
| 107 | + failed_attempts: 0, |
| 108 | + } |
| 109 | + } |
| 110 | + |
| 111 | + /// Read-only access to the pending recheck window, for tests. |
| 112 | + pub fn pending_recheck(&self) -> Option<(u32, u32, u64)> { |
| 113 | + self.pending_recheck |
| 114 | + } |
| 115 | + |
| 116 | + /// Read-only access to the backoff deadline, for tests. |
| 117 | + pub fn backoff_until(&self) -> u64 { |
| 118 | + self.backoff_until |
| 119 | + } |
| 120 | + |
| 121 | + /// Read-only access to the consecutive-denial counter, for tests. |
| 122 | + pub fn failed_attempts(&self) -> u8 { |
| 123 | + self.failed_attempts |
| 124 | + } |
| 125 | + |
| 126 | + /// Step the state machine. |
| 127 | + /// |
| 128 | + /// - `now_ms`: virtual wall clock (milliseconds). |
| 129 | + /// - `fobs`: snapshot of authorized credential IDs at this instant. |
| 130 | + /// - `input`: the event being delivered. |
| 131 | + /// |
| 132 | + /// Returns the ordered list of effects the firmware adapter must apply. |
| 133 | + pub fn step( |
| 134 | + &mut self, |
| 135 | + now_ms: u64, |
| 136 | + fobs: &[u32], |
| 137 | + input: Input, |
| 138 | + ) -> HVec<Effect, MAX_EFFECTS_PER_STEP> { |
| 139 | + let mut out: HVec<Effect, MAX_EFFECTS_PER_STEP> = HVec::new(); |
| 140 | + |
| 141 | + match input { |
| 142 | + Input::WatchdogFeed => { |
| 143 | + let _ = out.push(Effect::FeedWatchdog); |
| 144 | + } |
| 145 | + |
| 146 | + Input::SyncComplete => { |
| 147 | + if let Some((fob, nfc, deadline)) = self.pending_recheck.take() { |
| 148 | + if now_ms > deadline { |
| 149 | + // Recheck expired; do nothing. |
| 150 | + return out; |
| 151 | + } |
| 152 | + let allowed = |
| 153 | + fobs.iter().any(|&f| f == fob) || fobs.iter().any(|&f| f == nfc); |
| 154 | + if allowed { |
| 155 | + // Mirror main.rs:336-340 — failed_attempts is reset |
| 156 | + // to 0 here, but `backoff_until` is intentionally |
| 157 | + // *not* cleared. A grant after sync therefore still |
| 158 | + // honors any outstanding backoff window for future |
| 159 | + // card reads. Tests pin this behavior. |
| 160 | + self.failed_attempts = 0; |
| 161 | + let _ = out.push(Effect::Feedback(Outcome::Granted)); |
| 162 | + let _ = out.push(Effect::OpenDoor); |
| 163 | + } else { |
| 164 | + self.failed_attempts = self.failed_attempts.saturating_add(1); |
| 165 | + let delay_ms = (1u64 << self.failed_attempts.min(3)) * 1000; |
| 166 | + self.backoff_until = now_ms + delay_ms; |
| 167 | + let _ = out.push(Effect::Feedback(Outcome::Denied)); |
| 168 | + } |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + Input::Card(read) => { |
| 173 | + if now_ms < self.backoff_until { |
| 174 | + // Card ignored during backoff window; no effects. |
| 175 | + return out; |
| 176 | + } |
| 177 | + |
| 178 | + let fob = read.fob; |
| 179 | + let nfc = read.nfc; |
| 180 | + |
| 181 | + let fob_ok = fobs.iter().any(|&f| f == fob); |
| 182 | + let nfc_ok = !fob_ok && fobs.iter().any(|&f| f == nfc); |
| 183 | + let allowed = fob_ok || nfc_ok; |
| 184 | + |
| 185 | + if allowed { |
| 186 | + self.failed_attempts = 0; |
| 187 | + let credential = if fob_ok { fob } else { nfc }; |
| 188 | + let _ = out.push(Effect::Record(AccessEvent { |
| 189 | + fob: credential, |
| 190 | + allowed: true, |
| 191 | + })); |
| 192 | + let _ = out.push(Effect::Feedback(Outcome::Granted)); |
| 193 | + let _ = out.push(Effect::OpenDoor); |
| 194 | + } else { |
| 195 | + let _ = out.push(Effect::Record(AccessEvent { fob, allowed: false })); |
| 196 | + let _ = out.push(Effect::Feedback(Outcome::Denied)); |
| 197 | + let _ = out.push(Effect::RequestSync); |
| 198 | + self.pending_recheck = Some((fob, nfc, now_ms + RECHECK_DEADLINE_MS)); |
| 199 | + } |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | + out |
| 204 | + } |
| 205 | +} |
0 commit comments