9 Commits
Author SHA1 Message Date
nicolabellutiandClaude Sonnet 5 c2471ed548 Bumped version to 0.2.0
Check, format and test / build (push) Successful in 48s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 15:27:43 +02:00
nicolabellutiandClaude Sonnet 5 9a637190b5 Expanded the e2e test suite and fake device data
Check, format and test / build (push) Successful in 46s
- fake_records now generates a full year (2026) of classic workday
  punches (09:00-13:00, 14:00-18:00) for the employees in fake_users;
  this spans many 1024-byte blocks, exercising the multi-block
  streaming path
- fake_users/fake_records are now public so tests can assert against
  them instead of hardcoded expectations
- scoped #[allow(clippy::unwrap_used)] to only the functions that
  actually use unwrap, instead of a blanket file-level allow
- added gets_total_record_count, streams_records, and
  gets_none_for_unknown_employee tests

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 15:25:18 +02:00
nicolabellutiandClaude Sonnet 5 9977db0e46 Added an e2e test harness with a fake R701 device
Check, format and test / build (push) Successful in 47s
tests/mock/mod.rs emulates the device's TCP protocol (ping, get_name,
get_total_record_count, get_record_bytes), grounded in the reverse
engineering notes at
https://nicolabelluti.me/posts/2024/05/studying-a-communication-protocol/,
so tests/e2e.rs can exercise the real R701 client against it instead of
needing a physical reader. Starts with one test (connect + ping);
more scenarios (get_name, record streaming, error cases) to follow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 14:47:29 +02:00
nicolabellutiandClaude Sonnet 5 f697660ff4 Documented the public API and removed the missing_errors_doc allows
Check, format and test / build (push) Successful in 42s
Added rustdoc comments to every public item (R701, Record, Clock,
RecordIterator and their methods/fields/variants), each with an
# Errors section where applicable, so the clippy::missing_errors_doc
allows are no longer needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 15:07:40 +02:00
nicolabelluti d8c6440a59 Re-added npm to the CI/CD pipeline
Check, format and test / build (push) Successful in 33s
2026-10-01 14:49:02 +02:00
nicolabellutiandClaude Sonnet 5 e0e453c9c5 Cleaned up the protocol layer and metadata
Check, format and test / build (push) Failing after 5s
- request() now reads exactly the expected response length with
  read_exact instead of a single non-guaranteed fill_buf, fixing a
  latent bug where a response split across multiple TCP packets could
  be mistaken for malformed data; the per-method length checks are now
  redundant and were removed
- sequence_number wraps explicitly via wrapping_add instead of
  relying on implicit (and debug-vs-release inconsistent) overflow
  behavior after 65536 requests
- get_name validates UTF-8 strictly instead of silently lossy-decoding
  invalid bytes; on invalid data it now returns the FromUtf8Error
  itself (byte-level \0 trimming moved after the UTF-8 check), letting
  the caller choose to fail or fall back to a lossy rendering
- removed the unused Default derive from Clock and Record
- removed npm from the CI image (no JS/Node in this repo), fixed a
  typo in a CI step name
