Skip to content

Commit 17f43f3

Browse files
committed
access-controller: add manual unlock button and last-swipe row
Adds a POST /unlock endpoint plus an 'Unlock door' button on the status page so an operator on the LAN can pulse the door without presenting a card. Manual unlocks signal access_task (which owns the door + reader), log an audit event with a sentinel fob id (0), and bypass authorization entirely. The button is disabled and the endpoint returns 403 while in onboarding mode. Also surfaces the most recent door event in a new 'Last swipe' status row (fob id, Granted/Denied with the existing ok/err styling, age, and a '(manual)' marker for HTTP-initiated unlocks). State lives in a new LAST_SWIPE mutex written by access_task; AccessCore is untouched so the existing simulation tests still hold.
1 parent 4a6d3de commit 17f43f3

2 files changed

Lines changed: 159 additions & 15 deletions

File tree

access-controller/src/http.rs

Lines changed: 98 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ use heapless::String as HString;
1919

2020
use crate::ota::{self, OtaError, OtaWriter};
2121
use crate::settings::{self, Settings, MAX_PASSWORD, MAX_SSID};
22-
use crate::{DeviceMode, RuntimeConfig, EVENT_BUFFER, MAX_FOBS, WATCHDOG_FEED};
22+
use crate::{
23+
DeviceMode, LastSwipe, RuntimeConfig, EVENT_BUFFER, MANUAL_UNLOCK, MAX_FOBS, WATCHDOG_FEED,
24+
};
2325

