diff --git a/Cargo.lock b/Cargo.lock
index 489ba95..d550d1e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -251,9 +251,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
+ "tokio-macros",
"windows-sys",
]
+[[package]]
+name = "tokio-macros"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "tokio-stream"
version = "0.1.19"
diff --git a/Cargo.toml b/Cargo.toml
index 202ea30..49b9c10 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,3 +23,6 @@ unwrap_used = "deny"
enum_glob_use = { level = "deny", priority = 1 }
pedantic = { level = "deny", priority = -1 }
nursery = { level = "deny", priority = -1 }
+
+[dev-dependencies]
+tokio = { version = "1.53.1", features = ["rt", "macros", "net"] }
diff --git a/tests/e2e.rs b/tests/e2e.rs
new file mode 100644
index 0000000..e4a6ab2
--- /dev/null
+++ b/tests/e2e.rs
@@ -0,0 +1,14 @@
+//! End-to-end tests exercising R701's public API against the emulated
+//! device in `mock`.
+
+#![allow(clippy::unwrap_used)]
+
+mod mock;
+
+use r701::R701;
+
+#[tokio::test]
+async fn connects_and_pings_successfully() {
+ let addr = mock::spawn().await;
+ assert!(R701::connect(addr).await.is_ok());
+}
diff --git a/tests/mock/mod.rs b/tests/mock/mod.rs
new file mode 100644
index 0000000..af37759
--- /dev/null
+++ b/tests/mock/mod.rs
@@ -0,0 +1,144 @@
+//! Emulates an R701 device so `tests/e2e.rs` can exercise the real `R701`
+//! client against it, grounded in the protocol documented at
+//! .
+
+use chrono::{Datelike, Local, TimeZone, Timelike};
+use r701::{Clock, Record};
+use std::collections::HashMap;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpListener;
+
+/// The employee names the fake device reports
+fn fake_users() -> HashMap {
+ HashMap::from([(1, "mario".to_string())])
+}
+
+/// The attendance records the fake device reports
+fn fake_records() -> Vec {
+ vec![Record {
+ employee_id: 1,
+ clock: Clock::FirstIn,
+ datetime: Local
+ .with_ymd_and_hms(1970, 1, 1, 0, 0, 0)
+ .single()
+ .unwrap(),
+ }]
+}
+
+/// Starts a fake R701 device in the background and returns the address to
+/// connect to.
+pub async fn spawn() -> String {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap().to_string();
+
+ tokio::spawn(async move {
+ let (mut socket, _) = listener.accept().await.unwrap();
+
+ loop {
+ let mut request = [0u8; 16];
+ let Ok(bytes_read) = socket.read(&mut request).await else {
+ return;
+ };
+ if bytes_read == 0 {
+ return;
+ }
+
+ let response = if bytes_read < 16 {
+ vec![0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0]
+ } else if request[..2] != [0x55, 0xaa] {
+ continue;
+ } else {
+ let payload = &request[2..14];
+ match (payload[0], payload[1]) {
+ (0x01, 0x80) => vec![0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0],
+ (0x01, 0xc7) => get_name_response(payload),
+ (0x01, 0xb4) => get_total_record_count_response(),
+ (0x01, 0xa4) => get_record_bytes_response(payload),
+ _ => vec![0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0],
+ }
+ };
+
+ if socket.write_all(&response).await.is_err() {
+ return;
+ }
+ }
+ });
+
+ addr
+}
+
+/// Builds the 34-byte get-name response: a 12-byte header (whether the
+/// employee was found), followed by their name (if found) null-padded to
+/// 10 bytes, followed by 12 don't-care bytes.
+fn get_name_response(payload: &[u8]) -> Vec {
+ let employee_id = u32::from_le_bytes(payload[2..6].try_into().unwrap());
+ let mut response = [0u8; 34];
+
+ if let Some(name) = fake_users().get(&employee_id) {
+ response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]);
+ let name_bytes = name.as_bytes();
+ let len = name_bytes.len().min(10);
+ response[12..12 + len].clone_from_slice(&name_bytes[..len]);
+ } else {
+ response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]);
+ }
+
+ response.to_vec()
+}
+
+/// Builds the 10-byte get-total-record-count response.
+#[allow(clippy::cast_possible_truncation)]
+fn get_total_record_count_response() -> Vec {
+ let count = fake_records().len() as u16;
+ let mut response = [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0];
+ response[4..6].clone_from_slice(&count.to_le_bytes());
+ response.to_vec()
+}
+
+/// Builds the 1038-byte get-record-bytes response: a 12-byte header, the
+/// requested 1024-byte block of records (`ff`-padded past the last real
+/// one, since records don't align to 1024-byte block boundaries), and 2
+/// trailing bytes.
+fn get_record_bytes_response(payload: &[u8]) -> Vec {
+ let sequence_number = u16::from_le_bytes(payload[8..10].try_into().unwrap());
+ let all_bytes: Vec = fake_records().iter().flat_map(encode_record).collect();
+
+ let start = sequence_number as usize * 1024;
+ let mut block = [0xffu8; 1024];
+ let available = all_bytes.len().saturating_sub(start).min(1024);
+ block[..available].clone_from_slice(&all_bytes[start..start + available]);
+
+ let mut response = vec![0u8; 1038];
+ response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]);
+ response[12..1036].clone_from_slice(&block);
+ response
+}
+
+/// Encodes a [`Record`] back into its 12-byte wire representation (the
+/// inverse of `Record`'s `TryFrom<&[u8]>`). The "unknown" bytes that
+/// `Record` doesn't decode are left as 0, since they only need to
+/// round-trip through our own parsing, not match a real device capture.
+fn encode_record(record: &Record) -> [u8; 12] {
+ let mut bytes = [0u8; 12];
+
+ let clock_bits: u8 = match record.clock {
+ Clock::FirstIn => 0,
+ Clock::FirstOut => 1,
+ Clock::SecondIn => 2,
+ Clock::SecondOut => 3,
+ };
+ bytes[1] = clock_bits << 6;
+
+ bytes[4..8].copy_from_slice(&record.employee_id.to_le_bytes());
+
+ #[allow(clippy::cast_sign_loss)]
+ let year = record.datetime.year() as u32 & 0x0fff;
+ let month = record.datetime.month() & 0x0f;
+ let day = record.datetime.day() & 0x1f;
+ let hour = record.datetime.hour() & 0x1f;
+ let minute = record.datetime.minute() & 0x3f;
+ let packed = year | (month << 12) | (day << 16) | (hour << 21) | (minute << 26);
+ bytes[8..12].copy_from_slice(&packed.to_le_bytes());
+
+ bytes
+}