Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2471ed548 | ||
|
|
9a637190b5 | ||
|
|
9977db0e46 | ||
|
|
f697660ff4 | ||
|
|
d8c6440a59 | ||
|
|
e0e453c9c5 | ||
|
|
9af4a82e39 | ||
|
|
1745ecc05e | ||
|
|
93f9e9d3af |
@@ -5,7 +5,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
container: docker.io/rust:1.79.0-alpine3.20
|
container: docker.io/rust:1.98.1-alpine3.24
|
||||||
steps:
|
steps:
|
||||||
- name: Install the dependencies
|
- name: Install the dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -27,5 +27,5 @@ jobs:
|
|||||||
- name: Check if the code is formatted correctly
|
- name: Check if the code is formatted correctly
|
||||||
run: cargo fmt --check
|
run: cargo fmt --check
|
||||||
|
|
||||||
- name: Check if Clippy has someting to say
|
- name: Check if Clippy has something to say
|
||||||
run: cargo clippy --all-targets
|
run: cargo clippy --all-targets
|
||||||
|
|||||||
Generated
+126
-4
@@ -1,6 +1,6 @@
|
|||||||
# This file is automatically @generated by Cargo.
|
# This file is automatically @generated by Cargo.
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android-tzdata"
|
name = "android-tzdata"
|
||||||
@@ -17,6 +17,28 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-stream"
|
||||||
|
version = "0.3.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||||
|
dependencies = [
|
||||||
|
"async-stream-impl",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-stream-impl"
|
||||||
|
version = "0.3.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -29,6 +51,12 @@ version = "3.16.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
|
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.0.99"
|
version = "1.0.99"
|
||||||
@@ -60,6 +88,12 @@ version = "0.8.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
|
checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iana-time-zone"
|
name = "iana-time-zone"
|
||||||
version = "0.1.60"
|
version = "0.1.60"
|
||||||
@@ -94,9 +128,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.155"
|
version = "0.2.189"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
@@ -104,6 +138,17 @@ version = "0.4.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
|
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -119,6 +164,12 @@ version = "1.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.85"
|
version = "1.0.85"
|
||||||
@@ -139,10 +190,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r701"
|
name = "r701"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-stream",
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -165,6 +219,16 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket2"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.66"
|
version = "2.0.66"
|
||||||
@@ -176,12 +240,55 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio"
|
||||||
|
version = "1.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"libc",
|
||||||
|
"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"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.12"
|
version = "1.0.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.92"
|
version = "0.2.92"
|
||||||
@@ -245,6 +352,21 @@ dependencies = [
|
|||||||
"windows-targets",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.5"
|
version = "0.52.5"
|
||||||
|
|||||||
+8
-2
@@ -1,13 +1,16 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "r701"
|
name = "r701"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "GNU AGPLv3.0"
|
license = "AGPL-3.0-or-later"
|
||||||
repository = "https://git.nicolabelluti.me/nicolabelluti/r701"
|
repository = "https://git.nicolabelluti.me/nicolabelluti/r701"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-stream = "0.3.6"
|
||||||
chrono = { version = "0.4.38", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4.38", default-features = false, features = ["clock"] }
|
||||||
serde = { version = "1.0.203", default-features = false, features = ["derive"], optional = true }
|
serde = { version = "1.0.203", default-features = false, features = ["derive"], optional = true }
|
||||||
|
tokio = { version = "1.53.1", default-features = false, features = ["net", "io-util", "sync"] }
|
||||||
|
tokio-stream = { version = "0.1.19", default-features = false }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
serde = ["chrono/serde", "dep:serde"]
|
serde = ["chrono/serde", "dep:serde"]
|
||||||
@@ -20,3 +23,6 @@ unwrap_used = "deny"
|
|||||||
enum_glob_use = { level = "deny", priority = 1 }
|
enum_glob_use = { level = "deny", priority = 1 }
|
||||||
pedantic = { level = "deny", priority = -1 }
|
pedantic = { level = "deny", priority = -1 }
|
||||||
nursery = { level = "deny", priority = -1 }
|
nursery = { level = "deny", priority = -1 }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.53.1", features = ["rt", "macros", "net"] }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# R701 🕰️
|
# R701 🕰️
|
||||||
|
|
||||||
[](https://www.rust-lang.org)
|
[](https://www.rust-lang.org)
|
||||||
[](https://brainmade.org)
|
[](https://claude.com/product/claude-code)
|
||||||
[](https://choosealicense.com/licenses/agpl-3.0)
|
[](https://choosealicense.com/licenses/agpl-3.0)
|
||||||
[](https://buymeacoffee.com/nicolabelluti)
|
[](https://buymeacoffee.com/nicolabelluti)
|
||||||
<br>
|
<br>
|
||||||
@@ -28,24 +28,28 @@ If you want to read about this reverse engineering attempt, check out
|
|||||||
|
|
||||||
2. Use the library in you project:
|
2. Use the library in you project:
|
||||||
|
|
||||||
|
This library is async and built on [tokio](https://tokio.rs); add it to your
|
||||||
|
own `[dependencies]` (e.g. `tokio = { version = "1", features =
|
||||||
|
["rt-multi-thread", "macros"] }`) to run the example below.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use r701::R701;
|
use r701::R701;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
fn main() {
|
#[tokio::main]
|
||||||
let mut r701 = R701::connect("127.0.0.1:5005").unwrap();
|
async fn main() {
|
||||||
|
let r701 = R701::connect("127.0.0.1:5005").await.unwrap();
|
||||||
|
let mut record_stream = r701.into_record_iter().await.unwrap();
|
||||||
|
let mut id = 0;
|
||||||
|
|
||||||
println!("No\tMchn\tEnNo\t\tName\t\tMode\tIOMd\tDateTime\t");
|
println!("No\tMchn\tEnNo\t\tName\t\tMode\tIOMd\tDateTime\t");
|
||||||
r701.into_record_iter()
|
while let Some(record) = record_stream.next().await {
|
||||||
.unwrap()
|
let record = record.unwrap();
|
||||||
.collect::<Vec<_>>()
|
let name = match r701.get_name(record.employee_id).await.unwrap() {
|
||||||
.iter()
|
Some(Ok(name)) => name,
|
||||||
.rev()
|
Some(Err(error)) => String::from_utf8_lossy(error.as_bytes()).into_owned(),
|
||||||
.enumerate()
|
None => format!("user #{}", record.employee_id),
|
||||||
.for_each(|(id, record)| {
|
};
|
||||||
let name = r701
|
|
||||||
.get_name(record.employee_id)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap_or(format!("user #{}", record.employee_id));
|
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"{:0>6}\t{}\t{:0>9}\t{: <10}\t{}\t{}\t{}",
|
"{:0>6}\t{}\t{:0>9}\t{: <10}\t{}\t{}\t{}",
|
||||||
@@ -57,6 +61,8 @@ If you want to read about this reverse engineering attempt, check out
|
|||||||
record.clock as u8,
|
record.clock as u8,
|
||||||
record.datetime.format("%Y/%m/%d %H:%M:%S"),
|
record.datetime.format("%Y/%m/%d %H:%M:%S"),
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
id += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 r701;
|
||||||
mod record;
|
mod record;
|
||||||
mod record_iterator;
|
mod record_iterator;
|
||||||
|
|||||||
+128
-48
@@ -1,27 +1,60 @@
|
|||||||
use crate::RecordIterator;
|
use crate::RecordIterator;
|
||||||
use std::io::{BufRead, BufReader, Error, ErrorKind::InvalidData, Result, Write};
|
use std::io::{Error, ErrorKind::InvalidData, Result};
|
||||||
use std::net::{TcpStream, ToSocketAddrs};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpStream, ToSocketAddrs};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct R701 {
|
struct ConnectionState {
|
||||||
tcp_stream: TcpStream,
|
tcp_stream: TcpStream,
|
||||||
sequence_number: u16,
|
sequence_number: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A connection to an R701 attendance reader.
|
||||||
|
///
|
||||||
|
/// Create one with [`R701::connect`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct R701 {
|
||||||
|
state: Mutex<ConnectionState>,
|
||||||
|
}
|
||||||
|
|
||||||
impl R701 {
|
impl R701 {
|
||||||
pub fn connect(connection_info: impl ToSocketAddrs) -> Result<Self> {
|
/// 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<Self> {
|
||||||
// Create a new R701 struct
|
// Create a new R701 struct
|
||||||
let mut new = Self {
|
let new = Self {
|
||||||
tcp_stream: TcpStream::connect(connection_info)?,
|
state: Mutex::new(ConnectionState {
|
||||||
|
tcp_stream: TcpStream::connect(connection_info).await?,
|
||||||
sequence_number: 0,
|
sequence_number: 0,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try to ping the endpoint
|
// Try to ping the endpoint
|
||||||
new.ping()?;
|
new.ping().await?;
|
||||||
Ok(new)
|
Ok(new)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn request(&mut self, payload: &[u8; 12]) -> Result<Vec<u8>> {
|
/// 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)]
|
||||||
|
pub async fn request(&self, payload: &[u8; 12], response_len: usize) -> Result<Vec<u8>> {
|
||||||
|
let mut state = self.state.lock().await;
|
||||||
|
|
||||||
// Create a blank request
|
// Create a blank request
|
||||||
let mut request = [0x55, 0xaa, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
let mut request = [0x55, 0xaa, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||||
|
|
||||||
@@ -29,21 +62,31 @@ impl R701 {
|
|||||||
request[2..14].clone_from_slice(payload);
|
request[2..14].clone_from_slice(payload);
|
||||||
|
|
||||||
// Insert the sequence number
|
// Insert the sequence number
|
||||||
request[14..].clone_from_slice(&self.sequence_number.to_le_bytes());
|
request[14..].clone_from_slice(&state.sequence_number.to_le_bytes());
|
||||||
self.sequence_number += 1;
|
state.sequence_number = state.sequence_number.wrapping_add(1);
|
||||||
|
|
||||||
// Send the request
|
// Send the request
|
||||||
self.tcp_stream.write_all(&request)?;
|
state.tcp_stream.write_all(&request).await?;
|
||||||
|
|
||||||
// Create a buffer and return the response
|
// Read exactly response_len bytes, looping over multiple reads if
|
||||||
let mut buffer = BufReader::new(&self.tcp_stream);
|
// the response arrives split across several TCP packets
|
||||||
Ok(buffer.fill_buf()?.to_vec())
|
let mut buffer = vec![0u8; response_len];
|
||||||
|
state.tcp_stream.read_exact(&mut buffer).await?;
|
||||||
|
Ok(buffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ping(&mut self) -> Result<()> {
|
/// 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
|
// Create a request with a payload of `01 80 00 00 00 00 00 00 00 00 00
|
||||||
// 00` and get the response
|
// 00` and get the response
|
||||||
let response = self.request(&[0x01, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])?;
|
let response = self
|
||||||
|
.request(&[0x01, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 10)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// If the response is not `aa 55 01 01 00 00 00 00 00 00` then return an
|
// If the response is not `aa 55 01 01 00 00 00 00 00 00` then return an
|
||||||
// error
|
// error
|
||||||
@@ -54,52 +97,69 @@ impl R701 {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_name(&mut self, id: u32) -> Result<Option<String>> {
|
/// 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,
|
||||||
|
) -> Result<Option<std::result::Result<String, std::string::FromUtf8Error>>> {
|
||||||
// Create a request with a payload of `01 c7 xx xx xx xx 00 00 00 00 14
|
// Create a request with a payload of `01 c7 xx xx xx xx 00 00 00 00 14
|
||||||
// 00`, where `xx xx xx xx` is the employee ID in little endian
|
// 00`, where `xx xx xx xx` is the employee ID in little endian
|
||||||
let mut request = [0x01, 0xc7, 0, 0, 0, 0, 0, 0, 0, 0, 0x14, 0];
|
let mut request = [0x01, 0xc7, 0, 0, 0, 0, 0, 0, 0, 0, 0x14, 0];
|
||||||
request[2..6].clone_from_slice(&id.to_le_bytes());
|
request[2..6].clone_from_slice(&id.to_le_bytes());
|
||||||
|
|
||||||
// Get the response
|
// Get the response
|
||||||
let response = self.request(&request)?;
|
let response = self.request(&request, 34).await?;
|
||||||
|
|
||||||
// If the response length is right but the header is `01 00 00 00 00 00
|
// If the header is `01 00 00 00 00 00 00 00` then the request has
|
||||||
// 00 00` then the request is been succesful but the name was not found
|
// been succesful but the name was not found
|
||||||
if response.len() == 34
|
if response[..12] == [0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa] {
|
||||||
&& response[..12] == [0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
|
|
||||||
{
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If one between the response length or the response header is wrong
|
// If the response header is wrong return an error
|
||||||
// return an error
|
if response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa] {
|
||||||
if response.len() != 34
|
|
||||||
|| response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
|
|
||||||
{
|
|
||||||
return Err(Error::new(InvalidData, "Malformed response"));
|
return Err(Error::new(InvalidData, "Malformed response"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the name as a UTF-8 string and delete the `\0` at the end
|
// Get the name as a UTF-8 string and delete the `\0` at the end
|
||||||
let name = String::from_utf8_lossy(&response[12..22])
|
let name = match String::from_utf8(response[12..22].to_vec()) {
|
||||||
.trim_end_matches('\0')
|
Ok(name) => name.trim_end_matches('\0').to_string(),
|
||||||
.to_string();
|
Err(error) => return Ok(Some(Err(error))),
|
||||||
|
};
|
||||||
|
|
||||||
// Return None if the name is empty, else return the name wrapped into a
|
// Return None if the name is empty, else return the name wrapped into a
|
||||||
// Some
|
// Some
|
||||||
Ok(Some(name).filter(|name| !name.is_empty()))
|
Ok(Some(name).filter(|name| !name.is_empty()).map(Ok))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_total_record_count(&mut self) -> Result<u16> {
|
/// 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<u16> {
|
||||||
// Create a request with a payload of `01 b4 08 00 00 00 00 00 ff ff 00
|
// Create a request with a payload of `01 b4 08 00 00 00 00 00 ff ff 00
|
||||||
// 00` and get the response
|
// 00` and get the response
|
||||||
let response = self.request(&[0x01, 0xb4, 0x08, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0])?;
|
let response = self
|
||||||
|
.request(&[0x01, 0xb4, 0x08, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0], 10)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// If one between the response length or the response header is wrong
|
// If one between the response header or the trailing bytes is wrong
|
||||||
// return an error
|
// return an error
|
||||||
if response.len() != 10
|
if response[..4] != [0xaa, 0x55, 0x01, 0x01] || response[6..] != [0u8; 4] {
|
||||||
|| response[..4] != [0xaa, 0x55, 0x01, 0x01]
|
|
||||||
|| response[6..] != [0u8; 4]
|
|
||||||
{
|
|
||||||
return Err(Error::new(InvalidData, "Malformed response"));
|
return Err(Error::new(InvalidData, "Malformed response"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,8 +169,20 @@ impl R701 {
|
|||||||
Ok(u16::from_le_bytes(record_count_le))
|
Ok(u16::from_le_bytes(record_count_le))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_record_bytes(
|
/// Fetches one 1024-byte block of raw record data.
|
||||||
&mut self,
|
///
|
||||||
|
/// `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,
|
total_records: u16,
|
||||||
sequence_number: u16,
|
sequence_number: u16,
|
||||||
) -> Result<Vec<u8>> {
|
) -> Result<Vec<u8>> {
|
||||||
@@ -131,12 +203,11 @@ impl R701 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the response
|
// Get the response
|
||||||
let response = self.request(&request)?;
|
let response = self.request(&request, 1038).await?;
|
||||||
|
|
||||||
// If one between the response length, the response header or the last
|
// If one between the response header or the last two bits is wrong
|
||||||
// two bits is wrong return an error
|
// return an error
|
||||||
if response.len() != 1038
|
if response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
|
||||||
|| response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
|
|
||||||
|| response[1036..] != [0, 0]
|
|| response[1036..] != [0, 0]
|
||||||
{
|
{
|
||||||
return Err(Error::new(InvalidData, "Malformed response"));
|
return Err(Error::new(InvalidData, "Malformed response"));
|
||||||
@@ -146,7 +217,16 @@ impl R701 {
|
|||||||
Ok(response[12..1036].to_vec())
|
Ok(response[12..1036].to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_record_iter(&mut self) -> Result<RecordIterator> {
|
/// Starts streaming all attendance records currently stored on the
|
||||||
RecordIterator::from(self)
|
/// 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<'_>> {
|
||||||
|
RecordIterator::from(self).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-9
@@ -2,33 +2,45 @@ use chrono::{DateTime, Local, TimeZone};
|
|||||||
#[cfg(feature = "serde")]
|
#[cfg(feature = "serde")]
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
/// 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))]
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
pub enum Clock {
|
pub enum Clock {
|
||||||
#[default]
|
/// Entering during the first time slot
|
||||||
FirstIn,
|
FirstIn,
|
||||||
|
/// Leaving during the first time slot
|
||||||
FirstOut,
|
FirstOut,
|
||||||
|
/// Entering during the second time slot
|
||||||
SecondIn,
|
SecondIn,
|
||||||
|
/// Leaving during the second time slot
|
||||||
SecondOut,
|
SecondOut,
|
||||||
}
|
}
|
||||||
|
|
||||||
// unsafe impl Send for Clock {}
|
/// A single attendance record read from the device.
|
||||||
// unsafe impl Sync for Clock {}
|
///
|
||||||
|
/// Identifies the employee who punched, whether they were entering or
|
||||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
/// leaving, and when.
|
||||||
|
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
||||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
pub struct Record {
|
pub struct Record {
|
||||||
|
/// The employee's numeric ID, as configured on the device
|
||||||
pub employee_id: u32,
|
pub employee_id: u32,
|
||||||
|
/// Whether the employee was entering or leaving
|
||||||
pub clock: Clock,
|
pub clock: Clock,
|
||||||
|
/// When the record was punched
|
||||||
pub datetime: DateTime<Local>,
|
pub datetime: DateTime<Local>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// unsafe impl Send for Record {}
|
|
||||||
// unsafe impl Sync for Record {}
|
|
||||||
|
|
||||||
impl TryFrom<&[u8]> for Record {
|
impl TryFrom<&[u8]> for Record {
|
||||||
type Error = &'static str;
|
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<Self, Self::Error> {
|
fn try_from(record_bytes: &[u8]) -> Result<Self, Self::Error> {
|
||||||
// Return an error if the slice length is less than 12
|
// Return an error if the slice length is less than 12
|
||||||
if record_bytes.len() != 12 {
|
if record_bytes.len() != 12 {
|
||||||
|
|||||||
+70
-61
@@ -1,86 +1,95 @@
|
|||||||
use crate::{Record, R701};
|
use crate::{Record, R701};
|
||||||
use std::io::Result;
|
use std::io::{Error, ErrorKind::InvalidData, Result};
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
|
use tokio_stream::Stream;
|
||||||
|
|
||||||
#[derive(Debug)]
|
/// 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> {
|
pub struct RecordIterator<'a> {
|
||||||
r701: &'a mut R701,
|
inner: Pin<Box<dyn Stream<Item = Result<Record>> + 'a>>,
|
||||||
input_buffer: Vec<u8>,
|
|
||||||
sequence_number: u16,
|
|
||||||
total_records: u16,
|
|
||||||
record_count: u16,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> RecordIterator<'a> {
|
impl<'a> RecordIterator<'a> {
|
||||||
pub fn from(r701: &'a mut R701) -> Result<Self> {
|
/// 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<Self> {
|
||||||
// Get the total number of records
|
// Get the total number of records
|
||||||
let total_records = r701.get_total_record_count()?;
|
let total_records = r701.get_total_record_count().await?;
|
||||||
|
|
||||||
// Calculate the sequence number on which the last record resides and
|
// Calculate the sequence number on which the last record resides and
|
||||||
// the index of the first `ff` byte, avoiding overflows
|
// the index of the first `ff` byte. The multiplication is done in
|
||||||
//
|
// u32 to avoid overflowing total_records * 12 (up to 786_420); the
|
||||||
// TODO: Find a better way to do the multiplication and the division
|
// division result (at most 786_420 / 1024 = 768) always fits back
|
||||||
// avoiding overflows
|
// into a u16, so the truncating cast below can never actually lose
|
||||||
|
// data
|
||||||
#[allow(clippy::cast_possible_truncation)]
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
let sequence_number = (u32::from(total_records) * 12 / 1024) as u16;
|
let mut sequence_number = (u32::from(total_records) * 12 / 1024) as u16;
|
||||||
let first_useless_byte_index = total_records as usize * 12 % 1024;
|
let first_useless_byte_index = total_records as usize * 12 % 1024;
|
||||||
|
|
||||||
// The endpoint expects the first block of records to be sent first
|
// The endpoint expects the first block of records to be sent first
|
||||||
r701.get_record_bytes(total_records, 0)?;
|
r701.get_record_bytes(total_records, 0).await?;
|
||||||
|
|
||||||
// Get the last records and cut out all the trailing `ff` bytes
|
// Get the last records and cut out all the trailing `ff` bytes
|
||||||
let input_buffer = r701
|
let mut input_buffer = r701
|
||||||
.get_record_bytes(total_records, sequence_number)?
|
.get_record_bytes(total_records, sequence_number)
|
||||||
.drain(..first_useless_byte_index)
|
.await?;
|
||||||
.collect::<Vec<u8>>();
|
input_buffer.truncate(first_useless_byte_index);
|
||||||
|
|
||||||
|
let mut record_count = total_records;
|
||||||
|
|
||||||
|
// Build the record stream, yielding an `Err` both on network errors and
|
||||||
|
// on malformed records, so the caller can fail
|
||||||
|
let inner = async_stream::try_stream! {
|
||||||
|
while record_count != 0 {
|
||||||
|
record_count -= 1;
|
||||||
|
|
||||||
|
// If the input buffer is almost empty, make another request to
|
||||||
|
// the endpoint asking for more data
|
||||||
|
if input_buffer.len() < 12 {
|
||||||
|
// If the buffer is almost empty but the sequence number is
|
||||||
|
// already zero, stop iterating
|
||||||
|
if sequence_number == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
sequence_number -= 1;
|
||||||
|
|
||||||
|
// Request new bytes
|
||||||
|
let bytes = r701.get_record_bytes(total_records, sequence_number).await?;
|
||||||
|
|
||||||
|
// Put the bytes at the start of the vector
|
||||||
|
input_buffer.splice(0..0, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield a new Record
|
||||||
|
yield input_buffer
|
||||||
|
.drain(input_buffer.len() - 12..)
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|error: &str| Error::new(InvalidData, error))?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Return a new Iterator
|
// Return a new Iterator
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
r701,
|
inner: Box::pin(inner),
|
||||||
input_buffer,
|
|
||||||
sequence_number,
|
|
||||||
total_records,
|
|
||||||
record_count: total_records,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Iterator for RecordIterator<'a> {
|
impl Stream for RecordIterator<'_> {
|
||||||
type Item = Record;
|
type Item = Result<Record>;
|
||||||
|
|
||||||
fn next(&mut self) -> Option<Self::Item> {
|
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||||
// Stop iterating when there are no more records
|
self.inner.as_mut().poll_next(cx)
|
||||||
if self.record_count == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.record_count -= 1;
|
|
||||||
|
|
||||||
// If the input buffer is almost empty, make another request to the
|
|
||||||
// endpoint asking for more data
|
|
||||||
if self.input_buffer.len() < 12 {
|
|
||||||
// If the buffer is almost empty but the sequence number is already
|
|
||||||
// zero, stop iterating
|
|
||||||
if self.sequence_number == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.sequence_number -= 1;
|
|
||||||
|
|
||||||
// Request new bytes
|
|
||||||
let bytes = self
|
|
||||||
.r701
|
|
||||||
.get_record_bytes(self.total_records, self.sequence_number)
|
|
||||||
.ok()?;
|
|
||||||
|
|
||||||
// Put the bytes at the start of the vector
|
|
||||||
self.input_buffer.splice(0..0, bytes.iter().copied());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return a new Record
|
|
||||||
self.input_buffer
|
|
||||||
.drain(self.input_buffer.len() - 12..)
|
|
||||||
.as_slice()
|
|
||||||
.try_into()
|
|
||||||
.ok()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
//! End-to-end tests exercising R701's public API against the emulated
|
||||||
|
//! device in `mock`.
|
||||||
|
|
||||||
|
mod mock;
|
||||||
|
|
||||||
|
use r701::R701;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connects_and_pings_successfully() {
|
||||||
|
let addr = mock::spawn().await;
|
||||||
|
assert!(R701::connect(addr).await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
async fn gets_employee_name() {
|
||||||
|
let addr = mock::spawn().await;
|
||||||
|
let r701 = R701::connect(addr).await.unwrap();
|
||||||
|
let expected_name = mock::fake_users()[&1].clone();
|
||||||
|
|
||||||
|
match r701.get_name(1).await.unwrap() {
|
||||||
|
Some(Ok(name)) => assert_eq!(name, expected_name),
|
||||||
|
other => panic!("expected Some(Ok({expected_name:?})), got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
async fn gets_none_for_unknown_employee() {
|
||||||
|
let addr = mock::spawn().await;
|
||||||
|
let r701 = R701::connect(addr).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(r701.get_name(999).await.unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[allow(clippy::unwrap_used, clippy::cast_possible_truncation)]
|
||||||
|
async fn gets_total_record_count() {
|
||||||
|
let addr = mock::spawn().await;
|
||||||
|
let r701 = R701::connect(addr).await.unwrap();
|
||||||
|
let expected_count = mock::fake_records().len() as u16;
|
||||||
|
|
||||||
|
assert_eq!(r701.get_total_record_count().await.unwrap(), expected_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
async fn streams_records() {
|
||||||
|
let addr = mock::spawn().await;
|
||||||
|
let r701 = R701::connect(addr).await.unwrap();
|
||||||
|
// The device streams newest first, the reverse of fake_records' order
|
||||||
|
let expected_records: Vec<_> = mock::fake_records().into_iter().rev().collect();
|
||||||
|
|
||||||
|
let mut records = r701.into_record_iter().await.unwrap();
|
||||||
|
for expected in expected_records {
|
||||||
|
let record = records.next().await.unwrap().unwrap();
|
||||||
|
assert_eq!(record, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(records.next().await.is_none());
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
//! Emulates an R701 device so `tests/e2e.rs` can exercise the real `R701`
|
||||||
|
//! client against it, grounded in the protocol documented at
|
||||||
|
//! <https://nicolabelluti.me/posts/2024/05/studying-a-communication-protocol/>.
|
||||||
|
|
||||||
|
use chrono::{Datelike, Duration, 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
|
||||||
|
pub fn fake_users() -> HashMap<u32, String> {
|
||||||
|
HashMap::from([(1, "alice".to_string()), (2, "bob".to_string())])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The attendance records the fake device reports: a full year of classic
|
||||||
|
/// workday punches (09:00 - 13:00, 14:00 - 18:00) for the employees in
|
||||||
|
/// `fake_users()`, staggered by punch time rather than grouped by employee.
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
pub fn fake_records() -> Vec<Record> {
|
||||||
|
let first_day = Local
|
||||||
|
.with_ymd_and_hms(2026, 1, 1, 9, 0, 0)
|
||||||
|
.single()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut employee_ids: Vec<u32> = fake_users().into_keys().collect();
|
||||||
|
employee_ids.sort_unstable();
|
||||||
|
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for day in 0..365 {
|
||||||
|
let morning_in = first_day + Duration::days(day);
|
||||||
|
let morning_out = morning_in + Duration::hours(4);
|
||||||
|
let afternoon_in = morning_out + Duration::hours(1);
|
||||||
|
let afternoon_out = afternoon_in + Duration::hours(4);
|
||||||
|
|
||||||
|
for (clock, datetime) in [
|
||||||
|
(Clock::FirstIn, morning_in),
|
||||||
|
(Clock::FirstOut, morning_out),
|
||||||
|
(Clock::SecondIn, afternoon_in),
|
||||||
|
(Clock::SecondOut, afternoon_out),
|
||||||
|
] {
|
||||||
|
for &employee_id in &employee_ids {
|
||||||
|
records.push(Record {
|
||||||
|
employee_id,
|
||||||
|
clock,
|
||||||
|
datetime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a fake R701 device in the background and returns the address to
|
||||||
|
/// connect to.
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
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.
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
fn get_name_response(payload: &[u8]) -> Vec<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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.
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
fn get_record_bytes_response(payload: &[u8]) -> Vec<u8> {
|
||||||
|
let sequence_number = u16::from_le_bytes(payload[8..10].try_into().unwrap());
|
||||||
|
let all_bytes: Vec<u8> = 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user