2426
const HTTP_PORT: u16 = 80;
2527
/// Timeout for normal short requests.
@@ -45,6 +47,7 @@ pub async fn http_server_task(
4547
stack: &'static Stack<'static>,
4648
fobs: &'static Mutex<CriticalSectionRawMutex, heapless::Vec<u32, MAX_FOBS>>,
4749
etag: &'static Mutex<CriticalSectionRawMutex, HString<64>>,
50+
last_swipe: &'static Mutex<CriticalSectionRawMutex, Option<LastSwipe>>,
4851
rt: &'static RuntimeConfig,
4952
) {
5053
// Wait for the network stack to be ready before binding.
@@ -76,7 +79,7 @@ pub async fn http_server_task(
7679
let peer = socket.remote_endpoint();
7780
log::info!("http: connection from {:?}", peer);
7881

79-
handle_connection(&mut socket, fobs, etag, stack, rt).await;
82+
handle_connection(&mut socket, fobs, etag, last_swipe, stack, rt).await;
8083

8184
let _ = socket.flush().await;
8285
socket.close();
@@ -87,6 +90,7 @@ async fn handle_connection(
8790
socket: &mut TcpSocket<'_>,
8891
fobs: &Mutex<CriticalSectionRawMutex, heapless::Vec<u32, MAX_FOBS>>,
8992
etag: &Mutex<CriticalSectionRawMutex, HString<64>>,
93+
last_swipe: &Mutex<CriticalSectionRawMutex, Option<LastSwipe>>,
9094
stack: &Stack<'static>,
9195
rt: &'static RuntimeConfig,
9296
) {
@@ -145,7 +149,7 @@ async fn handle_connection(
145149
send_redirect(socket, "/config").await;
146150
}
147151
("GET", "/") | ("GET", "/status") => {
148-
send_status_page(socket, fobs, etag, stack, rt).await;
152+
send_status_page(socket, fobs, etag, last_swipe, stack, rt).await;
149153
}
150154
("GET", "/config") => {
151155
send_config_page(socket, rt).await;
@@ -188,6 +192,9 @@ async fn handle_connection(
188192
("POST", "/ota/rollback") => {
189193
handle_ota_rollback(socket).await;
190194
}
195+
("POST", "/unlock") => {
196+
handle_manual_unlock(socket, rt).await;
197+
}
191198
("GET", _) if rt.mode == DeviceMode::Onboarding => {
192199
// Any unknown GET while onboarding: bounce to /config so
193200
// OS captive-portal heuristics fire.
@@ -328,6 +335,26 @@ async fn send_ota_error(socket: &mut TcpSocket<'_>, err: OtaError) {
328335
send_text(socket, err.http_status(), body.as_bytes()).await;
329336
}
330337

338+
/// Operator-initiated door pulse. Forbidden while onboarding (the
339+
/// device isn't yet trusted to be on a private LAN). Otherwise the
340+
/// access_task observes `MANUAL_UNLOCK`, fires `DOOR_SIGNAL` +
341+
/// `READER_FEEDBACK::Granted`, and records an audit entry with the
342+
/// `MANUAL_UNLOCK_FOB` sentinel.
343+
async fn handle_manual_unlock(socket: &mut TcpSocket<'_>, rt: &'static RuntimeConfig) {
344+
if rt.mode == DeviceMode::Onboarding {
345+
send_status_line(
346+
socket,
347+
"403 Forbidden",
348+
b"manual unlock is disabled during onboarding\n",
349+
)
350+
.await;
351+
return;
352+
}
353+
log::warn!("http: manual unlock requested by {:?}", socket.remote_endpoint());
354+
MANUAL_UNLOCK.signal(());
355+
send_text(socket, "200 OK", b"ok: door pulsed\n").await;
356+
}
357+
331358
/// Case-insensitive scan for `Content-Length: <decimal>` in the header block.
332359
fn parse_content_length(headers: &str) -> Option<u32> {
333360
for line in headers.lines() {
@@ -367,17 +394,20 @@ async fn send_status_page(
367394
socket: &mut TcpSocket<'_>,
368395
fobs: &Mutex<CriticalSectionRawMutex, heapless::Vec<u32, MAX_FOBS>>,
369396
etag: &Mutex<CriticalSectionRawMutex, HString<64>>,
397+
last_swipe: &Mutex<CriticalSectionRawMutex, Option<LastSwipe>>,
370398
stack: &Stack<'static>,
371399
rt: &'static RuntimeConfig,
372400
) {
373401
// Gather state.
374-
let uptime_secs = Instant::now().as_millis() / 1000;
402+
let uptime_ms = Instant::now().as_millis();
403+
let uptime_secs = uptime_ms / 1000;
375404
let fob_count = fobs.lock().await.len();
376405
let pending_events = EVENT_BUFFER.len().await;
377406
let current_etag = {
378407
let g = etag.lock().await;
379408
g.clone()
380409
};
410+
let last_swipe_snap: Option<LastSwipe> = *last_swipe.lock().await;
381411

382412
// Snapshot live settings so the page reflects current creds and
383413
// Conway URL even after a /config save (which reboots, but better
@@ -444,8 +474,57 @@ async fn send_status_page(
444474
""
445475
};
446476

447-
// Build body. 4 KiB is plenty for this page including the upload form.
448-
let mut body: HString<4096> = HString::new();
477+
// Format the last-swipe row, e.g.
478+
// "fob 1234567 <span class=ok>Granted</span> &middot; 12s ago (manual)"
479+
// or "(none)" if no swipe has been recorded since boot.
480+
let mut last_swipe_html: HString<192> = HString::new();
481+
match last_swipe_snap {
482+
None => {
483+
let _ = last_swipe_html.push_str("(none)");
484+
}
485+
Some(ls) => {
486+
let age_ms = uptime_ms.saturating_sub(ls.at_uptime_ms);
487+
let age_secs = age_ms / 1000;
488+
let (status_class, status_text) = if ls.allowed {
489+
("ok", "Granted")
490+
} else {
491+
("err", "Denied")
492+
};
493+
let label = if ls.manual { " (manual)" } else { "" };
494+
if age_secs < 60 {
495+
let _ = write!(
496+
last_swipe_html,
497+
"fob {} &middot; <span class=\"{}\">{}</span> &middot; {}s ago{}",
498+
ls.fob, status_class, status_text, age_secs, label
499+
);
500+
} else {
501+
let _ = write!(
502+
last_swipe_html,
503+
"fob {} &middot; <span class=\"{}\">{}</span> &middot; {}m {}s ago{}",
504+
ls.fob,
505+
status_class,
506+
status_text,
507+
age_secs / 60,
508+
age_secs % 60,
509+
label
510+
);
511+
}
512+
}
513+
}
514+
515+
// Manual-unlock button is hidden in onboarding mode (POST /unlock
516+
// returns 403 there anyway).
517+
let unlock_section: &str = if is_onboarding {
518+
""
519+
} else {
520+
"<h2>Door</h2>\
521+
<p><button id=\"unlockbtn\">Unlock door</button> \
522+
<span id=\"unlockstatus\"></span></p>"
523+
};
524+
525+
// Build body. 5 KiB is plenty for this page including the upload
526+
// form, last-swipe row, and unlock button.
527+
let mut body: HString<5120> = HString::new();
449528
let _ = write!(
450529
body,
451530
"<!doctype html>\
@@ -465,9 +544,11 @@ th{{background:#f3f3f3}}progress{{width:100%}}\
465544
<tr><th>Conway server</th><td>{chost}:{cport}</td></tr>\
466545
<tr><th>Cached fobs</th><td>{fobs}</td></tr>\
467546
<tr><th>Pending events</th><td>{events}</td></tr>\
547+
<tr><th>Last swipe</th><td>{last_swipe}</td></tr>\
468548
<tr><th>Sync ETag</th><td>{etag}</td></tr>\
469549
<tr><th>OTA slot</th><td>{ota}</td></tr>\
470550
</table>\
551+
{unlock_section}\
471552
<h2>Firmware update</h2>\
472553
<p>Max image size: {maxk} KiB. The device will reboot into the new \
473554
image on success.</p>\
@@ -481,7 +562,9 @@ image on success.</p>\
481562
<script>\
482563
const f=document.getElementById('otaform'),fi=document.getElementById('otafile'),\
483564
p=document.getElementById('otaprog'),s=document.getElementById('otastatus'),\
484-
rb=document.getElementById('rollbackbtn');\
565+
rb=document.getElementById('rollbackbtn'),\
566+
ub=document.getElementById('unlockbtn'),\
567+
us=document.getElementById('unlockstatus');\
485568
f.addEventListener('submit',e=>{{e.preventDefault();const file=fi.files[0];if(!file)return;\
486569
s.textContent='Uploading '+file.size+' bytes...';s.className='';\
487570
const x=new XMLHttpRequest();x.open('POST','/ota');\
@@ -494,6 +577,12 @@ x.send(file);}});\
494577
rb.addEventListener('click',()=>{{if(!confirm('Roll back and reboot?'))return;\
495578
fetch('/ota/rollback',{{method:'POST'}}).then(r=>r.text()).then(t=>{{s.textContent=t;}})\
496579
.catch(e=>{{s.textContent='rollback failed';s.className='err';}});}});\
580+
if(ub){{ub.addEventListener('click',()=>{{if(!confirm('Unlock the door now?'))return;\
581+
us.textContent='unlocking...';us.className='';\
582+
fetch('/unlock',{{method:'POST'}}).then(r=>r.text().then(t=>{{\
583+
us.textContent=t.trim();us.className=r.ok?'ok':'err';\
584+
if(r.ok)setTimeout(()=>location.reload(),800);}}))\
585+
.catch(e=>{{us.textContent='unlock failed';us.className='err';}});}});}}\
497586
</script>\
498587
</body></html>",
499588
firmware = firmware,
@@ -505,13 +594,15 @@ fetch('/ota/rollback',{{method:'POST'}}).then(r=>r.text()).then(t=>{{s.textConte
505594
cport = conway_port,
506595
fobs = fob_count,
507596
events = pending_events,
597+
last_swipe = last_swipe_html.as_str(),
508598
etag = if current_etag.is_empty() {
509599
"(none)"
510600
} else {
511601
current_etag.as_str()
512602
},
513603
ota = ota_str.as_str(),
514604
maxk = next_slot_size / 1024,
605+
unlock_section = unlock_section,
515606
);
516607

517608
let mut header: HString<160> = HString::new();

access-controller/src/main.rs

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,23 @@ pub static SYNC_COMPLETE: Signal<CriticalSectionRawMutex, ()> = Signal::new();
8787
// Signal for door unlock (after successful auth)
8888
pub static DOOR_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
8989

90+
// Signal raised by `POST /unlock` to request a manual door pulse.
91+
pub static MANUAL_UNLOCK: Signal<CriticalSectionRawMutex, ()> = Signal::new();
92+
93+
/// Sentinel `fob` value used when recording a manual-unlock event so the
94+
/// Conway audit trail can distinguish it from a real card swipe.
95+
pub const MANUAL_UNLOCK_FOB: u32 = 0;
96+
97+
/// Most recent door event (swipe or manual unlock). Rendered on the
98+
/// HTTP status page; not persisted across reboots.
99+
#[derive(Debug, Clone, Copy)]
100+
pub struct LastSwipe {
101+
pub fob: u32,
102+
pub allowed: bool,
103+
pub at_uptime_ms: u64,
104+
pub manual: bool,
105+
}
106+
90107
/// Reader-side user feedback to play after an access decision.
91108
#[derive(Debug, Clone, Copy)]
92109
pub enum AccessOutcome {
@@ -104,6 +121,8 @@ pub static WATCHDOG_FEED: Signal<CriticalSectionRawMutex, ()> = Signal::new();
104121
static FOBS: StaticCell<Mutex<CriticalSectionRawMutex, heapless::Vec<u32, MAX_FOBS>>> =
105122
StaticCell::new();
106123
static ETAG: StaticCell<Mutex<CriticalSectionRawMutex, HString<64>>> = StaticCell::new();
124+
static LAST_SWIPE: StaticCell<Mutex<CriticalSectionRawMutex, Option<LastSwipe>>> =
125+
StaticCell::new();
107126
static STACK_RESOURCES: StaticCell<StackResources<8>> = StaticCell::new();
108127
static STACK: StaticCell<Stack<'static>> = StaticCell::new();
109128

@@ -173,6 +192,7 @@ async fn main(spawner: embassy_executor::Spawner) {
173192
// Initialize shared state (fobs and etag start empty, populated by sync)
174193
let fobs = FOBS.init(Mutex::new(heapless::Vec::new()));
175194
let etag = ETAG.init(Mutex::new(HString::new()));
195+
let last_swipe = LAST_SWIPE.init(Mutex::new(None));
176196

177197
log::info!("storage: fob cache initialized (empty, will sync from server)");
178198

@@ -284,7 +304,7 @@ async fn main(spawner: embassy_executor::Spawner) {
284304
spawner.spawn(net_task(runner)).unwrap();
285305
spawner.spawn(wifi_task(wifi_controller, rt_config)).unwrap();
286306
spawner.spawn(wiegand_task(wiegand)).unwrap();
287-
spawner.spawn(access_task(fobs, wdt)).unwrap();
307+
spawner.spawn(access_task(fobs, last_swipe, wdt)).unwrap();
288308
spawner.spawn(door_task(door)).unwrap();
289309
spawner
290310
.spawn(reader_feedback_task(reader_led, reader_beep))
@@ -297,7 +317,7 @@ async fn main(spawner: embassy_executor::Spawner) {
297317
spawner.spawn(sync_task(stack, fobs, etag, rt_config)).unwrap();
298318
}
299319
spawner
300-
.spawn(http::http_server_task(stack, fobs, etag, rt_config))
320+
.spawn(http::http_server_task(stack, fobs, etag, last_swipe, rt_config))
301321
.unwrap();
302322
spawner.spawn(watchdog_feed_task()).unwrap();
303323

@@ -425,29 +445,55 @@ async fn wiegand_task(mut wiegand: Wiegand<'static>) {
425445
#[embassy_executor::task]
426446
async fn access_task(
427447
fobs: &'static Mutex<CriticalSectionRawMutex, heapless::Vec<u32, MAX_FOBS>>,
448+
last_swipe: &'static Mutex<CriticalSectionRawMutex, Option<LastSwipe>>,
428449
wdt: &'static Mutex<CriticalSectionRawMutex, WdtType>,
429450
) {
430451
let mut core = AccessCore::new();
431452

432453
loop {
433-
// Use select3 to handle card reads, sync completion, AND watchdog feed requests.
434-
// This ensures we can service all events without blocking.
435-
let event = embassy_futures::select::select3(
454+
// Select across all firmware-level inputs: card reads, sync
455+
// completion, watchdog feed ticks, and operator-initiated
456+
// manual unlocks from the HTTP server.
457+
let event = embassy_futures::select::select4(
436458
WIEGAND_CHANNEL.receive(),
437459
SYNC_COMPLETE.wait(),
438460
WATCHDOG_FEED.wait(),
461+
MANUAL_UNLOCK.wait(),
439462
)
440463
.await;
441464

442465
let now = embassy_time::Instant::now().as_millis();
443466

467+
// Manual unlock is handled entirely in the firmware adapter -
468+
// it doesn't run through AccessCore because there's no
469+
// authorization decision to make.
470+
if let embassy_futures::select::Either4::Fourth(()) = event {
471+
log::warn!("access MANUAL UNLOCK via HTTP");
472+
DOOR_SIGNAL.signal(());
473+
READER_FEEDBACK.signal(AccessOutcome::Granted);
474+
EVENT_BUFFER
475+
.push(AccessEvent {
476+
fob: MANUAL_UNLOCK_FOB,
477+
allowed: true,
478+
})
479+
.await;
480+
*last_swipe.lock().await = Some(LastSwipe {
481+
fob: MANUAL_UNLOCK_FOB,
482+
allowed: true,
483+
at_uptime_ms: now,
484+
manual: true,
485+
});
486+
continue;
487+
}
488+
444489
let input = match event {
445-
embassy_futures::select::Either3::First(read) => CoreInput::Card(CardRead {
490+
embassy_futures::select::Either4::First(read) => CoreInput::Card(CardRead {
446491
fob: read.to_fob(),
447492
nfc: read.to_nfc_uid(),
448493
}),
449-
embassy_futures::select::Either3::Second(()) => CoreInput::SyncComplete,
450-
embassy_futures::select::Either3::Third(()) => CoreInput::WatchdogFeed,
494+
embassy_futures::select::Either4::Second(()) => CoreInput::SyncComplete,
495+
embassy_futures::select::Either4::Third(()) => CoreInput::WatchdogFeed,
496+
embassy_futures::select::Either4::Fourth(()) => unreachable!(),
451497
};
452498

453499
// Snapshot the cache once and pass it as a slice; mirrors the
@@ -477,6 +523,13 @@ async fn access_task(
477523
allowed: ev.allowed,
478524
})
479525
.await;
526+
// Mirror the record into the UI's last-swipe slot.
527+
*last_swipe.lock().await = Some(LastSwipe {
528+
fob: ev.fob,
529+
allowed: ev.allowed,
530+
at_uptime_ms: now,
531+
manual: false,
532+
});
480533
}
481534
Effect::RequestSync => {
482535
SYNC_SIGNAL.signal(());

0 commit comments

Comments
 (0)