//! 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 }