zkteco 1.0.0

A pure-Rust client for ZKTeco biometric attendance / access-control terminals (TCP/UDP protocol). A faithful port of the Python `pyzk` library.
Documentation
//! The [`User`] record and its on-wire (de)serialization.
//!
//! ZKTeco devices use two user record layouts depending on firmware generation:
//! a compact **28-byte** "ZK6" layout and a larger **72-byte** "ZK8" layout. The
//! crate auto-detects which one a device uses (see [`crate::Zk`]).
//!
//! ## Encoding
//! Names/passwords are encoded as UTF-8 (lossy on the way out, lossy on the way
//! in). This covers the overwhelming majority of deployments; exotic legacy code
//! pages (e.g. GBK) are not supported in order to keep the crate dependency-free.

use crate::protocol::{u16_le, u32_le, wr};
use std::fmt;

/// Standard (non-admin) privilege level.
pub const USER_DEFAULT: u8 = 0;
/// Administrator privilege level.
pub const USER_ADMIN: u8 = 14;

/// A user enrolled on the device.
///
/// Mirrors `pyzk`'s `User`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
    /// Internal device index (assigned by the device / this crate).
    pub uid: u16,
    /// Display name.
    pub name: String,
    /// Privilege bitfield. `0` = normal, `14` = admin; bit 0 set = disabled.
    pub privilege: u8,
    /// Numeric password as a string (empty if none).
    pub password: String,
    /// Group id (as a string; device-defined).
    pub group_id: String,
    /// The user's own ID / PIN shown on the keypad.
    pub user_id: String,
    /// Card number (0 if none).
    pub card: u32,
}

impl User {
    /// Construct a user record.
    pub fn new(
        uid: u16,
        name: impl Into<String>,
        privilege: u8,
        password: impl Into<String>,
        group_id: impl Into<String>,
        user_id: impl Into<String>,
        card: u32,
    ) -> Self {
        User {
            uid,
            name: name.into(),
            privilege,
            password: password.into(),
            group_id: group_id.into(),
            user_id: user_id.into(),
            card,
        }
    }

    /// `true` if the user is disabled (bit 0 of the privilege field).
    pub fn is_disabled(&self) -> bool {
        self.privilege & 1 != 0
    }

    /// `true` if the user is enabled.
    pub fn is_enabled(&self) -> bool {
        !self.is_disabled()
    }

    /// The privilege "type" (privilege with the disabled bit masked out).
    pub fn usertype(&self) -> u8 {
        self.privilege & 0xE
    }

    /// Pack into the 29-byte ZK6 record used by the bulk
    /// [`crate::Zk::save_user_templates`] upload (a `0x02` tag plus the 28-byte
    /// body). Layout `<B H B 5s 8s I x B h I>`.
    pub(crate) fn repack29(&self) -> Vec<u8> {
        let group = self.group_id.parse::<u8>().unwrap_or(0);
        let user_id = self.user_id.parse::<u32>().unwrap_or(0);
        wr(|w| {
            w.u8(2) // record tag
                .u16(self.uid)
                .u8(self.privilege)
                .fixed(self.password.as_bytes(), 5)
                .fixed(self.name.as_bytes(), 8)
                .u32(self.card)
                .pad(1) // 'x'
                .u8(group)
                .u16(0) // signed short 0 (timezone slot)
                .u32(user_id);
        })
    }

    /// Pack into the 73-byte ZK8 record. Layout `<B H B 8s 24s I B 7s x 24s>`.
    pub(crate) fn repack73(&self) -> Vec<u8> {
        wr(|w| {
            w.u8(2) // record tag
                .u16(self.uid)
                .u8(self.privilege)
                .fixed(self.password.as_bytes(), 8)
                .fixed(self.name.as_bytes(), 24)
                .u32(self.card)
                .u8(1)
                .fixed(self.group_id.as_bytes(), 7)
                .pad(1) // 'x'
                .fixed(self.user_id.as_bytes(), 24);
        })
    }

    /// Parse one 28-byte ZK6 user record. Layout `<H B 5s 8s I x B h I>`.
    pub(crate) fn parse_zk6(rec: &[u8; 28]) -> User {
        let uid = u16_le(&rec[0..2]);
        let privilege = rec[2];
        let password = decode_cstr(&rec[3..8]);
        let name = decode_cstr(&rec[8..16]).trim().to_string();
        let card = u32_le(&rec[16..20]);
        // rec[20] is padding 'x'
        let group_id = rec[21].to_string();
        // rec[22..24] is the signed-short timezone slot (ignored)
        let user_id_num = u32_le(&rec[24..28]);
        let user_id = user_id_num.to_string();

        let name = if name.is_empty() {
            format!("NN-{user_id}")
        } else {
            name
        };
        User::new(uid, name, privilege, password, group_id, user_id, card)
    }

    /// Parse one 72-byte ZK8 user record. Layout `<H B 8s 24s I x 7s x 24s>`.
    pub(crate) fn parse_zk8(rec: &[u8; 72]) -> User {
        let uid = u16_le(&rec[0..2]);
        let privilege = rec[2];
        let password = decode_cstr(&rec[3..11]);
        let name = decode_cstr(&rec[11..35]).trim().to_string();
        let card = u32_le(&rec[35..39]);
        // rec[39] padding 'x'
        let group_id = decode_cstr(&rec[40..47]).trim().to_string();
        // rec[47] padding 'x'
        let user_id = decode_cstr(&rec[48..72]);

        let name = if name.is_empty() {
            format!("NN-{user_id}")
        } else {
            name
        };
        User::new(uid, name, privilege, password, group_id, user_id, card)
    }
}

impl fmt::Display for User {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "<User>: [uid:{}, name:{} user_id:{}]",
            self.uid, self.name, self.user_id
        )
    }
}

/// Decode a fixed-width, NUL-terminated field to a `String` (UTF-8, lossy).
///
/// Everything up to the first `\0` is the value; trailing bytes are padding.
fn decode_cstr(field: &[u8]) -> String {
    let end = field.iter().position(|&b| b == 0).unwrap_or(field.len());
    String::from_utf8_lossy(&field[..end]).into_owned()
}