@@ -19,7 +19,9 @@ use heapless::String as HString;
1919
2020use crate :: ota:: { self , OtaError , OtaWriter } ;
2121use 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
2426const 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.
332359fn 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> · 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 {} · <span class=\" {}\" >{}</span> · {}s ago{}" ,
498+ ls. fob, status_class, status_text, age_secs, label
499+ ) ;
500+ } else {
501+ let _ = write ! (
502+ last_swipe_html,
503+ "fob {} · <span class=\" {}\" >{}</span> · {}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 ( ) ;
0 commit comments