Skip to content

Commit 6512707

Browse files
committed
access-controller: add deterministic simulation tests for business logic
Extracts the Wiegand frame decoders and the access-task decision state machine into a pure `access_controller` library so they can be exercised on the host without ESP32 hardware. `access_task` becomes a thin adapter that calls `AccessCore::step()` and dispatches the returned `Effect`s to Embassy primitives; observable firmware behavior is unchanged. Tests combine handwritten scenarios with proptest property tests (seeded ChaCha PRNG for determinism). Run with: RUSTUP_TOOLCHAIN=stable cargo test \ --no-default-features --features sim \ --target x86_64-unknown-linux-gnu Properties proven: no OpenDoor without a current fob-cache hit (A1/A2/A3); silent backoff window (A4); 10-second recheck deadline never grants past expiry (A5); every granted card swipe is accompanied by an allowed:true audit record; and Wiegand frame-parity / fob-format invariants (W1-W4).
1 parent a581e66 commit 6512707

10 files changed

Lines changed: 1098 additions & 161 deletions

File tree

access-controller/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,35 @@ Copy `network.env.example` to `network.env` and edit with your values:
2424
source network.env && cargo run --release
2525
```
2626

27+
## Deterministic simulation tests
28+
29+
The crate's business-logic core (Wiegand frame decoders + the
30+
authorization state machine that drives `access_task`) is extracted into
31+
a small pure library that can be exercised on the host without any
32+
ESP32 hardware. Tests live in `tests/wiegand_decode.rs` and
33+
`tests/access_core.rs` and combine handwritten scenarios with
34+
`proptest`-based property tests over randomly generated event traces.
35+
36+
Run them with the host toolchain (NOT the `esp` toolchain pinned by
37+
`rust-toolchain.toml`):
38+
39+
```bash
40+
RUSTUP_TOOLCHAIN=stable cargo test \
41+
--no-default-features --features sim \
42+
--target x86_64-unknown-linux-gnu
43+
```
44+
45+
The `sim` feature gates the binary out (`required-features = ["esp32"]`)
46+
and makes all hardware deps optional, so only pure code and tests are
47+
compiled. Default `cargo build --release` for the firmware is
48+
unaffected.
49+
50+
Properties currently proven include: no `OpenDoor` effect without a
51+
current fob-cache hit (A1/A2/A3); silent backoff window (A4); 10-second
52+
recheck deadline never grants past expiry (A5); every granted card swipe
53+
is accompanied by an `allowed:true` audit record; and Wiegand
54+
frame-parity / fob-format invariants (W1–W4).
55+
2756
## Flashing an ESP32
2857

2958
1. Connect ESP32 via USB

access-controller/src/core.rs

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
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+
}

access-controller/src/decode.rs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
//! Pure Wiegand frame decoders.
2+
//!
3+
//! The hardware-driven async reader lives in `src/wiegand.rs`. Everything
4+
//! testable in isolation (parity checks, field extraction, credential
5+
//! derivation) lives here so it can be exercised from host tests.
6+
7+
/// Decoded Wiegand credential.
8+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9+
pub struct WiegandRead {
10+
pub facility: u32,
11+
pub card: u32,
12+
pub raw_data: u32,
13+
}
14+
15+
impl WiegandRead {
16+
/// H10301 fob format: facility code + 5-digit card ID.
17+
pub fn to_fob(&self) -> u32 {
18+
self.facility * 100_000 + self.card
19+
}
20+
21+
/// NFC UID derived by byte-reversing the raw data field.
22+
pub fn to_nfc_uid(&self) -> u32 {
23+
self.raw_data.swap_bytes()
24+
}
25+
}
26+
27+
/// Decode a 26-bit Wiegand frame (H10301).
28+
///
29+
/// Frame layout (MSB first):
30+
/// - bit 25: even-parity over upper 12 data bits
31+
/// - bits 24..1: 24 data bits (8-bit facility + 16-bit card)
32+
/// - bit 0: odd-parity over lower 12 data bits
33+
///
34+
/// Returns `None` on parity failure.
35+
pub fn decode_26(raw: u64) -> Option<WiegandRead> {
36+
let raw = raw as u32;
37+
let leading = (raw >> 25) & 1;
38+
let trailing = raw & 1;
39+
let data = (raw >> 1) & 0xFF_FFFF;
40+
41+
let upper = data >> 12;
42+
let lower = data & 0xFFF;
43+
let even_ok = (upper.count_ones() % 2) == leading;
44+
let odd_ok = (lower.count_ones() % 2) != trailing;
45+
if !even_ok || !odd_ok {
46+
return None;
47+
}
48+
49+
let facility = (data >> 16) & 0xFF;
50+
let card = data & 0xFFFF;
51+
Some(WiegandRead {
52+
facility,
53+
card,
54+
raw_data: data,
55+
})
56+
}
57+
58+
/// Decode a 34-bit Wiegand frame.
59+
///
60+
/// Frame layout (MSB first):
61+
/// - bit 33: even-parity over upper 16 data bits
62+
/// - bits 32..1: 32 data bits
63+
/// - bit 0: odd-parity over lower 16 data bits
64+
///
65+
/// NOTE: We intentionally pull facility from bits 23..16 (8-bit), not bits
66+
/// 31..16 as strict H10304 would specify. This matches the legacy fob
67+
/// database that pre-dated this firmware. Tests below pin this behavior.
68+
pub fn decode_34(raw: u64) -> Option<WiegandRead> {
69+
let leading = ((raw >> 33) & 1) as u32;
70+
let trailing = (raw & 1) as u32;
71+
let data = ((raw >> 1) & 0xFFFF_FFFF) as u32;
72+
73+
let upper = data >> 16;
74+
let lower = data & 0xFFFF;
75+
let even_ok = (upper.count_ones() % 2) == leading;
76+
let odd_ok = (lower.count_ones() % 2) != trailing;
77+
if !even_ok || !odd_ok {
78+
return None;
79+
}
80+
81+
let facility = (data >> 16) & 0xFF;
82+
let card = data & 0xFFFF;
83+
Some(WiegandRead {
84+
facility,
85+
card,
86+
raw_data: data,
87+
})
88+
}
89+
90+
/// Build a syntactically valid 26-bit frame for a given facility/card pair,
91+
/// with correct parity bits. Useful for tests and for round-tripping known
92+
/// credentials through `decode_26`. Truncates `facility` to 8 bits and
93+
/// `card` to 16 bits per H10301.
94+
pub fn encode_26(facility: u32, card: u32) -> u64 {
95+
let facility = facility & 0xFF;
96+
let card = card & 0xFFFF;
97+
let data = (facility << 16) | card;
98+
let upper = data >> 12;
99+
let lower = data & 0xFFF;
100+
// even parity bit: chosen so upper.count_ones() + leading is even.
101+
let leading = upper.count_ones() & 1;
102+
// odd parity bit: chosen so lower.count_ones() + trailing is odd.
103+
let trailing = (lower.count_ones() & 1) ^ 1;
104+
((leading as u64) << 25) | ((data as u64) << 1) | (trailing as u64)
105+
}
106+
107+
/// Build a syntactically valid 34-bit frame, matching the legacy 8-bit
108+
/// facility layout that `decode_34` understands.
109+
pub fn encode_34(facility: u32, card: u32) -> u64 {
110+
let facility = facility & 0xFF;
111+
let card = card & 0xFFFF;
112+
let data: u32 = (facility << 16) | card;
113+
let upper = data >> 16;
114+
let lower = data & 0xFFFF;
115+
let leading = upper.count_ones() & 1;
116+
let trailing = (lower.count_ones() & 1) ^ 1;
117+
((leading as u64) << 33) | ((data as u64) << 1) | (trailing as u64)
118+
}

access-controller/src/events.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//! Access event reported to the Conway server.
2+
3+
/// A single swipe event: which credential was presented and whether the
4+
/// local cache authorized it. Buffered locally and POSTed to Conway during
5+
/// the next sync; only removed from the buffer after the server ACKs.
6+
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
7+
pub struct AccessEvent {
8+
pub fob: u32,
9+
pub allowed: bool,
10+
}

access-controller/src/lib.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//! Pure library surface of the access-controller crate.
2+
//!
3+
//! Only modules with zero hardware/HAL dependencies live here. They are
4+
//! shared between the firmware binary (`src/main.rs`) and host-side
5+
//! deterministic simulation tests (`tests/*.rs`).
6+
//!
7+
//! Build modes:
8+
//! - Default (firmware): `cargo build` with `--target xtensa-esp32-none-elf`,
9+
//! feature `esp32` (enabled by default). `no_std`.
10+
//! - Simulation/tests: `cargo test --no-default-features --features sim` on
11+
//! the host; uses `std` so we can run proptest and standard `#[test]`s.
12+
13+
#![cfg_attr(not(feature = "sim"), no_std)]
14+
15+
extern crate alloc;
16+
17+
pub mod core;
18+
pub mod decode;
19+
pub mod events;

0 commit comments

Comments
 (0)