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.
|
||||
# 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"
|
||||
@@ -30,10 +52,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.0.96"
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
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]]
|
||||
name = "cfg-if"
|
||||
@@ -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.154"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346"
|
||||
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"
|
||||
@@ -120,10 +165,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.81"
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
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 = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -139,26 +190,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "r701"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"chrono",
|
||||
"serde",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.200"
|
||||
version = "1.0.203"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f"
|
||||
checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.200"
|
||||
version = "1.0.203"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb"
|
||||
checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -166,22 +220,75 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.60"
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
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 = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"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"
|
||||
|
||||
+19
-4
@@ -1,13 +1,28 @@
|
||||
[package]
|
||||
name = "r701"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
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.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]
|
||||
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
|
||||
> [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/>.
|
||||
|
||||
## 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 record;
|
||||
mod record_iterator;
|
||||
|
||||
+129
-49
@@ -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 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
|
||||
}
|
||||
}
|
||||
|
||||
+30
-17
@@ -2,37 +2,49 @@ 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 {
|
||||
return Err("Slice must be at least of length 12 to be converted into Record");
|
||||
if record_bytes.len() != 12 {
|
||||
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
|
||||
@@ -48,6 +60,7 @@ impl TryFrom<&[u8]> for Record {
|
||||
let datetime = u32::from_le_bytes(datetime);
|
||||
|
||||
// Return a new Record
|
||||
#[allow(clippy::cast_possible_wrap)]
|
||||
Ok(Self {
|
||||
employee_id,
|
||||
clock: match record_bytes[1] >> 6 {
|
||||
@@ -55,7 +68,7 @@ impl TryFrom<&[u8]> for Record {
|
||||
1 => Clock::FirstOut,
|
||||
2 => Clock::SecondIn,
|
||||
3 => Clock::SecondOut,
|
||||
_ => panic!("Math has broken"),
|
||||
_ => unreachable!("Math has broken"),
|
||||
},
|
||||
datetime: Local
|
||||
.with_ymd_and_hms(
|
||||
@@ -94,9 +107,9 @@ mod tests {
|
||||
datetime: Local
|
||||
.with_ymd_and_hms(1970, 1, 1, 0, 0, 0)
|
||||
.single()
|
||||
.unwrap(),
|
||||
.expect("Datetime is not unique!"),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -105,8 +118,8 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
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]
|
||||
@@ -116,6 +129,6 @@ mod tests {
|
||||
assert_eq!(
|
||||
record_bytes.try_into(),
|
||||
Err::<Record, &str>("DateTime conversion error")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+71
-58
@@ -1,82 +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
|
||||
let sequence_number = (total_records as u32 * 12 / 1024) as u16;
|
||||
// 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 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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