From d7c41e018fe4fe12eb36c630725225923ec5883b Mon Sep 17 00:00:00 2001 From: Andreas Neuhaus Date: Sun, 27 Oct 2024 22:18:05 +0100 Subject: [PATCH 1/9] Fix HTTP pipelined requests failing after incompletely reading a response body --- firmware/src/json/reader.rs | 11 +++++++++++ firmware/src/vereinsflieger/mod.rs | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/firmware/src/json/reader.rs b/firmware/src/json/reader.rs index 0f4e160..043f0ff 100644 --- a/firmware/src/json/reader.rs +++ b/firmware/src/json/reader.rs @@ -232,6 +232,17 @@ impl Reader { self.expect(b'l').await?; Ok(()) } + + /// Read and discard any remaining data + pub async fn discard_to_end(&mut self) -> Result<(), Error> { + loop { + match self.reader.fill_buf().await?.len() { + 0 => break, + len => self.reader.consume(len), + } + } + Ok(()) + } } impl Reader { diff --git a/firmware/src/vereinsflieger/mod.rs b/firmware/src/vereinsflieger/mod.rs index ad30378..90a5ffc 100644 --- a/firmware/src/vereinsflieger/mod.rs +++ b/firmware/src/vereinsflieger/mod.rs @@ -159,6 +159,11 @@ impl<'a> Connection<'a> { response.total_articles ); + // Discard remaining body (needed to make the next pipelined request work) + json.discard_to_end() + .await + .map_err(http::Error::MalformedResponse)?; + Ok(()) } } From 4a91dfb6e9f22262b0301258f5f596ba5f53222b Mon Sep 17 00:00:00 2001 From: Andreas Neuhaus Date: Mon, 28 Oct 2024 13:34:32 +0100 Subject: [PATCH 2/9] Log a warning when an article is missing a valid price --- firmware/src/article.rs | 1 + firmware/src/vereinsflieger/proto_articles.rs | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/firmware/src/article.rs b/firmware/src/article.rs index b25043a..c98d0c7 100644 --- a/firmware/src/article.rs +++ b/firmware/src/article.rs @@ -1,6 +1,7 @@ use alloc::string::String; /// Article id +/// Equivalent to the Vereinsflieger `articleid` attribute #[allow(clippy::module_name_repetitions)] pub type ArticleId = u32; diff --git a/firmware/src/vereinsflieger/proto_articles.rs b/firmware/src/vereinsflieger/proto_articles.rs index b055536..6f9e595 100644 --- a/firmware/src/vereinsflieger/proto_articles.rs +++ b/firmware/src/vereinsflieger/proto_articles.rs @@ -6,6 +6,7 @@ use alloc::vec::Vec; use core::cell::RefCell; use core::str::FromStr; use embedded_io_async::{BufRead, Write}; +use log::warn; /// `articles/list` request #[derive(Debug)] @@ -57,6 +58,11 @@ impl FromJsonObject for ArticleListResponse { // needed, which heavily reduces memory consumption. let mut articles = context.borrow_mut(); articles.update(article.articleid, article.designation.clone(), price); + } else { + warn!( + "Ignoring article with no valid price ({}): {}", + article.articleid, article.designation + ); } } _ => _ = json.read_any().await?, From fe1546daa0c50f58088f4c38c028412c29daf2e3 Mon Sep 17 00:00:00 2001 From: Andreas Neuhaus Date: Tue, 22 Oct 2024 12:57:53 +0200 Subject: [PATCH 3/9] Fetch users from Vereinsflieger --- firmware/Cargo.lock | 1 + firmware/Cargo.toml | 1 + firmware/src/buzzer.rs | 1 - firmware/src/main.rs | 5 +- firmware/src/nfc.rs | 27 ++- firmware/src/ui.rs | 50 +++-- firmware/src/user.rs | 85 +++++++++ firmware/src/vereinsflieger/mod.rs | 38 ++++ firmware/src/vereinsflieger/proto_user.rs | 214 ++++++++++++++++++++++ 9 files changed, 402 insertions(+), 20 deletions(-) create mode 100644 firmware/src/user.rs create mode 100644 firmware/src/vereinsflieger/proto_user.rs 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(()) + } +} From f497c80583c1f90535563b61969ac026cb96a2d6 Mon Sep 17 00:00:00 2001 From: Andreas Neuhaus Date: Mon, 28 Oct 2024 15:10:39 +0100 Subject: [PATCH 4/9] Remember Vereinsflieger API access token across connections --- firmware/src/vereinsflieger/mod.rs | 129 ++++++++++++++++------------- 1 file changed, 73 insertions(+), 56 deletions(-) diff --git a/firmware/src/vereinsflieger/mod.rs b/firmware/src/vereinsflieger/mod.rs index 4274f84..d3af588 100644 --- a/firmware/src/vereinsflieger/mod.rs +++ b/firmware/src/vereinsflieger/mod.rs @@ -45,6 +45,7 @@ pub struct Vereinsflieger<'a> { password_md5: &'a str, appkey: &'a str, cid: Option, + accesstoken: Option, } impl<'a> fmt::Debug for Vereinsflieger<'a> { @@ -78,28 +79,20 @@ impl<'a> Vereinsflieger<'a> { password_md5, appkey, cid, + accesstoken: None, } } /// Connect to API server pub async fn connect(&mut self) -> Result { - let connection = self.http.connect(BASE_URL).await?; - - Connection::sign_in( - connection, - self.username, - self.password_md5, - self.appkey, - self.cid, - ) - .await + Connection::new(self).await } } /// Vereinsflieger API client connection pub struct Connection<'a> { connection: http::Connection<'a>, - accesstoken: AccessToken, + accesstoken: &'a AccessToken, } impl<'a> fmt::Debug for Connection<'a> { @@ -122,7 +115,7 @@ impl<'a> Connection<'a> { .post( "auth/getuser", &UserInformationRequest { - accesstoken: &self.accesstoken, + accesstoken: self.accesstoken, }, ) .await?; @@ -139,7 +132,7 @@ impl<'a> Connection<'a> { debug!("Vereinsflieger: Refreshing articles..."); let request_body = http::Connection::prepare_body(&ArticleListRequest { - accesstoken: &self.accesstoken, + accesstoken: self.accesstoken, }) .await?; let mut rx_buf = [0; 4096]; @@ -175,7 +168,7 @@ impl<'a> Connection<'a> { debug!("Vereinsflieger: Refreshing users..."); let request_body = http::Connection::prepare_body(&UserListRequest { - accesstoken: &self.accesstoken, + accesstoken: self.accesstoken, }) .await?; let mut rx_buf = [0; 4096]; @@ -207,51 +200,75 @@ impl<'a> Connection<'a> { } impl<'a> Connection<'a> { - /// Fetch access token, sign in to API server, return connection for API requests - async fn sign_in( - mut connection: http::Connection<'a>, - username: &str, - password_md5: &str, - appkey: &str, - cid: Option, - ) -> Result { - use proto_auth::{AccessTokenResponse, SignInRequest, SignInResponse}; - - // Fetch access token - let response: AccessTokenResponse = connection.get("auth/accesstoken").await?; - let accesstoken = response.accesstoken; - // debug!("Vereinsflieger: Got access token {}", accesstoken); - debug!( - "Vereinsflieger: Got access token (length {})", - accesstoken.len() - ); + /// Connect to API server, check existing access token (if any) or fetch a new one and sign + /// in. Return connection for authenticated API requests. + async fn new(vf: &'a mut Vereinsflieger<'_>) -> Result { + // Connect to API server + let mut connection = vf.http.connect(BASE_URL).await?; - // Use access token and credentials to sign in - let response: Result = connection - .post( - "auth/signin", - &SignInRequest { - accesstoken: &accesstoken, - username, - password_md5, - appkey, - cid, - auth_secret: None, - }, - ) - .await; - match response { - Ok(_) => { - info!("Vereinsflieger: Signed in as {}", username); - Ok(Self { - connection, - accesstoken, - }) + // If exist, check validity of access token + if let Some(ref accesstoken) = vf.accesstoken { + use proto_auth::{UserInformationRequest, UserInformationResponse}; + + let response: Result = connection + .post("auth/getuser", &UserInformationRequest { accesstoken }) + .await; + match response { + Ok(_userinfo) => debug!("Vereinsflieger: Access token valid"), + Err(http::Error::Unauthorized) => { + debug!("Vereinsflieger: Access token expired"); + vf.accesstoken = None; + } + Err(err) => return Err(err.into()), } - Err(err) => { - warn!("Vereinsflieger: Sign in failed: {}", err); - Err(err.into()) + } + + // Without an access token, fetch a new access token and sign in + if vf.accesstoken.is_none() { + use proto_auth::{AccessTokenResponse, SignInRequest, SignInResponse}; + + // Fetch a new access token + let response: AccessTokenResponse = connection.get("auth/accesstoken").await?; + let accesstoken = response.accesstoken; + // debug!("Vereinsflieger: Got access token {}", accesstoken); + debug!( + "Vereinsflieger: Got access token (length {})", + accesstoken.len() + ); + + // Use credentials to sign in + let response: Result = connection + .post( + "auth/signin", + &SignInRequest { + accesstoken: &accesstoken, + username: vf.username, + password_md5: vf.password_md5, + appkey: vf.appkey, + cid: vf.cid, + auth_secret: None, + }, + ) + .await; + match response { + Ok(_signin) => { + vf.accesstoken = Some(accesstoken); + info!("Vereinsflieger: Signed in as {}", vf.username); + } + Err(err) => { + warn!("Vereinsflieger: Sign in failed: {}", err); + return Err(err.into()); + } } } + + match vf.accesstoken { + Some(ref accesstoken) => Ok(Self { + connection, + accesstoken, + }), + // Actually unreachable + None => Err(http::Error::Unauthorized.into()), + } } } From d4d3f03f3a3fa066eee8b40d31dec878b3227205 Mon Sep 17 00:00:00 2001 From: Andreas Neuhaus Date: Tue, 29 Oct 2024 11:12:55 +0100 Subject: [PATCH 5/9] Make articleid a String, like Vereinsflieger defines it --- firmware/config-example.json | 2 +- firmware/src/article.rs | 15 ++++++++++----- firmware/src/ui.rs | 6 +++--- firmware/src/vereinsflieger/proto_articles.rs | 6 +++--- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/firmware/config-example.json b/firmware/config-example.json index 5d1ab5c..13afff0 100644 --- a/firmware/config-example.json +++ b/firmware/config-example.json @@ -15,5 +15,5 @@ "vf-cid": 0, // Vereinsflieger article id to use for purchases - "vf-article-id": 0 + "vf-article-id": "1234" } diff --git a/firmware/src/article.rs b/firmware/src/article.rs index c98d0c7..3241489 100644 --- a/firmware/src/article.rs +++ b/firmware/src/article.rs @@ -3,7 +3,7 @@ use alloc::string::String; /// Article id /// Equivalent to the Vereinsflieger `articleid` attribute #[allow(clippy::module_name_repetitions)] -pub type ArticleId = u32; +pub type ArticleId = String; /// Article information #[derive(Debug, Clone, PartialEq)] @@ -38,8 +38,8 @@ impl Articles { } /// Update article with given article id. Ignores article ids not in list. - pub fn update(&mut self, id: ArticleId, name: String, price: f32) { - if let Some(idx) = self.ids.iter().position(|i| *i == id) { + pub fn update(&mut self, id: &ArticleId, name: String, price: f32) { + if let Some(idx) = self.find(id) { self.articles[idx] = Some(Article { name, price }); } } @@ -49,10 +49,15 @@ impl Articles { self.articles.iter().filter(|a| a.is_some()).count() } + /// Find index of article with given id + pub fn find(&self, id: &ArticleId) -> Option { + self.ids.iter().position(|i| i == id) + } + /// Look up id of article at given index #[allow(dead_code)] - pub fn id(&self, index: usize) -> Option { - self.ids.get(index).copied() + pub fn id(&self, index: usize) -> Option<&ArticleId> { + self.ids.get(index) } /// Look up article information at given index diff --git a/firmware/src/ui.rs b/firmware/src/ui.rs index eb25a3a..1a08e64 100644 --- a/firmware/src/ui.rs +++ b/firmware/src/ui.rs @@ -186,11 +186,11 @@ impl<'a, I2C: I2c, IRQ: Wait> Ui<'a, I2C, IRQ> { let _user = self.users.get(userid); // Ask for number of drinks let num_drinks = self.get_number_of_drinks().await?; - // Get article price - let price = self.articles.get(0).ok_or(Error::ArticleNotFound)?.price; + // Get article information + let article = self.articles.get(0).ok_or(Error::ArticleNotFound)?; // Calculate total price. It's ok to cast num_drinks to f32 as it's always a small number. #[allow(clippy::cast_precision_loss)] - let total_price = price * num_drinks as f32; + let total_price = article.price * num_drinks as f32; // Show total price and ask for confirmation self.confirm_purchase(num_drinks, total_price).await?; diff --git a/firmware/src/vereinsflieger/proto_articles.rs b/firmware/src/vereinsflieger/proto_articles.rs index 6f9e595..b125fbe 100644 --- a/firmware/src/vereinsflieger/proto_articles.rs +++ b/firmware/src/vereinsflieger/proto_articles.rs @@ -57,7 +57,7 @@ impl FromJsonObject for ArticleListResponse { // articles directly to the article lookup table and only keeps the articles // needed, which heavily reduces memory consumption. let mut articles = context.borrow_mut(); - articles.update(article.articleid, article.designation.clone(), price); + articles.update(&article.articleid, article.designation, price); } else { warn!( "Ignoring article with no valid price ({}): {}", @@ -74,7 +74,7 @@ impl FromJsonObject for ArticleListResponse { /// Article #[derive(Debug, Default)] pub struct Article { - pub articleid: u32, + pub articleid: String, pub designation: String, pub unittype: String, pub prices: Vec, @@ -90,7 +90,7 @@ impl FromJsonObject for Article { _context: &Self::Context<'_>, ) -> Result<(), json::Error> { match &*key { - "articleid" => self.articleid = json.read_any().await?.try_into()?, + "articleid" => self.articleid = json.read().await?, "designation" => self.designation = json.read().await?, "unittype" => self.unittype = json.read().await?, "prices" => self.prices = json.read().await?, From a48ccc3639af65a4aac4fc56ade6a4141759b023 Mon Sep 17 00:00:00 2001 From: Andreas Neuhaus Date: Tue, 29 Oct 2024 14:44:00 +0100 Subject: [PATCH 6/9] Fix test user's member id --- firmware/src/user.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firmware/src/user.rs b/firmware/src/user.rs index bdd0455..802049c 100644 --- a/firmware/src/user.rs +++ b/firmware/src/user.rs @@ -5,9 +5,9 @@ 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), + (Uid::Single([0x13, 0xbd, 0x5b, 0x2a]), 3), // Test token #1 (Mifare Classic 1k) - (Uid::Single([0xb7, 0xd3, 0x65, 0x26]), 1271), + (Uid::Single([0xb7, 0xd3, 0x65, 0x26]), 3), ]; /// User id From 5f0eb335d4e9a54b7645381cd0e06d6cbd04bb60 Mon Sep 17 00:00:00 2001 From: Andreas Neuhaus Date: Tue, 29 Oct 2024 16:20:59 +0100 Subject: [PATCH 7/9] Extract current time from HTTP responses and keep track of it --- firmware/Cargo.lock | 1 + firmware/Cargo.toml | 1 + firmware/src/http.rs | 16 +++++++++++++++- firmware/src/main.rs | 1 + firmware/src/time.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 firmware/src/time.rs diff --git a/firmware/Cargo.lock b/firmware/Cargo.lock index 229b906..231ea32 100644 --- a/firmware/Cargo.lock +++ b/firmware/Cargo.lock @@ -1931,6 +1931,7 @@ dependencies = [ name = "touch-n-drink" version = "0.0.0" dependencies = [ + "chrono", "display-interface", "embassy-embedded-hal", "embassy-executor", diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index 92fea88..d579b32 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -44,6 +44,7 @@ overflow-checks = false git2 = { version = "0.19", default-features = false } [dependencies] +chrono = { version = "0.4", default-features = false, features = ["alloc"] } display-interface = "0.5" embassy-embedded-hal = "0.2" embassy-executor = "0.6" diff --git a/firmware/src/http.rs b/firmware/src/http.rs index 741f9d3..9cf9d4e 100644 --- a/firmware/src/http.rs +++ b/firmware/src/http.rs @@ -1,8 +1,10 @@ use crate::json::{self, FromJson, ToJson}; +use crate::time; use crate::wifi::{DnsSocket, TcpClient, TcpConnection, Wifi}; use alloc::vec::Vec; +use chrono::{DateTime, Utc}; use core::convert::Infallible; -use core::fmt; +use core::{fmt, str}; use embedded_io_async::{BufRead, Read}; use log::debug; use reqwless::client::{HttpClient, HttpResource, HttpResourceRequestBuilder}; @@ -212,6 +214,18 @@ impl<'a> Connection<'a> { let response = request.send(rx_buf).await?; debug!("HTTP: Status {}", response.status.0); + // Extract current date and time from response + let time = response + .headers() + .find_map(|(k, v)| (k == "Date").then_some(v)) + .and_then(|v| str::from_utf8(v).ok()) + .and_then(|s| DateTime::parse_from_rfc2822(s).ok()) + .map(|d| d.with_timezone(&Utc)); + if let Some(time) = time { + time::set(time); + } + + // Check HTTP response status if response.status.0 == 401 { return Err(Error::Unauthorized); } else if response.status.is_server_error() { diff --git a/firmware/src/main.rs b/firmware/src/main.rs index f4a803b..bb69939 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -50,6 +50,7 @@ mod keypad; mod nfc; mod pn532; mod screen; +mod time; mod ui; mod user; mod vereinsflieger; diff --git a/firmware/src/time.rs b/firmware/src/time.rs new file mode 100644 index 0000000..2928c26 --- /dev/null +++ b/firmware/src/time.rs @@ -0,0 +1,44 @@ +use chrono::{DateTime, TimeDelta, Utc}; +use core::cell::RefCell; +use embassy_sync::blocking_mutex::CriticalSectionMutex; +use log::debug; + +/// Calculated time of system start +static SYSTEM_START_TIME: CriticalSectionMutex>>> = + CriticalSectionMutex::new(RefCell::new(None)); + +/// Time of system start +fn start_time() -> Option> { + SYSTEM_START_TIME.lock(|sst| *sst.borrow()) +} + +/// Set time of system start +fn set_start_time(time: DateTime) { + SYSTEM_START_TIME.lock(|sst| { + *sst.borrow_mut() = Some(time); + }); +} + +/// Duration of system run time +pub fn uptime() -> Option { + let millis = esp_hal::time::now().duration_since_epoch().to_millis(); + TimeDelta::try_milliseconds(i64::try_from(millis).ok()?) +} + +/// Current time +#[allow(dead_code)] +pub fn now() -> Option> { + if let (Some(start_time), Some(uptime)) = (start_time(), uptime()) { + Some(start_time + uptime) + } else { + None + } +} + +/// Set current time by using the given current time to calculate the time of system start +pub fn set(now: DateTime) { + if let Some(uptime) = uptime() { + set_start_time(now - uptime); + debug!("Time: Current time set to {}", now); + } +} From 8ae9f36dba4bd925dce7f6ef5347fd1a33f2d237 Mon Sep 17 00:00:00 2001 From: Andreas Neuhaus Date: Tue, 29 Oct 2024 11:01:14 +0100 Subject: [PATCH 8/9] Allow to actually purchase an article --- firmware/src/article.rs | 1 - firmware/src/screen.rs | 4 +- firmware/src/time.rs | 1 - firmware/src/ui.rs | 74 ++++++++++++++++++-- firmware/src/vereinsflieger/mod.rs | 50 +++++++++++++- firmware/src/vereinsflieger/proto_sale.rs | 84 +++++++++++++++++++++++ 6 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 firmware/src/vereinsflieger/proto_sale.rs diff --git a/firmware/src/article.rs b/firmware/src/article.rs index 3241489..456eb07 100644 --- a/firmware/src/article.rs +++ b/firmware/src/article.rs @@ -55,7 +55,6 @@ impl Articles { } /// Look up id of article at given index - #[allow(dead_code)] pub fn id(&self, index: usize) -> Option<&ArticleId> { self.ids.get(index) } diff --git a/firmware/src/screen.rs b/firmware/src/screen.rs index 9153849..aa517d1 100644 --- a/firmware/src/screen.rs +++ b/firmware/src/screen.rs @@ -144,7 +144,9 @@ impl Screen for PleaseWait { FontColor::Transparent(BinaryColor::On), target, )?; - Footer::new("* Abbruch", "").draw(target)?; + if let Self::WifiConnecting = self { + Footer::new("* Abbruch", "").draw(target)?; + } Ok(()) } } diff --git a/firmware/src/time.rs b/firmware/src/time.rs index 2928c26..ff9e3f4 100644 --- a/firmware/src/time.rs +++ b/firmware/src/time.rs @@ -26,7 +26,6 @@ pub fn uptime() -> Option { } /// Current time -#[allow(dead_code)] pub fn now() -> Option> { if let (Some(start_time), Some(uptime)) = (start_time(), uptime()) { Some(start_time + uptime) diff --git a/firmware/src/ui.rs b/firmware/src/ui.rs index 1a08e64..fc357b0 100644 --- a/firmware/src/ui.rs +++ b/firmware/src/ui.rs @@ -1,4 +1,4 @@ -use crate::article::Articles; +use crate::article::{ArticleId, Articles}; use crate::buzzer::Buzzer; use crate::display::Display; use crate::error::Error; @@ -182,21 +182,31 @@ 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 userid = self.authenticate_user().await?; - let _user = self.users.get(userid); + let user_id = self.authenticate_user().await?; + let _user = self.users.get(user_id); + // Ask for number of drinks let num_drinks = self.get_number_of_drinks().await?; + // Get article information + let article_id = self.articles.id(0).ok_or(Error::ArticleNotFound)?.clone(); let article = self.articles.get(0).ok_or(Error::ArticleNotFound)?; + // Calculate total price. It's ok to cast num_drinks to f32 as it's always a small number. #[allow(clippy::cast_precision_loss)] - let total_price = article.price * num_drinks as f32; + let amount = num_drinks as f32; + let total_price = article.price * amount; + // Show total price and ask for confirmation self.confirm_purchase(num_drinks, total_price).await?; - // TODO: Process payment - let _ = screen::Success::new(num_drinks); - let _ = self.show_error("Not implemented yet", true).await; + // Store purchase + self.purchase(&article_id, amount, user_id, total_price) + .await?; + + // Show success and affirm to take drinks + self.show_success(num_drinks).await?; + Ok(()) } } @@ -293,4 +303,54 @@ impl<'a, I2C: I2c, IRQ: Wait> Ui<'a, I2C, IRQ> { } } } + + /// Purchase the given article + async fn purchase( + &mut self, + article_id: &ArticleId, + amount: f32, + user_id: UserId, + total_price: f32, + ) -> Result<(), Error> { + // Wait for network to become available (if not already) + self.wait_network_up().await?; + + info!( + "UI: Purchasing {}x {}, {:.02} EUR for user {}...", + amount, article_id, total_price, user_id + ); + + self.display + .screen(&screen::PleaseWait::ApiQuerying) + .await?; + let mut vf = self.vereinsflieger.connect().await?; + + // Store purchase + vf.purchase(article_id, amount, user_id, total_price) + .await?; + + Ok(()) + } + + /// Show success screen and wait for keypress or timeout + async fn show_success(&mut self, num_drinks: usize) -> Result<(), Error> { + info!("UI: Displaying success, {} drinks", num_drinks); + + self.display + .screen(&screen::Success::new(num_drinks)) + .await?; + let _ = self.buzzer.confirm().await; + + // Wait at least 1s without responding to keypad + let min_time = Duration::from_secs(1); + Timer::after(min_time).await; + + let wait_cancel = async { while self.keypad.read().await != Key::Enter {} }; + match with_timeout(USER_TIMEOUT - min_time, wait_cancel).await { + // Enter key continues + Ok(()) => Ok(()), + // User interaction timeout + Err(TimeoutError) => Err(Error::UserTimeout), + } + } } diff --git a/firmware/src/vereinsflieger/mod.rs b/firmware/src/vereinsflieger/mod.rs index d3af588..5b555b9 100644 --- a/firmware/src/vereinsflieger/mod.rs +++ b/firmware/src/vereinsflieger/mod.rs @@ -1,11 +1,14 @@ mod proto_articles; mod proto_auth; +mod proto_sale; mod proto_user; -use crate::article::Articles; +use crate::article::{ArticleId, Articles}; use crate::http::{self, Http}; -use crate::user::Users; +use crate::time; +use crate::user::{UserId, Users}; use crate::wifi::Wifi; +use alloc::format; use alloc::string::String; use core::cell::RefCell; use core::fmt; @@ -197,6 +200,40 @@ impl<'a> Connection<'a> { Ok(()) } + + /// Store a purchase + pub async fn purchase( + &mut self, + article_id: &ArticleId, + amount: f32, + user_id: UserId, + total_price: f32, + ) -> Result<(), Error> { + use proto_sale::{SaleAddRequest, SaleAddResponse}; + + debug!( + "Vereinsflieger: Purchasing {}x {}, {:.02} EUR for user {}", + amount, article_id, total_price, user_id + ); + + let _response: SaleAddResponse = self + .connection + .post( + "sale/add", + &SaleAddRequest { + accesstoken: self.accesstoken, + bookingdate: &Self::today(), + articleid: article_id, + amount, + memberid: Some(user_id), + totalprice: Some(total_price), + comment: None, + }, + ) + .await?; + debug!("Vereinsflieger: Purchase successful"); + Ok(()) + } } impl<'a> Connection<'a> { @@ -271,4 +308,13 @@ impl<'a> Connection<'a> { None => Err(http::Error::Unauthorized.into()), } } + + /// Helper function to get today's date as "yyyy-mm-dd" string + fn today() -> String { + if let Some(now) = time::now() { + format!("{}", now.format("%Y-%m-%d")) + } else { + String::new() + } + } } diff --git a/firmware/src/vereinsflieger/proto_sale.rs b/firmware/src/vereinsflieger/proto_sale.rs new file mode 100644 index 0000000..c3989b6 --- /dev/null +++ b/firmware/src/vereinsflieger/proto_sale.rs @@ -0,0 +1,84 @@ +use super::AccessToken; +use crate::json::{self, FromJsonObject, ToJson}; +use alloc::string::{String, ToString}; +use embedded_io_async::{BufRead, Write}; + +/// `sale/add` request +#[derive(Debug)] +pub struct SaleAddRequest<'a> { + pub accesstoken: &'a AccessToken, + pub bookingdate: &'a str, // "yyyy-mm-dd" + pub articleid: &'a str, + pub amount: f32, + pub memberid: Option, + // pub callsign: Option<&'a str>, + // pub salestax: Option, + pub totalprice: Option, + // pub counter: Option, + pub comment: Option<&'a str>, + // pub ccid: Option<&'a str>, + // pub caid2: Option, +} + +impl<'a> ToJson for SaleAddRequest<'a> { + async fn to_json( + &self, + json: &mut json::Writer, + ) -> Result<(), json::Error> { + let mut object = json.write_object().await?; + let mut object = object + .field("accesstoken", self.accesstoken) + .await? + .field("bookingdate", self.bookingdate) + .await? + .field("articleid", self.articleid) + .await? + .field("amount", self.amount) + .await?; + if let Some(memberid) = self.memberid { + object = object.field("memberid", memberid.to_string()).await?; + } + if let Some(totalprice) = self.totalprice { + object = object.field("totalprice", totalprice.to_string()).await?; + } + if let Some(comment) = self.comment { + object = object.field("comment", comment).await?; + } + object.finish().await + } +} + +/// `sale/add` response +#[derive(Debug, Default)] +pub struct SaleAddResponse { + // pub createtime: String, // "yyyy-mm-dd hh:mm:ss" + // pub modifytime: String, // "yyyy-mm-dd hh:mm:ss" + // pub bookingdate: String, // "yyyy-mm-dd" + // pub callsign: String, + // pub comment: String, + // pub username: String, + // pub uid: u32, + // pub memberid: u32, + // pub amount: f32, + // pub netvalue: f32, + // pub salestax: f32, + // pub totalprice: f32, + // pub supid: u32, + // pub articleid: String, + // pub caid2: u32, + // pub httpstatuscode: u16, +} + +impl FromJsonObject for SaleAddResponse { + type Context<'ctx> = (); + + async fn read_next( + &mut self, + _key: String, + json: &mut json::Reader, + _context: &Self::Context<'_>, + ) -> Result<(), json::Error> { + _ = json.read_any().await?; + Ok(()) + } +} From 0abe32b8a163083d3295740b10b758ed9261b69e Mon Sep 17 00:00:00 2001 From: Andreas Neuhaus Date: Tue, 29 Oct 2024 22:30:08 +0100 Subject: [PATCH 9/9] Fix broken JSON reader test --- firmware/src/json/reader.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firmware/src/json/reader.rs b/firmware/src/json/reader.rs index 043f0ff..054ff3e 100644 --- a/firmware/src/json/reader.rs +++ b/firmware/src/json/reader.rs @@ -516,7 +516,7 @@ mod tests { } impl FromJsonObject for Test { - type Context = (); + type Context<'ctx> = (); async fn read_next( &mut self,