- fixed the Cargo.toml license field to the actual SPDX identifier
  (AGPL-3.0-or-later, per the notice at the end of LICENSE) instead of
  the free-text "GNU AGPLv3.0"

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 14:43:50 +02:00
nicolabellutiandClaude Sonnet 5 9af4a82e39 Made R701 shareable via an internal Mutex, allowing get_name to be called while streaming records
Check, format and test / build (push) Successful in 34s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 14:02:47 +02:00
nicolabellutiandClaude Sonnet 5 1745ecc05e Made the library async using tokio and the iterator return a Result
Check, format and test / build (push) Successful in 33s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 13:28:32 +02:00
nicolabellutiandClaude Sonnet 5 93f9e9d3af Updated the Rust version used by the CI/CD and silenced clippy's missing_errors_doc lint
Check, format and test / build (push) Successful in 1m13s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 12:44:07 +02:00
10 changed files with 634 additions and 156 deletions
+2 -2
View File
@@ -5,7 +5,7 @@ on:
jobs:
build:
container: docker.io/rust:1.79.0-alpine3.20
container: docker.io/rust:1.98.1-alpine3.24
steps:
- name: Install the dependencies
run: |
@@ -27,5 +27,5 @@ jobs:
- name: Check if the code is formatted correctly
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
Generated
+126 -4
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "android-tzdata"
@@ -17,6 +17,28 @@ dependencies = [
"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]]
name = "autocfg"
version = "1.3.0"
@@ -29,6 +51,12 @@ version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.0.99"
@@ -60,6 +88,12 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "iana-time-zone"
version = "0.1.60"
@@ -94,9 +128,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.155"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "log"
@@ -104,6 +138,17 @@ version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "num-traits"
version = "0.2.19"
@@ -119,6 +164,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.85"
@@ -139,10 +190,13 @@ dependencies = [
[[package]]
name = "r701"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"async-stream",
"chrono",
"serde",
"tokio",
"tokio-stream",
]
[[package]]
@@ -165,6 +219,16 @@ dependencies = [
"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]]
name = "syn"
version = "2.0.66"
@@ -176,12 +240,55 @@ dependencies = [
"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]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "wasm-bindgen"
version = "0.2.92"
@@ -245,6 +352,21 @@ dependencies = [
"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]]
name = "windows-targets"
version = "0.52.5"
+8 -2
View File
@@ -1,13 +1,16 @@
[package]
name = "r701"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "GNU AGPLv3.0"
license = "AGPL-3.0-or-later"
repository = "https://git.nicolabelluti.me/nicolabelluti/r701"
[dependencies]
async-stream = "0.3.6"
chrono = { version = "0.4.38", default-features = false, features = ["clock"] }
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]
serde = ["chrono/serde", "dep:serde"]
@@ -20,3 +23,6 @@ unwrap_used = "deny"
enum_glob_use = { level = "deny", priority = 1 }
pedantic = { level = "deny", priority = -1 }
nursery = { level = "deny", priority = -1 }
[dev-dependencies]
tokio = { version = "1.53.1", features = ["rt", "macros", "net"] }
+31 -25
View File
@@ -3,7 +3,7 @@
# R701 🕰️
[![Rust](https://img.shields.io/badge/Rust-f74c00?logo=rust)](https://www.rust-lang.org)
[![Brain made](https://img.shields.io/badge/Brainmade-grey?logo=data:image/svg%2bxml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgd2lkdGg9IjY2LjUzOCIKICAgaGVpZ2h0PSI3OC43ODIiCiAgIGZpbGw9Im5vbmUiCiAgIHZlcnNpb249IjEuMSIKICAgaWQ9InN2ZzMiCiAgIHNvZGlwb2RpOmRvY25hbWU9IndoaXRlLWxvZ28taGVhZDMuc3ZnIgogICBpbmtzY2FwZTp2ZXJzaW9uPSIxLjMuMiAoMDkxZTIwZWYwZiwgMjAyMy0xMS0yNSwgY3VzdG9tKSIKICAgeG1sbnM6aW5rc2NhcGU9Imh0dHA6Ly93d3cuaW5rc2NhcGUub3JnL25hbWVzcGFjZXMvaW5rc2NhcGUiCiAgIHhtbG5zOnNvZGlwb2RpPSJodHRwOi8vc29kaXBvZGkuc291cmNlZm9yZ2UubmV0L0RURC9zb2RpcG9kaS0wLmR0ZCIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaWQ9Im5hbWVkdmlldzMiCiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIgogICAgIGJvcmRlcmNvbG9yPSIjMDAwMDAwIgogICAgIGJvcmRlcm9wYWNpdHk9IjAuMjUiCiAgICAgaW5rc2NhcGU6c2hvd3BhZ2VzaGFkb3c9IjIiCiAgICAgaW5rc2NhcGU6cGFnZW9wYWNpdHk9IjAuMCIKICAgICBpbmtzY2FwZTpwYWdlY2hlY2tlcmJvYXJkPSIwIgogICAgIGlua3NjYXBlOmRlc2tjb2xvcj0iI2QxZDFkMSIKICAgICBpbmtzY2FwZTp6b29tPSIxMC43NjM4OCIKICAgICBpbmtzY2FwZTpjeD0iMzMuMjU5MzgxIgogICAgIGlua3NjYXBlOmN5PSIzOS4zOTA5OTkiCiAgICAgaW5rc2NhcGU6d2luZG93LXdpZHRoPSIxOTIwIgogICAgIGlua3NjYXBlOndpbmRvdy1oZWlnaHQ9IjEwNTIiCiAgICAgaW5rc2NhcGU6d2luZG93LXg9IjAiCiAgICAgaW5rc2NhcGU6d2luZG93LXk9IjAiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJzdmczIiAvPgogIDxnCiAgICAgY2xpcC1wYXRoPSJ1cmwoI2EpIgogICAgIHN0eWxlPSJmaWxsOiNmZmZmZmYiCiAgICAgZmlsbD0iI2ZmZiIKICAgICBpZD0iZzIiPgogICAgPHBhdGgKICAgICAgIGQ9Ik01Mi42MTIgNzguNzgySDIzLjMzYTIuNTU5IDIuNTU5IDAgMCAxLTIuNTYtMi41NTl2LTcuNjc2aC03Ljk3M2EyLjU2IDIuNTYgMCAwIDEtMi41Ni0yLjU2VjU1LjMxNWwtOC44Mi00LjM5N2EyLjU1OSAyLjU1OSAwIDAgMS0uOTg2LTMuNzFsOS44MDctMTQuNzE0di00LjM1QzEwLjI0IDEyLjU5OSAyMi44NDMgMCAzOC4zODggMCA1My45MzIgMCA2Ni41MzQgMTIuNiA2Ni41MzggMjguMTQzYy0uNjMyIDI3LjgyNC0xMC43NiAyMy41MTYtMTEuMTggMzQuMDQ1bC0uMTg3IDE0LjAzNWEyLjU5IDIuNTkgMCAwIDEtLjc1IDEuODEgMi41NSAyLjU1IDAgMCAxLTEuODA5Ljc1em0tMjYuNzIzLTUuMTE3aDI0LjE2NGwuMjg2LTE0LjU0MmMtLjI2My02LjY1NiAxMS43MTYtOC4yNDMgMTEuMDgtMzAuNzM0LS4zNTgtMTIuNzEzLTEwLjMxMy0yMy4yNzEtMjMuMDMxLTIzLjI3MS0xMi43MTggMC0yMy4wMjkgMTAuMzA3LTIzLjAzMiAyMy4wMjV2NS4xMThjMCAuNTA1LS4xNS45OTktLjQzIDEuNDJsLTguNjMgMTIuOTQgNy42NDUgMy44MmEyLjU1OSAyLjU1OSAwIDAgMSAxLjQxNSAyLjI5MXY5LjY5N2g3Ljk3NGEyLjU1OSAyLjU1OSAwIDAgMSAyLjU2IDIuNTU5eiIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmYiCiAgICAgICBpZD0icGF0aDEiIC8+CiAgICA8cGF0aAogICAgICAgZD0iTTQwLjM3MiA1OC4yMjJWMzguOTM0Yy4xMTggMCAuMjM3LjAxOC4zNTUuMDE4IDkuNzY5LS4wMTIgMTcuMDUtOS4wMTIgMTUuMDIyLTE4LjU2N2EyLjM2NiAyLjM2NiAwIDAgMC0xLjgyMS0xLjgyMmMtOC4xMDYtMS43My0xNi4xMjEgMy4yOTItMTguMDk4IDExLjM0MS0uMDI0LS4wMjQtLjA0My0uMDUtLjA2Ni0uMDczYTE1LjMyMyAxNS4zMjMgMCAwIDAtMTQuMDYtNC4xNyAyLjM2NSAyLjM2NSAwIDAgMC0xLjgyMSAxLjgyYy0yLjAyOCA5LjU1NSA1LjI1MiAxOC41NTQgMTUuMDIgMTguNTY4LjIzNiAwIC40OTItLjAyOC43MzgtLjA0djEyLjIxM1ptMi44MzktMzIuMTQzYTEwLjY0NiAxMC42NDYgMCAwIDEgOC4xMjQtMy4xMDZjLjM1IDYuMzQtNC44ODggMTEuNTc3LTExLjIyOCAxMS4yM2ExMC41OCAxMC41OCAwIDAgMSAzLjEwNC04LjEyNHpNMjcuNDAzIDM4LjE5M2ExMC42MDcgMTAuNjA3IDAgMCAxLTMuMTE4LTguMTIzYzYuMzQ0LS4zNTggMTEuNTg3IDQuODg2IDExLjIyOCAxMS4yMy0zLjAyMy4xNjktNS45NzMtLjk2MS04LjExLTMuMTA3eiIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmYiCiAgICAgICBpZD0icGF0aDIiIC8+CiAgPC9nPgogIDxkZWZzCiAgICAgaWQ9ImRlZnMzIj4KICAgIDxjbGlwUGF0aAogICAgICAgaWQ9ImEiPgogICAgICA8cGF0aAogICAgICAgICBmaWxsPSIjZmZmIgogICAgICAgICBkPSJNMCAwaDI1NnY4MEgweiIKICAgICAgICAgaWQ9InBhdGgzIiAvPgogICAgPC9jbGlwUGF0aD4KICA8L2RlZnM+Cjwvc3ZnPgo=)](https://brainmade.org)
[![Claude Code](https://img.shields.io/badge/Claude%20Code-grey?logo=claude&logoColor=d97757)](https://claude.com/product/claude-code)
[![GNU AGPLv3.0 License](https://img.shields.io/badge/License-GNU%20AGPLv3.0-dark_green?logo=gnu)](https://choosealicense.com/licenses/agpl-3.0)
[![Buymeacoffee](https://img.shields.io/badge/Buymeacoffee-gray?logo=buymeacoffee)](https://buymeacoffee.com/nicolabelluti)
<br>
@@ -28,35 +28,41 @@ If you want to read about this reverse engineering attempt, check out
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;
fn main() {
let mut r701 = R701::connect("127.0.0.1:5005").unwrap();
#[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");
r701.into_record_iter()
.unwrap()
.collect::<Vec<_>>()
.iter()
.rev()
.enumerate()
.for_each(|(id, record)| {
let name = r701
.get_name(record.employee_id)
.unwrap()
.unwrap_or(format!("user #{}", record.employee_id));
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"),
);
});
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;
}
}
```
+7
View File
@@ -1,3 +1,10 @@
//! An async client library for the R701 attendance reader by I.P.S.
//! Informatica, reverse-engineered from its TCP protocol.
//!
//! Connect with [`R701::connect`], then read attendance records with
//! [`R701::into_record_iter`] and resolve employee names with
//! [`R701::get_name`].
mod r701;
mod record;
mod record_iterator;
+129 -49
View File
@@ -1,27 +1,60 @@
use crate::RecordIterator;
use std::io::{BufRead, BufReader, Error, ErrorKind::InvalidData, Result, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::io::{Error, ErrorKind::InvalidData, Result};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, ToSocketAddrs};
use tokio::sync::Mutex;
#[derive(Debug)]
pub struct R701 {
struct ConnectionState {
tcp_stream: TcpStream,
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 {
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
let mut new = Self {
tcp_stream: TcpStream::connect(connection_info)?,
sequence_number: 0,
let new = Self {
state: Mutex::new(ConnectionState {
tcp_stream: TcpStream::connect(connection_info).await?,
sequence_number: 0,
}),
};
// Try to ping the endpoint
new.ping()?;
new.ping().await?;
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
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);
// Insert the sequence number
request[14..].clone_from_slice(&self.sequence_number.to_le_bytes());
self.sequence_number += 1;
request[14..].clone_from_slice(&state.sequence_number.to_le_bytes());
state.sequence_number = state.sequence_number.wrapping_add(1);
// Send the request
self.tcp_stream.write_all(&request)?;
state.tcp_stream.write_all(&request).await?;
// Create a buffer and return the response
let mut buffer = BufReader::new(&self.tcp_stream);
Ok(buffer.fill_buf()?.to_vec())
// Read exactly response_len bytes, looping over multiple reads if
// the response arrives split across several TCP packets
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
// 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
// error
@@ -54,52 +97,69 @@ impl R701 {
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
// 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];
request[2..6].clone_from_slice(&id.to_le_bytes());
// 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
// 00 00` then the request is been succesful but the name was not found
if response.len() == 34
&& response[..12] == [0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
{
// If the header is `01 00 00 00 00 00 00 00` then the request has
// been succesful but the name was not found
if response[..12] == [0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa] {
return Ok(None);
}
// If one between the response length or the response header is wrong
// return an error
if response.len() != 34
|| response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
{
// If the response header is wrong return an error
if response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa] {
return Err(Error::new(InvalidData, "Malformed response"));
}
// Get the name as a UTF-8 string and delete the `\0` at the end
let name = String::from_utf8_lossy(&response[12..22])
.trim_end_matches('\0')
.to_string();
let name = match String::from_utf8(response[12..22].to_vec()) {
Ok(name) => name.trim_end_matches('\0').to_string(),
Err(error) => return Ok(Some(Err(error))),
};
// Return None if the name is empty, else return the name wrapped into a
// 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
// 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
if response.len() != 10
|| response[..4] != [0xaa, 0x55, 0x01, 0x01]
|| response[6..] != [0u8; 4]
{
if response[..4] != [0xaa, 0x55, 0x01, 0x01] || response[6..] != [0u8; 4] {
return Err(Error::new(InvalidData, "Malformed response"));
}
@@ -109,8 +169,20 @@ impl R701 {
Ok(u16::from_le_bytes(record_count_le))
}
pub fn get_record_bytes(
&mut self,
/// Fetches one 1024-byte block of raw record data.
///
/// `sequence_number` selects which block to fetch, except that `0`
/// always fetches the first block regardless of `total_records`,
/// following the device's own request convention. Used internally by
/// [`RecordIterator`] — most callers should use
/// [`R701::into_record_iter`] instead.
///
/// # Errors
///
/// Returns `Err` if the underlying request fails, or if the response
/// has an unexpected header or trailing bytes.
pub async fn get_record_bytes(
&self,
total_records: u16,
sequence_number: u16,
) -> Result<Vec<u8>> {
@@ -131,12 +203,11 @@ impl R701 {
}
// 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
// two bits is wrong return an error
if response.len() != 1038
|| response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
// If one between the response header or the last two bits is wrong
// return an error
if response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
|| response[1036..] != [0, 0]
{
return Err(Error::new(InvalidData, "Malformed response"));
@@ -146,7 +217,16 @@ impl R701 {
Ok(response[12..1036].to_vec())
}
pub fn into_record_iter(&mut self) -> Result<RecordIterator> {
RecordIterator::from(self)
/// Starts streaming all attendance records currently stored on the
/// device, newest first — fetching the most recent records first is
/// faster when you don't need the full history; reverse the order
/// yourself if you need chronological output.
///
/// # Errors
///
/// Returns `Err` if fetching the total record count or the first block
/// of record data fails.
pub async fn into_record_iter(&self) -> Result<RecordIterator<'_>> {
RecordIterator::from(self).await
}
}
+21 -9
View File
@@ -2,33 +2,45 @@ use chrono::{DateTime, Local, TimeZone};
#[cfg(feature = "serde")]
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))]
pub enum Clock {
#[default]
/// Entering during the first time slot
FirstIn,
/// Leaving during the first time slot
FirstOut,
/// Entering during the second time slot
SecondIn,
/// Leaving during the second time slot
SecondOut,
}
// unsafe impl Send for Clock {}
// unsafe impl Sync for Clock {}
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
/// A single attendance record read from the device.
///
/// Identifies the employee who punched, whether they were entering or
/// leaving, and when.
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Record {
/// The employee's numeric ID, as configured on the device
pub employee_id: u32,
/// Whether the employee was entering or leaving
pub clock: Clock,
/// When the record was punched
pub datetime: DateTime<Local>,
}
// unsafe impl Send for Record {}
// unsafe impl Sync for Record {}
impl TryFrom<&[u8]> for Record {
type Error = &'static str;
/// Parses a single 12-byte record as read from the device.
///
/// # Errors
///
/// Returns `Err` if `record_bytes` isn't exactly 12 bytes long, or if
/// the packed date/time bits don't form a valid calendar date.
fn try_from(record_bytes: &[u8]) -> Result<Self, Self::Error> {
// Return an error if the slice length is less than 12
if record_bytes.len() != 12 {
+70 -61
View File
@@ -1,86 +1,95 @@
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> {
r701: &'a mut R701,
input_buffer: Vec<u8>,
sequence_number: u16,
total_records: u16,
record_count: u16,
inner: Pin<Box<dyn Stream<Item = Result<Record>> + '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
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
// the index of the first `ff` byte, avoiding overflows
//
// TODO: Find a better way to do the multiplication and the division
// 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
// 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 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;
// 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
let input_buffer = r701
.get_record_bytes(total_records, sequence_number)?
.drain(..first_useless_byte_index)
.collect::<Vec<u8>>();
let mut input_buffer = r701
.get_record_bytes(total_records, sequence_number)
.await?;
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
Ok(Self {
r701,
input_buffer,
sequence_number,
total_records,
record_count: total_records,
inner: Box::pin(inner),
})
}
}
impl<'a> Iterator for RecordIterator<'a> {
type Item = Record;
impl Stream for RecordIterator<'_> {
type Item = Result<Record>;
fn next(&mut self) -> Option<Self::Item> {
// Stop iterating when there are no more records
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()
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.inner.as_mut().poll_next(cx)
}
}
+62
View File
@@ -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());
}
+174
View File
@@ -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
}