Compare commits
13
Commits
0dd05c0d91
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2471ed548 | ||
|
|
9a637190b5 | ||
|
|
9977db0e46 | ||
|
|
f697660ff4 | ||
|
|
d8c6440a59 | ||
|
|
e0e453c9c5 | ||
|
|
9af4a82e39 | ||
|
|
1745ecc05e | ||
|
|
93f9e9d3af | ||
|
|
07ef2a96bc | ||
|
|
b2b5f0c66c | ||
|
|
987af19ec8 | ||
|
|
35e75745db |
@@ -0,0 +1,31 @@
|
|||||||
|
name: Check, format and test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
container: docker.io/rust:1.98.1-alpine3.24
|
||||||
|
steps:
|
||||||
|
- name: Install the dependencies
|
||||||
|
run: |
|
||||||
|
rustup component add clippy rustfmt &&
|
||||||
|
apk update &&
|
||||||
|
apk add musl-dev git npm
|
||||||
|
|
||||||
|
- name: Checkout the code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Check if the code compiles
|
||||||
|
run: cargo check
|
||||||
|
|
||||||
|
- name: Check if the tests run correctly
|
||||||
|
run: cargo test
|
||||||
|
|
||||||
|
- name: Check if the code is formatted correctly
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
|
- name: Check if Clippy has something to say
|
||||||
|
run: cargo clippy --all-targets
|
||||||
Generated
+139
-17
@@ -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"
|
||||||
@@ -30,10 +52,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
|
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "bytes"
|
||||||
version = "1.0.96"
|
version = "1.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "065a29261d53ba54260972629f9ca6bffa69bac13cd1fed61420f7fa68b9f8bd"
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.0.99"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96c51067fd44124faa7f870b4b1c969379ad32b2ba805aa959430ceaa384f695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
@@ -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.154"
|
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 = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346"
|
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"
|
||||||
@@ -120,10 +165,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "pin-project-lite"
|
||||||
version = "1.0.81"
|
version = "0.2.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.85"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -139,26 +190,29 @@ 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]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.200"
|
version = "1.0.203"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f"
|
checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_derive"
|
name = "serde_derive"
|
||||||
version = "1.0.200"
|
version = "1.0.203"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb"
|
checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -166,22 +220,75 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "socket2"
|
||||||
version = "2.0.60"
|
version = "0.6.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3"
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.66"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"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"
|
||||||
|
|||||||
+19
-4
@@ -1,13 +1,28 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "r701"
|
name = "r701"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
license = "AGPL-3.0-or-later"
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
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.200", 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"]
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
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"] }
|
||||||
|
|||||||
@@ -1,8 +1,68 @@
|
|||||||
# R701 🦀
|
<div align="center">
|
||||||
|
|
||||||
|
# R701 🕰️
|
||||||
|
|
||||||
|
[](https://www.rust-lang.org)
|
||||||
|
[](https://claude.com/product/claude-code)
|
||||||
|
[](https://choosealicense.com/licenses/agpl-3.0)
|
||||||
|
[](https://buymeacoffee.com/nicolabelluti)
|
||||||
|
<br>
|
||||||
|
[](https://git.nicolabelluti.me/nicolabelluti/r701/actions/?workflow=check-format-and-test.yaml)
|
||||||
|
|
||||||
|
</div><br>
|
||||||
|
|
||||||
> A reverse-engineered library to communicate with the
|
> A reverse-engineered library to communicate with the
|
||||||
> [R701](https://ipsattendant.it/rilevatore-presenze-r701/) by [I.P.S.
|
> [R701](https://ipsattendant.it/rilevatore-presenze-r701/) by [I.P.S.
|
||||||
> Informatica](https://ipsinformatica.info/) via TCP, written in Rust
|
> Informatica](https://ipsinformatica.info/), written in Rust
|
||||||
|
|
||||||
If you want to read about this reverse engineering attempt you can check out
|
If you want to read about this reverse engineering attempt, check out
|
||||||
<https://nicolabelluti.me/series/attendance-reader/>.
|
<https://nicolabelluti.me/series/attendance-reader/>.
|
||||||
|
|
||||||
|
## Library Usage
|
||||||
|
|
||||||
|
1. Add the library to you project:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cargo add r701 --git https://git.nicolabelluti.me/nicolabelluti/r701.git
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
use r701::R701;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
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");
|
||||||
|
while let Some(record) = record_stream.next().await {
|
||||||
|
let record = record.unwrap();
|
||||||
|
let name = match r701.get_name(record.employee_id).await.unwrap() {
|
||||||
|
Some(Ok(name)) => name,
|
||||||
|
Some(Err(error)) => String::from_utf8_lossy(error.as_bytes()).into_owned(),
|
||||||
|
None => format!("user #{}", record.employee_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{:0>6}\t{}\t{:0>9}\t{: <10}\t{}\t{}\t{}",
|
||||||
|
id + 1,
|
||||||
|
1,
|
||||||
|
record.employee_id,
|
||||||
|
name,
|
||||||
|
35,
|
||||||
|
record.clock as u8,
|
||||||
|
record.datetime.format("%Y/%m/%d %H:%M:%S"),
|
||||||
|
);
|
||||||
|
|
||||||
|
id += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
Generated
-27
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1714906307,
|
|
||||||
"narHash": "sha256-UlRZtrCnhPFSJlDQE7M0eyhgvuuHBTe1eJ9N9AQlJQ0=",
|
|
||||||
"owner": "nixos",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "25865a40d14b3f9cf19f19b924e2ab4069b09588",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nixos",
|
|
||||||
"ref": "nixos-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
description = "Rust setup";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs, ... }:
|
|
||||||
let
|
|
||||||
system = "x86_64-linux";
|
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
|
||||||
in {
|
|
||||||
devShells.${system}.default = pkgs.mkShell {
|
|
||||||
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
cargo
|
|
||||||
clippy
|
|
||||||
rustfmt
|
|
||||||
];
|
|
||||||
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
+129
-49
@@ -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 {
|
||||||
sequence_number: 0,
|
tcp_stream: TcpStream::connect(connection_info).await?,
|
||||||
|
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 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-17
@@ -2,37 +2,49 @@ 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 {
|
||||||
return Err("Slice must be at least of length 12 to be converted into Record");
|
return Err("Slice must be of length 12 to be converted into Record");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the employee ID from the central 4 bytes of the record in
|
// Extract the employee ID from the central 4 bytes of the record in
|
||||||
@@ -48,6 +60,7 @@ impl TryFrom<&[u8]> for Record {
|
|||||||
let datetime = u32::from_le_bytes(datetime);
|
let datetime = u32::from_le_bytes(datetime);
|
||||||
|
|
||||||
// Return a new Record
|
// Return a new Record
|
||||||
|
#[allow(clippy::cast_possible_wrap)]
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
employee_id,
|
employee_id,
|
||||||
clock: match record_bytes[1] >> 6 {
|
clock: match record_bytes[1] >> 6 {
|
||||||
@@ -55,7 +68,7 @@ impl TryFrom<&[u8]> for Record {
|
|||||||
1 => Clock::FirstOut,
|
1 => Clock::FirstOut,
|
||||||
2 => Clock::SecondIn,
|
2 => Clock::SecondIn,
|
||||||
3 => Clock::SecondOut,
|
3 => Clock::SecondOut,
|
||||||
_ => panic!("Math has broken"),
|
_ => unreachable!("Math has broken"),
|
||||||
},
|
},
|
||||||
datetime: Local
|
datetime: Local
|
||||||
.with_ymd_and_hms(
|
.with_ymd_and_hms(
|
||||||
@@ -94,9 +107,9 @@ mod tests {
|
|||||||
datetime: Local
|
datetime: Local
|
||||||
.with_ymd_and_hms(1970, 1, 1, 0, 0, 0)
|
.with_ymd_and_hms(1970, 1, 1, 0, 0, 0)
|
||||||
.single()
|
.single()
|
||||||
.unwrap(),
|
.expect("Datetime is not unique!"),
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -105,8 +118,8 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
record_bytes.try_into(),
|
record_bytes.try_into(),
|
||||||
Err::<Record, &str>("Slice must be at least of length 12 to be converted into Record")
|
Err::<Record, &str>("Slice must be of length 12 to be converted into Record")
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -116,6 +129,6 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
record_bytes.try_into(),
|
record_bytes.try_into(),
|
||||||
Err::<Record, &str>("DateTime conversion error")
|
Err::<Record, &str>("DateTime conversion error")
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-58
@@ -1,82 +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
|
||||||
let sequence_number = (total_records as u32 * 12 / 1024) as u16;
|
// u32 to avoid overflowing total_records * 12 (up to 786_420); the
|
||||||
|
// division result (at most 786_420 / 1024 = 768) always fits back
|
||||||
|
// into a u16, so the truncating cast below can never actually lose
|
||||||
|
// data
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
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