diff --git a/firmware/Cargo.lock b/firmware/Cargo.lock index beb104e..229b906 100644 --- a/firmware/Cargo.lock +++ b/firmware/Cargo.lock @@ -1951,6 +1951,7 @@ dependencies = [ "esp-storage", "esp-wifi", "git2", + "hex", "log", "pn532", "rand_core", diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index e534811..92fea88 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -63,6 +63,7 @@ esp-partition-table = "0.1" esp-println = { version = "0.12", features = ["esp32c3", "log"] } esp-storage = { version = "0.3", features = ["esp32c3"] } esp-wifi = { version = "0.10", default-features = false, features = ["esp32c3", "esp-alloc", "async", "embassy-net", "log", "phy-enable-usb", "wifi"] } +hex = { version = "0.4", default-features = false, features = ["alloc"] } log = { version = "0.4", features = ["release_max_level_info"] } pn532 = "0.4" rand_core = "0.6" diff --git a/firmware/src/buzzer.rs b/firmware/src/buzzer.rs index 0e751b1..6217da2 100644 --- a/firmware/src/buzzer.rs +++ b/firmware/src/buzzer.rs @@ -106,7 +106,6 @@ impl<'a> Buzzer<'a> { } /// Output a long denying tone - #[allow(dead_code)] pub async fn deny(&mut self) -> Result<(), Error> { debug!("Buzzer: Playing deny tone"); self.tone(392, Duration::from_millis(500)).await?; // G4 diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 1bfde47..f4a803b 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -51,6 +51,7 @@ mod nfc; mod pn532; mod screen; mod ui; +mod user; mod vereinsflieger; mod wifi; @@ -130,8 +131,9 @@ async fn main(spawner: Spawner) { // Read system configuration let config = config::Config::read().await; - // Initialize list of articles + // Initialize article and user look up tables let mut articles = article::Articles::new([config.vf_article_id]); + let mut users = user::Users::new(); // Initialize I2C controller let i2c = I2c::new_with_timeout_async( @@ -220,6 +222,7 @@ async fn main(spawner: Spawner) { &wifi, &mut vereinsflieger, &mut articles, + &mut users, ); // Show splash screen for a while, ignore any error diff --git a/firmware/src/nfc.rs b/firmware/src/nfc.rs index 46d24f7..8132813 100644 --- a/firmware/src/nfc.rs +++ b/firmware/src/nfc.rs @@ -3,9 +3,11 @@ use crate::pn532; use core::convert::Infallible; use core::fmt::{self, Debug}; +use core::str::FromStr; use embassy_time::{Duration, Timer}; use embedded_hal_async::digital::Wait; use embedded_hal_async::i2c::I2c; +use hex::FromHex; use log::{debug, info, warn}; use pn532::{Error as Pn532Error, I2CInterfaceWithIrq, Pn532, Request, SAMMode}; @@ -181,6 +183,7 @@ impl> Nfc { // Return UID if retrieved, continue looping otherwise if let Some(uid) = maybe_uid { + debug!("NFC: Detected NFC card: {}", uid); return Ok(uid); } } @@ -192,7 +195,7 @@ impl> Nfc { pub struct InvalidUid; /// NFC UID -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum Uid { /// Single Size UID (4 bytes), Mifare Classic Single([u8; 4]), @@ -216,6 +219,28 @@ impl TryFrom<&[u8]> for Uid { } } +impl FromStr for Uid { + type Err = InvalidUid; + + fn from_str(s: &str) -> Result { + match s.len() { + 8 => { + let bytes = <[u8; 4]>::from_hex(s).map_err(|_e| InvalidUid)?; + Ok(Self::Single(bytes)) + } + 14 => { + let bytes = <[u8; 7]>::from_hex(s).map_err(|_e| InvalidUid)?; + Ok(Self::Double(bytes)) + } + 20 => { + let bytes = <[u8; 10]>::from_hex(s).map_err(|_e| InvalidUid)?; + Ok(Self::Triple(bytes)) + } + _ => Err(InvalidUid), + } + } +} + fn write_hex_bytes(f: &mut fmt::Formatter<'_>, bytes: &[u8]) -> fmt::Result { for b in bytes { write!(f, "{:02x}", *b)?; diff --git a/firmware/src/ui.rs b/firmware/src/ui.rs index 8a64a60..eb25a3a 100644 --- a/firmware/src/ui.rs +++ b/firmware/src/ui.rs @@ -3,8 +3,9 @@ use crate::buzzer::Buzzer; use crate::display::Display; use crate::error::Error; use crate::keypad::{Key, Keypad}; -use crate::nfc::{Nfc, Uid}; +use crate::nfc::Nfc; use crate::screen; +use crate::user::{UserId, Users}; use crate::vereinsflieger::Vereinsflieger; use crate::wifi::Wifi; use core::convert::Infallible; @@ -42,10 +43,12 @@ pub struct Ui<'a, I2C, IRQ> { wifi: &'a Wifi, vereinsflieger: &'a mut Vereinsflieger<'a>, articles: &'a mut Articles<1>, + users: &'a mut Users, } impl<'a, I2C: I2c, IRQ: Wait> Ui<'a, I2C, IRQ> { /// Create user interface with given human interface devices + #[allow(clippy::too_many_arguments)] pub fn new( display: &'a mut Display, keypad: &'a mut Keypad<'a, 3, 4>, @@ -54,6 +57,7 @@ impl<'a, I2C: I2c, IRQ: Wait> Ui<'a, I2C, IRQ> { wifi: &'a Wifi, vereinsflieger: &'a mut Vereinsflieger<'a>, articles: &'a mut Articles<1>, + users: &'a mut Users, ) -> Self { Self { display, @@ -63,6 +67,7 @@ impl<'a, I2C: I2c, IRQ: Wait> Ui<'a, I2C, IRQ> { wifi, vereinsflieger, articles, + users, } } @@ -138,12 +143,12 @@ impl<'a, I2C: I2c, IRQ: Wait> Ui<'a, I2C, IRQ> { } } - /// Refresh article names and prices - pub async fn refresh_articles(&mut self) -> Result<(), Error> { + /// Refresh article and user information + pub async fn refresh_articles_and_users(&mut self) -> Result<(), Error> { // Wait for network to become available (if not already) self.wait_network_up().await?; - info!("UI: Refreshing articles..."); + info!("UI: Refreshing articles and users..."); self.display .screen(&screen::PleaseWait::ApiQuerying) @@ -154,9 +159,12 @@ impl<'a, I2C: I2c, IRQ: Wait> Ui<'a, I2C, IRQ> { #[cfg(debug_assertions)] vf.get_user_information().await?; - // Refresh articles + // Refresh article information vf.refresh_articles(self.articles).await?; + // Refresh user information + vf.refresh_users(self.users).await?; + Ok(()) } @@ -165,8 +173,8 @@ impl<'a, I2C: I2c, IRQ: Wait> Ui<'a, I2C, IRQ> { // Wait for network to become available (if not already) self.wait_network_up().await?; - // Refresh article names and prices - self.refresh_articles().await?; + // Refresh articles and users + self.refresh_articles_and_users().await?; Ok(()) } @@ -174,7 +182,8 @@ impl<'a, I2C: I2c, IRQ: Wait> Ui<'a, I2C, IRQ> { /// Run the user interface flow pub async fn run(&mut self) -> Result<(), Error> { // Wait for id card and verify identification - let _uid = self.read_id_card().await?; + let userid = self.authenticate_user().await?; + let _user = self.users.get(userid); // Ask for number of drinks let num_drinks = self.get_number_of_drinks().await?; // Get article price @@ -188,15 +197,15 @@ impl<'a, I2C: I2c, IRQ: Wait> Ui<'a, I2C, IRQ> { // TODO: Process payment let _ = screen::Success::new(num_drinks); let _ = self.show_error("Not implemented yet", true).await; - let _key = self.keypad.read().await; Ok(()) } } impl<'a, I2C: I2c, IRQ: Wait> Ui<'a, I2C, IRQ> { - /// Wait for id card and read it. On idle timeout, enter power saving (turn off display). - /// Any key pressed leaves power saving (turn on display). - async fn read_id_card(&mut self) -> Result { + /// Authentication: wait for id card, read it and look up the associated user. On idle timeout, + /// enter power saving (turn off display). Any key pressed leaves power saving (turn on + /// display). + async fn authenticate_user(&mut self) -> Result { info!("UI: Waiting for NFC card..."); let mut saving_power = false; @@ -209,7 +218,7 @@ impl<'a, I2C: I2c, IRQ: Wait> Ui<'a, I2C, IRQ> { let uid = match with_timeout(IDLE_TIMEOUT, select(self.nfc.read(), self.keypad.read())) .await { - // Id card read + // Id card detected Ok(Either::First(res)) => res?, // Key pressed while saving power, leave power saving Ok(Either::Second(_key)) if saving_power => { @@ -225,10 +234,17 @@ impl<'a, I2C: I2c, IRQ: Wait> Ui<'a, I2C, IRQ> { // Otherwise, do nothing _ => continue, }; - info!("UI: Detected NFC card: {}", uid); - let _ = self.buzzer.confirm().await; - // TODO: Verify identification and return user information - return Ok(uid); + saving_power = false; + // Look up user id by detected NFC uid + if let Some(id) = self.users.id(&uid) { + // User found, authorized + info!("UI: NFC card {} identified as user {}", uid, id); + let _ = self.buzzer.confirm().await; + break Ok(id); + } + // User not found, unauthorized + info!("UI: NFC card {} unknown, rejecting", uid); + let _ = self.buzzer.deny().await; } } diff --git a/firmware/src/user.rs b/firmware/src/user.rs new file mode 100644 index 0000000..bdd0455 --- /dev/null +++ b/firmware/src/user.rs @@ -0,0 +1,85 @@ +use crate::nfc::Uid; +use alloc::collections::BTreeMap; +use alloc::string::String; + +/// Extra NFC card uids to add +static EXTRA_UIDS: [(Uid, UserId); 2] = [ + // Test card #1 (Mifare Classic 1k) + (Uid::Single([0x13, 0xbd, 0x5b, 0x2a]), 1271), + // Test token #1 (Mifare Classic 1k) + (Uid::Single([0xb7, 0xd3, 0x65, 0x26]), 1271), +]; + +/// User id +/// Equivalent to the Vereinsflieger `memberid` attribute +#[allow(clippy::module_name_repetitions)] +pub type UserId = u32; + +/// User information +#[derive(Debug, Clone, PartialEq)] +pub struct User { + // pub uids: Vec, + // pub id: UserId, + pub name: String, +} + +/// User lookup table +/// Provides a look up of user information (member id and name) by NFC uid. +#[derive(Debug)] +pub struct Users { + /// Look up NFC uid to user id + ids: BTreeMap, + /// Look up user id to user details + users: BTreeMap, +} + +impl Users { + /// Create new user lookup table + pub fn new() -> Self { + let mut this = Self { + ids: BTreeMap::new(), + users: BTreeMap::new(), + }; + this.clear(); + this + } + + /// Clear all user information + pub fn clear(&mut self) { + self.ids.clear(); + self.users.clear(); + + // Add extra uids and user for testing + for (uid, id) in &EXTRA_UIDS { + self.ids.insert(uid.clone(), *id); + self.users.entry(*id).or_insert_with(|| User { + name: String::from("Test-User"), + }); + } + } + + /// Add/update NFC uid for given user id + pub fn update_uid(&mut self, uid: Uid, id: UserId) { + self.ids.insert(uid, id); + } + + /// Add/update user with given user id + pub fn update_user(&mut self, id: UserId, name: String) { + self.users.insert(id, User { name }); + } + + /// Number of users + pub fn count(&self) -> usize { + self.users.len() + } + + /// Look up user id by NFC uid + pub fn id(&self, uid: &Uid) -> Option { + self.ids.get(uid).copied() + } + + /// Look up user by user id + pub fn get(&self, id: UserId) -> Option<&User> { + self.users.get(&id) + } +} diff --git a/firmware/src/vereinsflieger/mod.rs b/firmware/src/vereinsflieger/mod.rs index 90a5ffc..4274f84 100644 --- a/firmware/src/vereinsflieger/mod.rs +++ b/firmware/src/vereinsflieger/mod.rs @@ -1,8 +1,10 @@ mod proto_articles; mod proto_auth; +mod proto_user; use crate::article::Articles; use crate::http::{self, Http}; +use crate::user::Users; use crate::wifi::Wifi; use alloc::string::String; use core::cell::RefCell; @@ -166,6 +168,42 @@ impl<'a> Connection<'a> { Ok(()) } + + /// Fetch list of users and update user lookup table + pub async fn refresh_users(&mut self, users: &mut Users) -> Result<(), Error> { + use proto_user::{UserListRequest, UserListResponse}; + + debug!("Vereinsflieger: Refreshing users..."); + let request_body = http::Connection::prepare_body(&UserListRequest { + accesstoken: &self.accesstoken, + }) + .await?; + let mut rx_buf = [0; 4096]; + let mut json = self + .connection + .post_json("user/list", &request_body, &mut rx_buf) + .await?; + + users.clear(); + let users = RefCell::new(users); + + let response: UserListResponse = json + .read_object_with_context(&users) + .await + .map_err(http::Error::MalformedResponse)?; + info!( + "Vereinsflieger: Refreshed {} of {} users", + users.borrow().count(), + response.total_users + ); + + // Discard remaining body (needed to make the next pipelined request work) + json.discard_to_end() + .await + .map_err(http::Error::MalformedResponse)?; + + Ok(()) + } } impl<'a> Connection<'a> { diff --git a/firmware/src/vereinsflieger/proto_user.rs b/firmware/src/vereinsflieger/proto_user.rs new file mode 100644 index 0000000..93597e7 --- /dev/null +++ b/firmware/src/vereinsflieger/proto_user.rs @@ -0,0 +1,214 @@ +use super::AccessToken; +use crate::json::{self, FromJsonObject, ToJson}; +use crate::nfc::Uid; +use crate::user::Users; +use alloc::string::String; +use alloc::vec::Vec; +use core::cell::RefCell; +use core::str::FromStr; +use embedded_io_async::{BufRead, Write}; +use log::warn; + +/// `user/list` request +#[derive(Debug)] +pub struct UserListRequest<'a> { + pub accesstoken: &'a AccessToken, +} + +impl<'a> ToJson for UserListRequest<'a> { + async fn to_json( + &self, + json: &mut json::Writer, + ) -> Result<(), json::Error> { + json.write_object() + .await? + .field("accesstoken", self.accesstoken) + .await? + .finish() + .await + } +} + +/// `user/list` response +#[derive(Debug, Default)] +pub struct UserListResponse { + // pub *: User, + // pub httpstatuscode: u16, + // + /// Total number of users + pub total_users: u32, +} + +impl FromJsonObject for UserListResponse { + // Mutable reference to user lookup table + type Context<'ctx> = RefCell<&'ctx mut Users>; + + async fn read_next( + &mut self, + key: String, + json: &mut json::Reader, + context: &Self::Context<'_>, + ) -> Result<(), json::Error> { + match u32::from_str(&key) { + Ok(_key) => { + let user: User = json.read().await?; + self.total_users += 1; + if !user.is_retired() { + let keys = user.keys_named_with_prefix("NFC Transponder"); + if !keys.is_empty() { + // Instead of reading all users to a vector, this deserialization stores + // users directly to the user lookup table and only keeps the users needed, + // which heavily reduces memory consumption. + let mut users = context.borrow_mut(); + for key in keys { + if let Ok(uid) = Uid::from_str(key) { + users.update_uid(uid, user.memberid); + } else { + warn!( + "Ignoring user key with invalid NFC uid ({}): {}", + user.memberid, key + ); + } + } + users.update_user(user.memberid, user.firstname); + } + } + } + _ => _ = json.read_any().await?, + } + Ok(()) + } +} + +/// User +#[derive(Debug, Default)] +pub struct User { + // pub uid: u32, + // pub title: String, + pub firstname: String, + pub lastname: String, + // pub careof: String, + // pub street: String, + // pub postofficebox: String, // undocumented + // pub zipcode: String, + // pub town: String, + // pub email: String, + // pub gender: String, + // pub birthday: String, // "dd.mm.yyyy" + // pub birthplace: String, + // pub homenumber: String, + // pub mobilenumber: String, + // pub phonenumber: String, + // pub phonenumber2: String, + // pub carlicenseplate: String, + // pub identification: String, + // pub natoid: String, + // pub policecert_validto: String, // "yyyy-mm-dd" + // pub ice_contact1: String, + // pub ice_contact2: String, + pub memberid: u32, + // pub msid: String, // undocumented + // pub memberbegin: String, // "dd.mm.yyyy" + // pub memberend: String, // "yyyy-mm-dd" + // pub lettertitle: String, + // pub cid: String, // undocumented + // pub nickname: String, // undocumented + // pub clid: String, // undocumented + // pub flightrelease: String, // undocumented + // pub flightreleasevalidto: String, // undocumented "yyyy-mm-dd" + // pub flightdiscount: String, // undocumented + // pub flightdiscount2: String, // undocumented + // pub flightdiscount3: String, // undocumented + // pub flightdiscount4: String, // undocumented + // pub flightdiscount5: String, // undocumented + // pub flightdiscount6: String, // undocumented + pub memberstatus: String, + // pub country: String, + // pub bankaccountname: String, + // pub bankaccountinfo: String, // undocumented + // pub directdebitauth: u32, + // pub iban: String, + // pub bic: String, + // pub mandate: String, + // pub roles: Vec, + // pub mandatedate: String, // "yyyy-mm-dd" + // pub mailrecipient: u32, + // pub sector: Vec, + // pub functions: Vec, + // pub educations: Vec, + // pub prop0: [String, String], + // pub prop1: [String, String], + // pub prop2: [String, String], + // pub accounts: Vec, + pub keymanagement: Vec, + // pub stateassociation: Vec, + // pub key1designation: String, // undocumented + // pub key2designation: String, // undocumented + // pub keyrfid: String, // undocumented + // pub whtodo: UserWhTodoList, // undocumented +} + +impl FromJsonObject for User { + type Context<'ctx> = (); + + async fn read_next( + &mut self, + key: String, + json: &mut json::Reader, + _context: &Self::Context<'_>, + ) -> Result<(), json::Error> { + match &*key { + "firstname" => self.firstname = json.read().await?, + "lastname" => self.lastname = json.read().await?, + "memberid" => self.memberid = json.read_any().await?.try_into()?, + "memberstatus" => self.memberstatus = json.read().await?, + "keymanagement" => self.keymanagement = json.read().await?, + _ => _ = json.read_any().await?, + } + Ok(()) + } +} + +impl User { + /// Whether the user has/was retired ("ausgeschieden") + pub fn is_retired(&self) -> bool { + self.memberstatus.to_lowercase().contains("ausgeschieden") + } + + /// Get key numbers with the given label prefix + pub fn keys_named_with_prefix(&self, prefix: &str) -> Vec<&str> { + self.keymanagement + .iter() + .filter(|key| key.title.starts_with(prefix)) + .map(|key| key.keyname.as_str()) + .collect() + } +} + +/// User keymanagement +#[derive(Debug, Default)] +pub struct Key { + /// Key label + pub title: String, + /// Key number + pub keyname: String, + // pub rfidkey: u32, // undocumented +} + +impl FromJsonObject for Key { + type Context<'ctx> = (); + + async fn read_next( + &mut self, + key: String, + json: &mut json::Reader, + _context: &Self::Context<'_>, + ) -> Result<(), json::Error> { + match &*key { + "title" => self.title = json.read().await?, + "keyname" => self.keyname = json.read().await?, + _ => _ = json.read_any().await?, + } + Ok(()) + } +}