diff --git a/src/lib.rs b/src/lib.rs index f4568f7..b282394 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,10 @@ +//! An async client library for the R701 attendance reader by I.P.S. +//! Informatica, reverse-engineered from its TCP protocol. +//! +//! Connect with [`R701::connect`], then read attendance records with +//! [`R701::into_record_iter`] and resolve employee names with +//! [`R701::get_name`]. + mod r701; mod record; mod record_iterator; diff --git a/src/r701.rs b/src/r701.rs index 9aa1945..45a4273 100644 --- a/src/r701.rs +++ b/src/r701.rs @@ -10,13 +10,22 @@ struct ConnectionState { sequence_number: u16, } +/// A connection to an R701 attendance reader. +/// +/// Create one with [`R701::connect`]. #[derive(Debug)] pub struct R701 { state: Mutex, } impl R701 { - #[allow(clippy::missing_errors_doc)] + /// Connects to an R701 device at `connection_info` and pings it to + /// make sure it's responding correctly. + /// + /// # Errors + /// + /// Returns `Err` if the TCP connection can't be established, or if the + /// initial ping doesn't get the expected response. pub async fn connect(connection_info: impl ToSocketAddrs + Send) -> Result { // Create a new R701 struct let new = Self { @@ -31,7 +40,15 @@ impl R701 { Ok(new) } - #[allow(clippy::missing_errors_doc)] + /// Wraps `payload` in the device's request envelope (header and + /// sequence number) and sends it, then reads back exactly + /// `response_len` bytes of response. + /// + /// # Errors + /// + /// Returns `Err` if writing the request or reading the response fails, + /// including if the connection closes before `response_len` bytes have + /// been received. // The lock must stay held for the whole write+read round-trip, so that // concurrent callers can't interleave their requests on the wire #[allow(clippy::significant_drop_tightening)] @@ -58,7 +75,12 @@ impl R701 { Ok(buffer) } - #[allow(clippy::missing_errors_doc)] + /// Pings the device to check that it's connected and responding. + /// + /// # Errors + /// + /// Returns `Err` if the underlying request fails, or if the device + /// responds with anything other than the expected acknowledgement. pub async fn ping(&self) -> Result<()> { // Create a request with a payload of `01 80 00 00 00 00 00 00 00 00 00 // 00` and get the response @@ -75,7 +97,18 @@ impl R701 { Ok(()) } - #[allow(clippy::missing_errors_doc)] + /// Looks up the name of the employee with the given `id`. + /// + /// Returns `Ok(None)` if no employee with that ID is registered on the + /// device. If one is registered but its name isn't valid UTF-8, the + /// inner `Err` carries the original bytes (via + /// `FromUtf8Error::as_bytes`), letting the caller choose to fail or + /// fall back to a lossy rendering. + /// + /// # Errors + /// + /// Returns `Err` if the underlying request fails, or if the response + /// has an unexpected header. pub async fn get_name( &self, id: u32, @@ -110,7 +143,13 @@ impl R701 { Ok(Some(name).filter(|name| !name.is_empty()).map(Ok)) } - #[allow(clippy::missing_errors_doc)] + /// Returns the total number of attendance records currently stored on + /// the device. + /// + /// # Errors + /// + /// Returns `Err` if the underlying request fails, or if the response + /// has an unexpected header. pub async fn get_total_record_count(&self) -> Result { // Create a request with a payload of `01 b4 08 00 00 00 00 00 ff ff 00 // 00` and get the response @@ -130,7 +169,18 @@ impl R701 { Ok(u16::from_le_bytes(record_count_le)) } - #[allow(clippy::missing_errors_doc)] + /// Fetches one 1024-byte block of raw record data. + /// + /// `sequence_number` selects which block to fetch, except that `0` + /// always fetches the first block regardless of `total_records`, + /// following the device's own request convention. Used internally by + /// [`RecordIterator`] — most callers should use + /// [`R701::into_record_iter`] instead. + /// + /// # Errors + /// + /// Returns `Err` if the underlying request fails, or if the response + /// has an unexpected header or trailing bytes. pub async fn get_record_bytes( &self, total_records: u16, @@ -167,7 +217,15 @@ impl R701 { Ok(response[12..1036].to_vec()) } - #[allow(clippy::missing_errors_doc)] + /// Starts streaming all attendance records currently stored on the + /// device, newest first — fetching the most recent records first is + /// faster when you don't need the full history; reverse the order + /// yourself if you need chronological output. + /// + /// # Errors + /// + /// Returns `Err` if fetching the total record count or the first block + /// of record data fails. pub async fn into_record_iter(&self) -> Result> { RecordIterator::from(self).await } diff --git a/src/record.rs b/src/record.rs index 9c56996..f724d62 100644 --- a/src/record.rs +++ b/src/record.rs @@ -2,26 +2,45 @@ use chrono::{DateTime, Local, TimeZone}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; +/// Which clock an employee punched: entering or leaving, for one of the +/// device's two configured time slots. #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub enum Clock { + /// Entering during the first time slot FirstIn, + /// Leaving during the first time slot FirstOut, + /// Entering during the second time slot SecondIn, + /// Leaving during the second time slot SecondOut, } +/// A single attendance record read from the device. +/// +/// Identifies the employee who punched, whether they were entering or +/// leaving, and when. #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct Record { + /// The employee's numeric ID, as configured on the device pub employee_id: u32, + /// Whether the employee was entering or leaving pub clock: Clock, + /// When the record was punched pub datetime: DateTime, } impl TryFrom<&[u8]> for Record { type Error = &'static str; + /// Parses a single 12-byte record as read from the device. + /// + /// # Errors + /// + /// Returns `Err` if `record_bytes` isn't exactly 12 bytes long, or if + /// the packed date/time bits don't form a valid calendar date. fn try_from(record_bytes: &[u8]) -> Result { // Return an error if the slice length is less than 12 if record_bytes.len() != 12 { diff --git a/src/record_iterator.rs b/src/record_iterator.rs index 4c293ec..44ec1b4 100644 --- a/src/record_iterator.rs +++ b/src/record_iterator.rs @@ -4,12 +4,23 @@ use std::pin::Pin; use std::task::{Context, Poll}; use tokio_stream::Stream; +/// An async stream of [`Record`]s read from an [`R701`] device. +/// +/// Create one with [`R701::into_record_iter`]. Implements [`Stream`], so +/// consume it with [`tokio_stream::StreamExt::next`] (or any other +/// `Stream` combinator). pub struct RecordIterator<'a> { inner: Pin> + 'a>>, } impl<'a> RecordIterator<'a> { - #[allow(clippy::missing_errors_doc)] + /// Builds a new record stream, immediately fetching the device's total + /// record count and the first block of record data. + /// + /// # Errors + /// + /// Returns `Err` if fetching the total record count or the first block + /// of record data fails. pub async fn from(r701: &'a R701) -> Result { // Get the total number of records let total_records = r701.get_total_record_count().await?;